Skip to content

fix(cli): a step's extent is only as strong as the evidence that closed it - #777

Merged
blafourcade merged 2 commits into
nextfrom
fix/a-nested-step-does-not-close-the-one-that-invoked-it
Sep 6, 2026
Merged

fix(cli): a step's extent is only as strong as the evidence that closed it#777
blafourcade merged 2 commits into
nextfrom
fix/a-nested-step-does-not-close-the-one-that-invoked-it

Conversation

@blafourcade

@blafourcade blafourcade commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

What

Two faults in buildStepIntervals, one commit each, both about the same thing: the step axis read an interval's extent without asking what established it.

Measured on the one orchestrated session captured, 2026-09-04, aidd-orchestrator:01-sdlc read as 206 seconds against a session that ran until 09:27:21, and by_step named 1 record for it while by_flow, reading the same journal, named 1,052.

1. An invoked step does not close the orchestration that invoked it

Reading every step_start as the end of whatever was open assumes a session only ever runs one skill after another. An orchestrating skill's whole job is to invoke others, so the assumption cut it short at its first child — 05:56:27 to 05:59:53.

buildStepIntervals becomes two walks over the same lines. The orchestrating half is buildFlowIntervals: a flow is an orchestrating step, and calling it rather than restating its rule is what keeps the two axes from drifting apart again. The other half opens on a non-orchestrating step_start and keeps the rule it always had — any later step_start closes it, because a session that starts orchestrating is no longer running the plain skill it was running before.

attributeMoment answers with the innermost interval around a moment rather than the first in the array. Both claims are true of a record inside an invoked step; the inner one says more, and the outer one goes on answering for every moment the inner one does not cover. Array order decides nothing, which matters now that two separate walks build the list.

Which skills orchestrate is ORCHESTRATING_SKILLS's declaration, never inferred: nesting and sequence produce the identical journal, so no rule read off the boundaries alone separates them. The limit is stated in code — a skill that invokes another without being declared an orchestrator is still cut short by its own child.

On its own this commit changed nothing on the real data, and its message says so. The tail of that session was still owned by aidd-dev:01-plan, an invoked step nothing closed, which ran to the journal's last moment and won on having started later. That is the second fault.

2. A step nothing closed yields to the one that encloses it

An interval nothing closed ends at the journal's own last witnessed moment. That end is a bound, not a measurement.

buildClosedIntervals now tells its caller which of the two ended an interval — a moment the journal witnessed, or the cap standing in for one it never did — and answersFor reads it: an interval closed by the journal's end yields to one that encloses it and was never closed either. The enclosing one is at least still known to have been open at that moment; the inner one's extent rests on nothing.

Two values and not three. "Closed by its own step_end" and "closed by a later step_start" are genuinely different strengths of evidence, and both were weighed; both are nevertheless a moment the journal witnessed, and no caller separates them, so a third value would be structure nothing reads.

The yielding is that narrow and no wider:

  • where the enclosing interval states its own end, the inner one runs past it, nothing encloses it, and the innermost claim stands
  • an unclosed interval nothing encloses still answers for its own moments — what is refused is preferring a bound over a wider claim covering the same moment, never the bound itself
  • no tie between two unclosed sibling steps can arise, and it is not this rule that prevents it: any step_start closes whichever plain step was open, so at most one invoked step is ever left unclosed at a time. The test demonstrates that rather than a comment asserting it

Containment between two unclosed intervals is compared on the start alone, because they all end at the same cap — comparing the ends would be a clause no input can make false.

Measured on the real sink

30 days, 31,025 requests, every axis reconciling to the same total:

Axis row Before After
by_step · aidd-orchestrator:01-sdlc · journal-interval 1 973
by_step · aidd-dev:01-plan · journal-interval 972 0

973 is the whole of what the two walks can agree on, and the difference from by_flow is accounted for line by line: 1,052 records fall inside that flow interval, and 79 of them carry a step their own tool stated — 12 aidd-orchestrator:01-sdlc, 61 aidd-dev:01-plan, 6 aidd-pm:04-spec — which is the stronger claim and the one the step axis keeps. 1,052 − 79 = 973.

Twelve of those 79 are tool-stated aidd-orchestrator:01-sdlc, which by_flow still counts inside its own journal-interval row — so the same records read journal-interval on one axis and tool-stated on the other. That asymmetry predates this change, both axes still reconcile to the period total, and nothing here touches it; it is named so the arithmetic above does not read as tidier than it is.

Read this as attribution moving, not as coverage rising. It moves records within the 1,052 the journal covers. The 27,393 unattributed ones predate the journal, from a single session resumed since 2026-08-13, and no interval rule reaches them. prompt-matched is still 0.0% and untouched by this.

What this rule's life expectancy is

It is the right answer for the journals that already exist, where a session's end is never written down. It is not the permanent answer. A SessionEnd hook — available on all five tools, and the next change after UserPromptSubmit — replaces the inferred cap with an observed end, and every interval that gains one stops being a journal-end interval and stops yielding. The rule fires less often the more the capture improves, which is the direction it should go.

Tests

Seven cases, each written before the code that satisfies it; four were red at the start.

Case Guards
an orchestration outlives the step it invokes the two walks
a plain step is still closed by the next step_start the sequence rule
the innermost interval answers for a moment inside both answersFor
the enclosing interval answers again once the inner one ends answersFor
a moment goes to the orchestration when nothing closed the step inside it the yield
the earlier invoked step is kept, only the unclosed one yields no sibling tie
the innermost step is kept when the orchestration states its own end the yield's narrowness

Mutations run, five killed:

