Skip to content

Commit a3493ed

Browse files
jitokimoh-my-graph
andauthored
feat(handoff): a feedback arc and its quote are one mechanism — sweep it, and refuse the planned one (#195)
* docs(adr): a feedback arc and its quote are one mechanism ADR 0028. `feedback: { rerun: R }` and `{{ feedback.<declarer> }}` are two halves of ADR 0010's repair loop, and until now either half loaded clean alone: validateFeedback judges the arc, validateFeedbackPlaceholders judges a token that IS written, LintFeedbackReach judges the arc's aim — none asks whether both halves are present. Run 20260816-163759.091162000-1 is the specimen: a correct arc, a build prompt that branches on "if a FEEDBACK section appears below", and no token to put one there. The loop re-ran, produced identical output, and failed identically. Twice the money, one round's worth of information, and `lint` said nothing. The decision is a sixth advisory sweep in internal/handoff, warning on the rerun target and naming the declarer, matched with the runtime's own placeholder pattern so it holds after fragment splicing. Measured over three corpora first (docs/measurements/0028-feedback-quote-corpus.md): 11 declarers, 3 hits, all 3 real, 0 noise — and the corpus contains the specimen's own repair three minutes later, on which the predicate is correctly silent. No shipped graph fires. Co-Authored-By: oh-my-graph <graphs@oh-my-graph.dev> Signed-off-by: jitokim <pigberger70@gmail.com> * feat(handoff): warn when a feedback loop's repair never quotes the feedback The sixth advisory sweep, ADR 0028. 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. R is where the missing line goes, so that is where the line lands. A middle body node counts — `build → refine → check` quoted at `refine` really does repair, just not at the loop's first node — and the declarer's own quote does not: it is the judge, so its re-run repairs nothing, it only re-judges unchanged artifacts while being reminded of its own findings. Only prompts are read: a payload on a verify command line is LintVerifyInlining's finding and one in a cwd is a path. Matched with the runtime's own placeholderPattern rather than a formatted string, so what the sweep counts as a quote and what Interpolate splices cannot drift, and a namespaced id survives fragment splicing — the specimen's real token was `{{ feedback.qa-a/check }}`. Both shapes are tested, the spliced one through the real loader over a real fragment. Wired into the one warnAdvisories helper `lint` and `run --dry-run` share. Advisory, never a load error: the planner cannot author a `feedback:` at all, so only a person can write what this condemns. No runtime behaviour changed. Co-Authored-By: oh-my-graph <graphs@oh-my-graph.dev> Signed-off-by: jitokim <pigberger70@gmail.com> * docs(adr): the planner writes feedback arcs too, so auto mode refuses the blind one ADR 0028 said twice that `coordinator.validatePlannedNodes` refuses a planner-authored `feedback:` outright, and concluded from it that only a person can write what the sweep condemns and that there was "no auto-mode counterpart to add". Both are false. The coordinator CONSTRAINS a planned arc (max, reach) and the planner prompt asks for this very pairing in prose — "declare a feedback arc on the reviewing node … and have the implementing node's prompt read {{ feedback.<reviewing-node-id> }}" — with only the first half machine-checked. Measured: 3 of the 11 declarers in the run corpus are planner-authored. So the escalation the ADR declared unnecessary is taken here, on validatePlannedFeedbackReach's precedent: validatePlannedFeedbackQuoting refuses a planned arc whose loop body never quotes the payload, reading the same predicate through the new handoff.FeedbackQuoteFindings rather than re-deciding it. Its warrant is weaker than the reach rule's (no planner failure of this shape is measured) and its price of being wrong is lower than anywhere else in that validator: one re-plan, and a placeholder that is harmless when ignored because the namespace resolves to empty on the first pass. The refusal names both ends and the token, and tells the planner not to make the work conditional on a feedback section appearing — that "fix" is the specimen written back. Also handled, all recorded in the ADR: - the sweep is on `lint`/`run --dry-run` only, never a plain `run`, so the cost narrative no longer reads as if it stands between the author and the bill — and DESIGN.md's repo-layout line, which claimed `run` prints the sweeps against internal/graph/fragment.go's own statement that it does not, is fixed; - "observe it at run time" is separated from "refuse it at run time" and kept as a zero-false-positive successor, out of scope rather than rejected; - "3 hits, 0 noise" carries its caveat on the number: three lanes of ONE graph, one distinct control; - a loop FRAGMENT cannot be linted at all (measured: lint on repair-round.yaml exits 1 on an unbound substitution), so ownership of the warning is inverted and the corpus's fragment exclusion is blindness, not rigour; - the topological false negative (ancestry is not data dependency) is named; - so is the fact that none of the six advisories can be silenced; - "a citing graph cannot edit a spliced prompt" is narrowed to loop splices, since substitutions:/with: exist precisely to let it. TestLintFeedbackQuoting_ShippedGraphsAreClean walks graphs/*.yaml so the "no shipped graph fires" claim fails in the suite rather than decaying quietly; it covers the repair-round fragment too, spliced, via adr-driven-dev.yaml. Co-Authored-By: oh-my-graph <graphs@oh-my-graph.dev> Signed-off-by: jitokim <pigberger70@gmail.com> * fix(handoff): a filtered feedback token is not a quote, and the corpus keeps its denominators apart Three findings from review, none of them changing what the sweep decides on any graph that can be loaded today: - bodyQuotesFeedback read groups[1]/groups[2] and ignored the filter group, so `{{ feedback.D | inline }}` counted as a satisfied quote. The runtime does the opposite — resolveLocked errors on a filtered feedback token and graph.Validate refuses it at load — so that was the one seam where the sweep and the runtime could disagree, calling WIRED a loop the runtime refuses to run. Guarded, with the load-time invariant it leans on named and pinned: the new test asserts that graph.Parse still refuses the token, which is what makes the guard unreachable rather than load-bearing. - The measurement's headline folded three corpora into one corpus's denominator ("3 times in 11") while its own table totalled 15 declarers. 11 is the run-snapshot corpus alone; the split is now stated per corpus, and the three later sentences that meant "run corpus" say so. - dist/ is an untracked goreleaser artifact directory one `git add -A` away from being committed. Ignored. Co-Authored-By: oh-my-graph <graphs@oh-my-graph.dev> Signed-off-by: jitokim <pigberger70@gmail.com> * fix(coordinator): the two graph-level refusal families share one prompt budget, so bound it Review found that the ordering comment in validatePlannedNodes reasons about "one ~600-char graph-level sentence vs. 83-172-char per-node ones" — arithmetic written when there was one such family. There are now two, both fire on one arc independently (ADR 0028 §Failure modes), and both scale per declarer, so they crowd EACH OTHER rather than being crowded by the per-node refusals the ordering outranks. Measured on the new twoBrokenLanesSpec fixture: 677 + 677 for reach, 641 for quoting, 1998 bytes against a 2000-byte maxIssuesInPrompt — this ADR's own three-lane specimen would have blown it alone, and one per-node slip beside two lanes does. Past the cut the damage was invisible from both ends: fence.Truncate is head-only, so the last kept refusal ended mid-sentence and every later one vanished without trace. The single repair a refused plan buys was then spent on a prompt that never stated part of the fault. Three bounds, in the order they matter: - validatePlannedFeedbackQuoting emits ONE refusal for every blind arc in the graph, naming each declarer/rerun pair and each token to paste, instead of repeating a ~500-byte diagnosis per declarer. Four arcs: 753 bytes, from 2272. The same compaction validatePlannedFeedbackReach makes across producers, one level up. - maxIssuesInPrompt is sized from the two families rather than picked: 3000 holds three declarers faulty both ways with room for per-node refusals. - issuesForPrompt replaces the head-only cut — whole refusals from the front, and the dropped count stated in the prompt. Half a refusal names a node and stops before the correction, which is an instruction to guess. Tests for what the review exposed, all of which fail on the prior code: the two-arcs-both-rules case survives a crowded repair prompt (it loses the whole quoting family at the old budget); the "reach leads quoting leads per-node" order is pinned on the refusal list itself, not through a rendered prompt; the compacted refusal is held to naming both ends of both pairs and to reading as English in the plural; and issuesForPrompt is pinned on whole-refusal packing, on a disclosure count that agrees with what was dropped, and on the one unavoidable cut (a single refusal larger than the whole budget). Co-Authored-By: oh-my-graph <graphs@oh-my-graph.dev> Signed-off-by: jitokim <pigberger70@gmail.com> * docs(changelog): a release section carries one Fixed block, not two This branch opened a `### Fixed` under `[Unreleased]` without noticing the one #193 already put there, so the section carried two same-level headings between `## [Unreleased]` and `## [v0.9.0]`. scripts/release-notes.sh excerpts a version's section verbatim into the published body, so the next release page would have shown "Fixed" twice with the entries split across them — which is exactly the release-notes quality #193 and #194 were fixing. The three entries are one list; the second heading is gone. Co-Authored-By: oh-my-graph <graphs@oh-my-graph.dev> Signed-off-by: jitokim <pigberger70@gmail.com> * fix(coordinator): one rendering, one measured size, with an address Three files described the same rendering — two declarers each mis-aimed AND blind — at three different sizes, and one of them contradicted the others outright. repair.go said the pair rendered ~2033 bytes and that a family "was cut"; repair_budget_test.go said the two families render 1998 and FITTED the old 2000 budget with two bytes to spare (which is why its fixture pads with five per-node refusals); coordinator.go said 2488. The 2033/2780 pair were rounded per-family estimates (700x2 + 640), and they were the only argument on record for maxIssuesInPrompt being 3000. Re-measured on twoBrokenLanesSpec and its three- and four-lane extensions, with each figure labelled by which side of the compaction it describes: reach, per declarer 677 quoting, per arc, uncompacted 592 quoting, compacted 641 (2 arcs) / 701 (3) / 761 (4) both families joined, 2 lanes 2541 uncompacted, 1997 compacted both families joined, 3 lanes 2735 compacted So the two states were never comparable: uncompacted the pair overran the old 2000 budget and a family WAS cut, compacted it fits by three bytes and the shortest per-node refusal beside it takes it over. Both readings were right about different renderings and neither said which. TestGraphLevelRefusalFamiliesRenderTheirMeasuredSize pins every reachable figure, so a reworded refusal fails a test rather than leaving four comments, an ADR and a CHANGELOG entry quietly false — which is what happened here. It also pins the claim the budget rests on: three declarers faulty both ways fit inside maxIssuesInPrompt. ADR 0028 Failure modes carried the arithmetic slip that exposed this: it summed its own 677 + 677 + 641 as 1998 where the join is 1997. Co-Authored-By: oh-my-graph <graphs@oh-my-graph.dev> Signed-off-by: jitokim <pigberger70@gmail.com> * fix(coordinator): the compacted refusal reads in the plural to the end of the sentence plannedFeedbackQuoteRefusal pluralises where it names the list — the verbs, the possessives, the target of the paste — but the PLACEMENT clause kept a singular it never pluralised: "at the end of each of those prompts and after that node's verdict contract". This is a prompt the one repair call a refused plan buys has to act on, and that clause is the actionable half; "that node's" beside a plural "each of those prompts" names a node the planner has to guess at. %[8]s is plural(n, "that node's", "those nodes'"). Singular output is unchanged byte for byte. TestPlan_TwoBlindArcsCostOneRefusal already read the plural rendering for exactly this class of leftover and did not cover this clause; it now checks every clause that refers back to the list, and that the plural one names whose verdict contract to paste after. "those nodes'" is one byte longer than "that node's", which moves every measured figure for n >= 2 by one — the pin test added in the previous commit caught it and named all six. Comments, ADR and CHANGELOG follow it: quoting 642/702/762 for two/three/four arcs, and 1998 / 2736 / 3474 joined with the reach family. Co-Authored-By: oh-my-graph <graphs@oh-my-graph.dev> Signed-off-by: jitokim <pigberger70@gmail.com> * docs(handoff): six sweeps, a comment that matches the path it describes, and the sibling return style Three small corrections, none of them behavioural: - DESIGN.md's fragment-disclosure paragraph still said "the four *handoff* sweeps stay lint-only". It has been drifting since the fifth landed; this branch updated the repo-layout line and lint.go's "five" to six and left this one. Six: LintPlaceholders, LintSessions, LintVerdicts, LintToolGrants, LintVerifyInlining, LintFeedbackQuoting. - TestLintFeedbackQuoting_QuietWhereThereIsNoLoop claimed its second fixture exercised "an arc whose target is not an ancestor". It does not: a &graph.Graph{Nodes: ...} literal never runs fromRaw, so byID is nil and FeedbackBody returns at its FIRST lookup, not at the ancestor check. The guard under test is the same either way (delete it and the test fails), but the comment named a branch the case cannot reach — the same struct-construction trap already corrected once in this branch. It now says which return it takes and why the ancestor branch is unreachable from any loading path. - LintFeedbackQuoting normalised its empty case by hand (make+len check+nil). Its siblings LintVerifyInlining and LintToolGrants declare `var warnings []Warning` and get the same nil for free; matching them drops the allocation and the branch. Co-Authored-By: oh-my-graph <graphs@oh-my-graph.dev> Signed-off-by: jitokim <pigberger70@gmail.com> * chore: this branch is about feedback quoting, not about goreleaser's output The `/dist/` ignore rule rode in on 912973a with a commit about lint sweeps and corpus denominators. It is a fine rule and a wrong place: nothing about feedback quoting touches release artifacts, and mixing them makes the branch's history unreadable to anyone bisecting either subject. Moved verbatim to the local branch chore/gitignore-dist, cut from main, so nothing is lost — it wants its own PR. Co-Authored-By: oh-my-graph <graphs@oh-my-graph.dev> Signed-off-by: jitokim <pigberger70@gmail.com> * docs(coordinator): the pinned sizes carry their own repair instruction TestGraphLevelRefusalFamiliesRenderTheirMeasuredSize is exact on purpose — a reworded refusal is EXPECTED to fail it — but the instruction for what to do about that failure lived only in the comment above the test. The author who sees it sees a red line in a terminal, not that comment, so the failure now names the four places the numbers have to be carried to and the table that has to be updated. And say, where len(Issues) is defined, that it counts refusals rather than faults: the reach family emits one per declarer while the quoting family compacts every blind arc into one, so the two now count differently in the same number and anything reporting it to a human has to keep saying "refusal". Co-Authored-By: oh-my-graph <graphs@oh-my-graph.dev> Signed-off-by: jitokim <pigberger70@gmail.com> * fix(coordinator): the truncation that omitted refusals without saying so Review finding, and it is the failure issuesForPrompt exists to prevent, reached through its own edge case. When the first refusal fits inside the budget alone but not alongside the omission note, the kept-count loop ends at zero and the old code truncated the JOINED list. The later refusals then vanished AND their count vanished with them: the planner was told less than it had been refused for, and nothing said so — a re-plan answering a subset of the reasons it was rejected for. The note is now reserved first and the first refusal bounded to what remains, so what the planner loses is the tail of one refusal, which the truncation marker announces, plus a count of the rest, which the note announces. Both losses are disclosed; neither is silent. TestIssuesForPrompt_FirstRefusalTooLongStillDisclosesTheRest pins the boundary. Mutation-checked: restoring the old branch turns it red. Two smaller review findings: The measurement utility decided planner provenance with `run in src`, a SUBSTRING — any path merely containing the run id would have counted as planner-authored. A measurement tool looser than what it measures is not evidence. Re-measured with an exact path comparison and the split is unchanged (rows=7 planner=3 hand=4, declarers 3 and 8), which is the only reason the number is still quoted. Output fences in ADR 0028 and the measurement now carry a language tag. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: jitokim <pigberger70@gmail.com> --------- Signed-off-by: jitokim <pigberger70@gmail.com> Co-authored-by: oh-my-graph <graphs@oh-my-graph.dev>
1 parent 874c9c2 commit a3493ed

13 files changed

Lines changed: 2398 additions & 24 deletions

CHANGELOG.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,106 @@ oh-my-graph is **alpha software**. The graph YAML schema, the CLI, and the
1010

1111
## [Unreleased]
1212

13+
### Added
14+
15+
- **`lint` / `run --dry-run` warn when a feedback loop's repair node never
16+
quotes the feedback** (ADR 0028). `feedback: { rerun: R }` and
17+
`{{ feedback.<declarer> }}` are two halves of one mechanism, and until now
18+
either half loaded clean alone: the arc's topology is validated, a token that
19+
IS written is validated, the arc's aim is swept for — nothing asked whether
20+
both halves are present. Run `20260816-163759.091162000-1` is what that costs.
21+
A two-node loop declared its arc correctly, its build prompt said "if a
22+
FEEDBACK section appears below, write alpha", and no token existed to put one
23+
there. The engine wrote the payload, re-ran the build, got identical output,
24+
and the check failed identically. Twice the money, one round's worth of
25+
information, ledger reading `feedback round 1/1`, and `lint` silent.
26+
27+
`handoff.LintFeedbackQuoting` is the sixth advisory sweep in that package,
28+
wired into the one `warnAdvisories` helper `lint` and `run --dry-run` share.
29+
The rule: for every node `D` declaring `feedback: { rerun: R }`, if no node in
30+
the loop body other than `D` itself quotes `{{ feedback.D }}` in its **prompt**,
31+
warn — on `R`, naming `D`, because `R`'s prompt is where the missing line
32+
goes. A middle body node counts (`build → refine → check` quoted at `refine`
33+
really does repair); the declarer's own quote does not (it is the judge, so
34+
its re-run repairs nothing). Only prompts are read: a payload on a verify
35+
command line is the fifth sweep's finding, and one in a `cwd` is a path.
36+
Matching uses the runtime's own placeholder pattern, so the sweep holds after
37+
fragment splicing — the specimen's real token was
38+
`{{ feedback.qa-a/check }}`, a namespaced id the loader wrote — and both
39+
shapes are tested. Advisory for a hand-written graph, never a load error: an
40+
absent token has one legitimate reading (a loop that repairs from the
41+
repository rather than from the reply), where the misplaced token ADR 0010
42+
made an error has none.
43+
44+
- **Auto mode refuses a planned feedback arc nothing in its loop body quotes**
45+
(ADR 0028 §5). The planner is *asked* for both halves in one prompt sentence —
46+
declare the arc on the reviewing node, and have the implementing node's prompt
47+
read `{{ feedback.<reviewing-node-id> }}` — and until now only the arc half was
48+
machine-checked (`coordinator.validatePlannedNodes` constrains a planned
49+
`feedback:`; it never refused one). So the blind loop's worst instance, the one
50+
with no author to read a warning, was the case left uncovered.
51+
`coordinator.validatePlannedFeedbackQuoting` escalates the sweep to a plan
52+
refusal the same way `validatePlannedFeedbackReach` escalates
53+
`graph.LintFeedbackReach`, reading the same predicate rather than re-deciding
54+
it. A refused plan buys one corrected re-plan carrying the refusal's text, and
55+
the correction — one placeholder, empty on the first pass — is harmless even
56+
when the refusal is wrong, which is why this one needs no
57+
only-when-actionable weakening.
58+
59+
**It was measured before it shipped, and it has a control.** Over the shipped
60+
`graphs/*.yaml` (8 graphs, 2 declarers), a 26-lane operator corpus (2
61+
declarers) and 288 local run snapshots deduplicated to 201 distinct resolved
62+
graphs (11 declarers): **3 hits, all 3 real, 0 noise** — with the caveat
63+
attached to the number rather than to a later paragraph: the 3 are the three
64+
*lanes* of one specimen graph in one run, so the precision evidence is one
65+
distinct defective graph, not three independent ones. The same corpus holds
66+
that graph's repair, three minutes later, and the sweep is correctly silent on
67+
it. Of the 11 run-corpus declarers, **3 were planner-authored** (auto mode's `graph.json`)
68+
and all 3 quoted the payload correctly — the escalation above guards a shape
69+
the planner *can* write, not one it has been measured getting wrong. No shipped
70+
graph fires; nothing in `graphs/` needed fixing, and a test now walks
71+
`graphs/*.yaml` so that stays true. Full method, every number asserted rather
72+
than reported:
73+
[docs/measurements/0028-feedback-quote-corpus.md](docs/measurements/0028-feedback-quote-corpus.md).
74+
75+
No runtime behaviour changed: feedback's semantics, payload file, round
76+
accounting and exit codes are untouched. The auto-mode refusal changes what
77+
`auto` accepts as a plan, not how a graph runs — and note the reach of the
78+
advisory half: `lint` and `run --dry-run` print it, a plain `run` does not, so
79+
an operator who does not lint first still pays for a blind loop.
80+
1381
### Fixed
1482

83+
- **A repair prompt no longer loses whole refusals to a silent cut.** Auto mode
84+
hands a refused plan's refusals back to one corrected planner call, quoted into
85+
a fenced section bounded by `maxIssuesInPrompt`. That bound was applied by
86+
head-only truncation of the joined list, which on an over-long list left the
87+
last refusal it kept ending mid-sentence and dropped every later one with no
88+
trace — so the planner answered a prompt that never stated part of the fault,
89+
the corrected reply re-committed it, and the plan the user paid for was gone.
90+
The list is now packed in WHOLE refusals with the dropped count stated in the
91+
prompt, and the budget is sized (3000, from 2000) rather than picked.
92+
93+
The fault was reachable because two refusal families are graph-level and both
94+
scale with the number of faulty arcs: a mis-aimed arc and a blind one can be
95+
the same arc (ADR 0028 §Failure modes), and two such declarers rendered 2541
96+
bytes into a 2000-byte budget before a single per-node refusal joined them.
97+
`coordinator.validatePlannedFeedbackQuoting` now compacts every blind arc into
98+
one sentence naming each pair — four arcs cost 762 bytes instead of 2368 —
99+
keeping the shared ~530-byte diagnosis out of the repeat, which brings the
100+
same two declarers to 1998. Every one of those figures is measured on one
101+
fixture and pinned by
102+
`TestGraphLevelRefusalFamiliesRenderTheirMeasuredSize`, so a reworded refusal
103+
fails a test instead of leaving a comment quietly false.
104+
105+
- **`{{ feedback.D | inline }}` no longer counts as quoting a feedback payload.**
106+
`handoff.LintFeedbackQuoting` read the placeholder pattern's kind and reference
107+
and ignored its filter group, where the runtime refuses a filtered feedback
108+
token outright (`graph.Validate` at load, `Handoff.Interpolate` at run). No
109+
graph that can be loaded today reaches it — both callers sweep a parsed
110+
graph — so nothing observable changes; the guard and its test exist so the one
111+
case where the sweep and the runtime could disagree cannot open up quietly.
112+
15113
- **A release's page can no longer come out blank**
16114
([#193](https://github.com/jitokim/oh-my-graph/pull/193)). v0.9.0 published
17115
with an empty body — one newline — while every step reported success and the

DESIGN.md

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,7 @@ print one disclosure line per resolved fragment (source file + the fragment's
411411
own description + every overridden key, or — for a multi-node splice, which
412412
overrides nothing — the ids it spliced) plus the same fragment advisories on
413413
the warning channel (`run` discloses what it spliced, so it discloses the
414-
drift smell too; the four *handoff* sweeps stay lint-only), the snapshot stores the re-encoded
414+
drift smell too; the six *handoff* sweeps stay lint-only), the snapshot stores the re-encoded
415415
**resolved** graph whenever any node resolved a fragment (so resume never
416416
re-reads a fragment; `GraphSHA256` still hashes the entry file's bytes), and
417417
scheduler, handoff, the event stream and every consumer reading it see
@@ -456,6 +456,26 @@ golden, and the reviewer sees all four.
456456
consumer contract, in its own directory so it can never collide with an
457457
artifact (node ids allow dots, so a node named `x.feedback` is legal); the
458458
`.out` artifact keeps meaning "a *passed* node's result".
459+
The arc and the token are two halves of one mechanism, so `lint` /
460+
`run --dry-run` warn when a loop declares the first and never writes the
461+
second (`handoff.LintFeedbackQuoting`, ADR 0028): if nothing in the body
462+
except the declarer itself quotes `{{ feedback.<declarer> }}` in its
463+
**prompt**, the re-run is handed the prompt it already ran, produces the same
464+
output, and the declarer fails again for the same reason — twice the money,
465+
the same result, and nothing else in the engine has anything to say about it.
466+
The warning lands on the rerun target, because that is where the missing line
467+
goes. Advisory, not a load error: a loop whose re-run reads the repository
468+
rather than the reply is a legitimate, if rare, shape. For **planner output**
469+
it is a plan **refusal** instead (`coordinator.validatePlannedFeedbackQuoting`
470+
— the same advisory-here/refusal-there split `LintFeedbackReach` has): the
471+
planner is asked for the arc and the quote in one prompt sentence, nobody runs
472+
`lint` on a graph `auto` planned and ran in the same breath, and the
473+
correction — one placeholder, empty on the first pass — costs nothing even
474+
when the refusal is wrong.
475+
Note the reach of *any* of these sweeps: they are printed by `lint` and
476+
`run --dry-run` only. A plain `run` loads the graph and starts spending
477+
without them, which is why a defect this sweep can see is still paid for by an
478+
operator who did not lint first.
459479

460480
## Node-as-subagent (`agent:` — hand-written graphs, plus coordinator auto-mapping)
461481
A node may set `agent: <name>` to run as one of the user's OWN Claude Code
@@ -2345,7 +2365,7 @@ turns that rule into a build failure. Current dispositions:
23452365
| `with` | **rejected**`use`'s substitution bindings, on the same grounds: dead without a `use:`, and a `with:` on a planned node means the plan tried to reference a fragment at all |
23462366
| `budget_usd`, `timeout` | allowed |
23472367
| `retry` | constrained — bounded re-runs of an already-ceilinged node, but a planned `max` above `maxPlannedRetries` (3) is rejected: `verify_failed` is a legal cause, so retry count is the one lever planner output still has on an injected evidence command's execution (ADR 0016 §2) |
2348-
| `feedback` | constrained — `retry`'s standing one level up: bounded re-runs of body nodes already inside every ceiling, granting no tool, no path, no shell; the load validations hold for a planned graph exactly as for a hand-written one, but two things they leave open are closed here (ADR 0010). **max**: only `max` ≥ 1 is required at load and a plan has no human reviewer for the upper bound, so a planned `max` above `maxPlannedFeedbackRounds` (3) is rejected. **Reach**: an arc on a fan-in declarer may name a target whose body excludes a producer the declarer judges — valid, and unable to converge (#118) — so `validatePlannedFeedbackReach` refuses it whenever `graph.LintFeedbackReach` found a covering target, naming that target in the refusal |
2368+
| `feedback` | constrained — `retry`'s standing one level up: bounded re-runs of body nodes already inside every ceiling, granting no tool, no path, no shell; the load validations hold for a planned graph exactly as for a hand-written one, but two things they leave open are closed here (ADR 0010). **max**: only `max` ≥ 1 is required at load and a plan has no human reviewer for the upper bound, so a planned `max` above `maxPlannedFeedbackRounds` (3) is rejected. **Reach**: an arc on a fan-in declarer may name a target whose body excludes a producer the declarer judges — valid, and unable to converge (#118) — so `validatePlannedFeedbackReach` refuses it whenever `graph.LintFeedbackReach` found a covering target, naming that target in the refusal. **The quote**: an arc whose loop body never quotes `{{ feedback.<declarer> }}` re-runs a prompt that cannot have changed, for every round of `max` — valid, and ADR 0028's specimen — so `validatePlannedFeedbackQuoting` refuses it, naming the token to paste and the prompt it belongs in |
23492369

23502370
Both mechanisms apply ONLY to coordinator-planned graphs; hand-written YAML
23512371
(`oh-my-graph run`) is human-authored/reviewed, passes a nil deny list, and is
@@ -2588,7 +2608,7 @@ internal/invariants/exec_seam_test.go test-only: asserts only the four
25882608
internal/childenv/childenv.go + _test the shared "delete billing-switching vars" child-env policy (all four spawners)
25892609
internal/fence/fence.go + _test the shared data fence: a per-call crypto/rand nonce for both markers of any quote of untrusted text into a prompt, plus the head+tail bound on the quoted material. Its callers live in coordinator and schedule, and their number is stated in fence.go alone — internal/invariants counts the real ones repo-wide against that one sentence, so a second copy here would be a number nothing checks
25902610
internal/coordinator/{coordinator,router,agentmap,agentstage,skillscan,skillstage,goal,assess,repair}.go + _test auto mode: goal → planner call (NodeRunner seam) → validated graph + ToolPolicies; chat routing; post-validation subagent mapping with its definition staged (agentmap.go/agentstage.go — ADR 0022) and skill activation over a staged plugin directory (skillscan.go/skillstage.go — ADR 0017, superseding ADR 0012's inlining); the shared nonce fence (internal/fence, used by Assess and by the re-plan); the bounded plan→execute→assess goal loop (goal.go/assess.go — ADR 0011); the bounded re-plan a validation refusal buys (repair.go)
2591-
internal/handoff/{handoff,placeholder_lint,session_lint,verdict_lint,tool_grant_lint,verify_inline_lint}.go + _test interpolation, artifact persist/resolve, session pick, Seed for resume — plus the advisory lint sweeps `lint`/`run` print (unresolvable {{placeholders}}, session-handoff `--resume` that may not deliver the parent conversation, a prompt demanding a verdict token no `result_matches` reads, a `result_matches` that silently dropped the node's exit-code guard, a node that declares neither an `allowed_tools` grant nor a `success_check.verify` and so can observe no tool denial — #154 — and a `success_check.verify.command` splicing a model's own text into the shell command line the engine runs: `{{ artifacts.<id> | inline }}`, whose filterless form would be the engine's own file path, or `{{ feedback.<id> }}`, which has no filterless form)
2611+
internal/handoff/{handoff,placeholder_lint,session_lint,verdict_lint,tool_grant_lint,verify_inline_lint,feedback_quote_lint}.go + _test interpolation, artifact persist/resolve, session pick, Seed for resume — plus the advisory lint sweeps `lint`/`run --dry-run` print — and a plain `run` does NOT (unresolvable {{placeholders}}, session-handoff `--resume` that may not deliver the parent conversation, a prompt demanding a verdict token no `result_matches` reads, a `result_matches` that silently dropped the node's exit-code guard, a node that declares neither an `allowed_tools` grant nor a `success_check.verify` and so can observe no tool denial — #154 — a `success_check.verify.command` splicing a model's own text into the shell command line the engine runs: `{{ artifacts.<id> | inline }}`, whose filterless form would be the engine's own file path, or `{{ feedback.<id> }}`, which has no filterless form — and a feedback loop whose body never quotes `{{ feedback.<declarer> }}`, so the re-run repairs nothing: ADR 0028)
25922612
internal/gate/gate.go + _test Decision + PauseController/RecordedController
25932613
internal/runstate/{runstate,recorder,lock}.go + build-tagged flock_{unix,other}.go and fstype_{darwin,linux,other}.go + _test state.json snapshot — atomic write, schema version, resume load — plus the run lock: an flock(2) a leg holds for its duration (AcquireLock) and a reader may probe without writing anything (ProbeLock — ADR 0015 §1)
25942614
internal/runfeed/{runfeed,reader}.go + _test events.jsonl append-only lifecycle event stream — the consumer contract (docs/RUN-FEED.md) — plus the in-repo consumer readers (InFlight, Follow)

cmd/oh-my-graph/lint.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,10 @@ func runLintRuntime(runtime runner.Runtime, args []string) error {
5555
// splices a model's own text into the shell command line the engine runs
5656
// (handoff.LintVerifyInlining — `{{ artifacts.<id> | inline }}` or
5757
// `{{ feedback.<id> }}`, where the filterless artifacts token would be the
58-
// engine's own file path), and for a feedback arc that cannot
58+
// engine's own file path), for a feedback loop nothing in whose body quotes
59+
// `{{ feedback.<declarer> }}` (handoff.LintFeedbackQuoting — the re-run is
60+
// handed the prompt it already ran, so the round repairs nothing, ADR 0028),
61+
// and for a feedback arc that cannot
5962
// reach a producer its declarer fans in from (graph.LintFeedbackReach — the
6063
// loop would re-judge an unchanged artifact until its rounds are spent).
6164
// Those are printed to
@@ -101,17 +104,19 @@ func lintGraphForRuntime(w, warnW io.Writer, path string, runtime runner.Runtime
101104

102105
// warnAdvisories prints one `warning:` line per advisory finding in an
103106
// already-validated graph — the shared reporting half of `lint` and
104-
// `run --dry-run`, covering the five handoff sweeps (unresolvable
107+
// `run --dry-run`, covering the six handoff sweeps (unresolvable
105108
// placeholder-like tokens, session-handoff resumes that may start cold,
106109
// verdicts a node's own success_check cannot read, nodes that can observe
107-
// no tool denial, and a verify command carrying a model's own text into the
108-
// shell) plus the graph-topology
110+
// no tool denial, a verify command carrying a model's own text into the
111+
// shell, and a feedback loop whose repair node never quotes the payload)
112+
// plus the graph-topology
109113
// one (a feedback arc that cannot reach one of its declarer's producers).
110114
// Warnings are advice only: they never affect any exit code.
111115
func warnAdvisories(warnW io.Writer, path string, g *graph.Graph) {
112116
advisories := append(handoff.LintPlaceholders(g), handoff.LintSessions(g)...)
113117
advisories = append(advisories, handoff.LintToolGrants(g)...)
114118
advisories = append(advisories, handoff.LintVerifyInlining(g)...)
119+
advisories = append(advisories, handoff.LintFeedbackQuoting(g)...)
115120
for _, warning := range append(advisories, handoff.LintVerdicts(g)...) {
116121
fmt.Fprintf(warnW, "warning: %s: %s\n", path, warning)
117122
}

0 commit comments

Comments
 (0)