Mutation Red
outermost interval wins instead of innermost 1
single walk, every step_start opens 2
never yield 2
yield to any interval that opened earlier 1
let a closed interval yield too 2

pnpm test 3478 passed / 309 files. Typecheck clean, biome ci exit 0, knip clean, jscpd exit 0, bundle 596.6 / 598 KB, layering clean, 371 repository script tests.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp

…ed it

Reading every `step_start` as the end of whatever was open assumes a session only
ever runs one skill after another. An orchestrating skill's whole job is to
invoke others, so that assumption cuts it short at its first child.

Measured on the one orchestrated session captured, 2026-09-04:
`aidd-orchestrator:01-sdlc` opened at 05:56:27 and `aidd-pm:04-spec` at 05:59:53,
so the orchestration read as 206 seconds against a session that ran until
09:27:21. `by_flow`, reading the same journal under the rule this adopts, named
1,052 records for that skill while `by_step` named 1.

`buildStepIntervals` is now two walks over the same lines. The orchestrating half
*is* `buildFlowIntervals` — a flow is an orchestrating step, and calling it
rather than restating it is what keeps the two axes from drifting apart again.
The other half opens on a non-orchestrating `step_start` and keeps the rule it
always had: any later `step_start` closes it, because a session that starts
orchestrating is no longer running the plain skill it was running before.

`attributeMoment` now answers with the innermost interval around a moment rather
than the first one in the array. Both claims are true of a record inside an
invoked step; the inner one says more, and the outer one goes on answering for
every moment the inner one does not cover. Array order decides nothing, which
matters now that two separate walks build the list.

Which skills orchestrate is `ORCHESTRATING_SKILLS`'s declaration, never inferred:
nesting and sequence produce the identical journal, so no rule read off the
boundaries alone separates them. The limit is stated in code — a skill that
invokes another without being declared an orchestrator is still cut short by its
own child.

**Measured effect on today's data: none.** `by_step` reads
`aidd-dev:01-plan=972, aidd-orchestrator:01-sdlc=1` before and after. The tail of
that session is owned by `aidd-dev:01-plan`, an invoked step nothing closed,
which runs to the journal's last witnessed moment and wins on having started
later. Telling an interval closed by evidence from one merely bounded is the next
change, and this two-walk shape is what it needs to fall back to.

Mutations run, both killed: the outermost interval winning, and a single walk
opening on every `step_start`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
@blafourcade
blafourcade requested a review from a team as a code owner September 5, 2026 10:48
An interval nothing ever closed ends at the journal's own last witnessed moment.
That end is a bound, not a measurement, and reading it as an extent is how a step
that opened shortly before a long session went on working comes to be credited
with all of it — purely for having opened later than the orchestration around it.

Measured on the one orchestrated session captured, 2026-09-04:
`aidd-dev:01-plan` opened at 06:00:50 inside an orchestration opened at 05:56:27.
Neither was ever closed, so both ran to 09:27:21 and the innermost-start rule gave
the invoked step every record in between.

`buildClosedIntervals` now tells its caller which of the two ended an interval —
a moment the journal witnessed, or the cap standing in for one it never did — and
`answersFor` reads it: an interval closed by the journal's end yields to one that
encloses it and was never closed either.

Two values and not three. "Closed by its own `step_end`" and "closed by a later
`step_start`" are different strengths of evidence and both were weighed; both are
nevertheless a moment the journal witnessed, and no caller separates them, so a
third value would be structure nothing reads.

The yielding is that narrow and no wider. Where the enclosing interval states its
own end, the inner one runs past it, nothing encloses it, and the innermost claim
stands — the same answer it gets when both ends are witnessed. An unclosed
interval nothing encloses still answers for its own moments: what is refused is
preferring a bound over a wider claim covering the same moment, never the bound.

No tie between two unclosed sibling steps can arise, and it is not this rule that
prevents it: any `step_start` closes whichever plain step was open, so at most one
invoked step is ever left unclosed at a time. The test says so by demonstration
rather than a comment.

Containment between two unclosed intervals is compared on the start alone. They
all end at the same cap, so comparing the ends would be a clause no input can
make false — the kind of default that hides a wrong number.

Measured on the real sink, 30 days, 31,025 requests, every axis reconciling:

  by_step  aidd-orchestrator:01-sdlc  journal-interval    1 -> 973
  by_step  aidd-dev:01-plan           journal-interval  972 -> 0

973 is the whole of what the two walks can agree on. 1,052 records fall inside
that flow interval; 79 of them carry a step their own tool stated (12
`aidd-orchestrator:01-sdlc`, 61 `aidd-dev:01-plan`, 6 `aidd-pm:04-spec`) and keep
that stronger claim on the step axis. 1,052 - 79 = 973, and `by_flow` reads 1,052
for the same interval.

This moves attribution within the 1,052 records the journal covers. It does not
move the 27,393 unattributed ones: those predate the journal, from one session
resumed since 2026-08-13, and no interval rule reaches them.

Mutations run, three killed by three distinct guards: never yielding, yielding to
any earlier start, and letting a closed interval yield too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
@blafourcade blafourcade changed the title fix(cli): an invoked step does not close the orchestration that invoked it fix(cli): a step's extent is only as strong as the evidence that closed it Sep 5, 2026
@blafourcade
blafourcade enabled auto-merge (squash) September 6, 2026 02:41
@blafourcade
blafourcade merged commit af683f4 into next Sep 6, 2026
17 checks passed
@blafourcade
blafourcade deleted the fix/a-nested-step-does-not-close-the-one-that-invoked-it branch September 6, 2026 06:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant