Skip to content

feat(framework): emit step boundaries instead of inferring them #663

Closed

Description

@blafourcade

Outcome

Step boundaries are a fact the framework emits, rather than something inferred from a provider attribute that only one tool has and that it redacts.

Scope

Two measured problems share this one answer.

Only Claude Code marks steps, and badly. Its skill_activated event carries the real skill name — but only with OTEL_LOG_TOOL_DETAILS=1, which also logs Bash commands and tool inputs. And skill.name is sticky: once activated it rides every following datapoint, including subagents launched afterwards, so interleaved skills are attributed to whichever ran last. None of Codex, Cursor, Copilot or OpenCode emits any equivalent at all, yet #656 promises a per-step breakdown across tools.

The framework already knows. A skill starting and finishing is something the framework observes directly, on every tool, without asking a vendor and without a redaction flag.

  • Includes: a step-boundary record written on skill start and skill end, correlatable with the run journal.
  • Includes: nesting, since a skill can invoke another, and an unterminated step must be distinguishable from one still running.
  • Includes: correlation keys that let a reader attach provider telemetry to the interval, per tool.
  • Excludes: replacing skill_activated on Claude Code where it is available and cheaper. Both may coexist, and disagreement between them is a signal worth reading.
  • Excludes: any prompt or tool content.

What this unlocks

Per-step cost stops depending on OTEL_LOG_TOOL_DETAILS, which removes the privacy trade that currently forces collector-side redaction just to see a skill name. Interleaved skills become attributable. And the four remaining tools gain a breakdown they can never get from their own exports.

Done When

  • A step boundary is recorded on every supported tool, including those with no vendor equivalent.
  • Two interleaved skills produce two distinct attributions rather than one.
  • A skill interrupted or crashed leaves a step distinguishable from one still running.
  • Where both exist, framework boundaries and skill_activated agree; disagreement is surfaced, not hidden.
  • Per-step cost is obtainable on Claude Code without OTEL_LOG_TOOL_DETAILS.

Completion Evidence

A task whose per-step cost is identical whether computed from framework boundaries or from the provider event, and a second one with interleaved skills where only the framework boundaries are correct.

Relations

Field Value
parent #652
related #629, #653, #647

Activity

  1. added this to the milestone on Aug 15, 2026
  2. blafourcade commented on Aug 16, 2026

    @blafourcade
    ContributorAuthor

    Moved into milestone 14, 2026-08-16

    Decision: the framework emits its own step boundaries in v1, rather than inferring them from a provider attribute.

    Why this earns its place in a tight week

    It does not add a sixth problem, it removes three.

    Without it With it
    Per-step cost needs OTEL_LOG_TOOL_DETAILS, which also logs Bash commands and tool inputs The flag is not needed at all. The privacy trade disappears rather than being filtered
    The sink must redact, and #646 must refuse to set the flag until redaction is confirmed active Redaction stays valuable but stops being load-bearing for the headline feature
    Interleaved skills are attributed to whichever ran last, and the output must warn about it Boundaries are exact, because we observe them ourselves
    Per-step cost is impossible on the four other tools, none of which emits an equivalent The mechanism is the same everywhere from day one

    The provider event stays useful where it exists: comparing our boundaries against skill_activated on Claude Code is a free correctness check, and a disagreement between the two is worth reading rather than hiding.

    Knock-on changes to sibling issues

    Scope for v1

    Claude Code only, like the rest of the milestone. The multi-tool value is real but arrives with #653.

  3. modified the milestones: , Prove what an AI session costs on Aug 16, 2026
  4. blafourcade commented on Aug 18, 2026

    @blafourcade
    ContributorAuthor

    The mechanism this issue needs is already observable, and it was in our own recorded payloads.

    This issue says "the framework already knows" a skill started, without saying how. It does: a PostToolUse payload carries

    tool_name: "Skill"
    tool_input: { skill: … }
    

    So a step boundary is a tool call, not something to invent — and plugins/aidd-telemetry/hooks/ already listens on that event for task attachment. Whether PreToolUse fires for Skill as well decides whether a step has an observable start or only an end; that is being measured now, along with whether cost can be split per step at all without OTEL_LOG_TOOL_DETAILS.

    The correlation key is run_id, not a timestamp. This issue asks for "correlation keys that let a reader attach provider telemetry to the interval". Two sessions can work the same task at once — a background agent, a second worktree — so a step interval overlapping both cannot be resolved by time. Time is only ever used to split inside a session, never to choose one. Recorded in full on #649.

    What this changes if the measurement confirms it. Steps stop being something skills declare and become something the hook observes. That is the same correction already made twice in this layer: the run journal's task pointer was declared by the planning skills and is now observed from file writes, because a declaration can silently stop happening while everything still looks fine, and because it put a measurement concern inside skills whose concern is something else.

    It would also make #649 smaller: metadata.json keeps its identity and its upward link to the backlog, and stops carrying a journal that something else records more reliably.

  5. blafourcade commented on Aug 18, 2026

    @blafourcade
    ContributorAuthor

    Measured on a real paid session ($0.61). The privacy trade disappears, and this issue gets a sharper reason to exist.

    OTEL_LOG_TOOL_DETAILS is not needed for per-step cost

    Ordered per-step cost is obtainable from the OTLP logs stream alone, with the flag unset:

    • skill_activated events carry timestamps that mark step starts.
    • claude_code.api_request records carry an individually-timestamped cost_usd.
    • Partitioning the requests by those boundaries reconciles exactly to the vendor's own total: 0.401955 + 0.054666 + 0.157919 = 0.61454, against total_cost_usd: 0.61454 from --output-format json.

    So the whole privacy argument — that seeing a skill's cost required also logging Bash commands and tool inputs — was based on needing the name from the export. It is not needed for the cost.

    What the export still cannot give, and what this issue is for

    The name. With the flag unset, skill.name and plugin.name read "third-party" identically for two genuinely different skills, on cost.usage, token.usage and api_request alike. And skill_activated uses a different placeholder again, "custom_skill" — so even a join keyed on the literal "third-party" misses that event type.

    The hook stream has it, un-redacted, on both events:

    PreToolUse   Skill  tool_input: {"skill":"aidd-ui:01-hello"}
    PostToolUse  Skill  tool_input: {"skill":"aidd-context:11-explore","args":"codebase"}
    

    So PreToolUse does fire for Skill. A step has an observable start and an observable end, with the real plugin:skill name, and no vendor event and no flag involved.

    The measurement that makes this issue necessary rather than nice

    The boundary from skill_activated carries a structural attribution error, not noise. One api_request landed 59 ms before the next skill's dispatch — the turn that both finishes one step and decides to call the next. It was attributed to the earlier step, where it accounted for 47.7% of that step's entire cost.

    On short steps that is not a rounding concern, it is the majority of the number. A framework-emitted boundary is what removes it, because the framework knows which side of the turn it is on.

    Two things this changes elsewhere

    Build on logs, not metrics. Every cost.usage datapoint matched an api_request record float-for-float, and only because turns happened to be 3–7 s apart against a 1 s export tick. At the 60 s production default, several turns merge into one datapoint irrecoverably. The metrics stream is redundant here and strictly weaker under realistic settings. Also worth knowing: sums are aggregationTemporality: 1, delta and not cumulative, so no differencing is needed — and unit: "USD" lives only on the metric descriptor, never on a datapoint.

    query_source is not a join key. For the same underlying API calls it reads "main" on the metric and "sdk" on the log. It disagrees with itself across streams.

    Left undetermined, deliberately

    invocation_trigger read "nested-skill" for the second skill. If skills nest rather than run in sequence, a flat partition is a modelling choice and not a fact. One session cannot say, and this issue's "nesting" scope item is exactly where it belongs.

    Also not run: whether the flag changes skill.name on these event types. The unset case answered the question without it, so a second paid session was not spent.

  6. blafourcade commented on Aug 18, 2026

    @blafourcade
    ContributorAuthor

    Nesting is not a blocker, and this issue should not treat it as one

    I flagged invocation_trigger: "nested-skill" as an open question, as though the design could not proceed until it was settled. That was wrong, and the maintainer's framing is the right one: people can invoke whatever they like; what matters is that we record what they did.

    Nesting is a question for the report, never for the trace.

    Recording a start and an end per invocation settles it — and both are available, since PreToolUse and PostToolUse were both measured firing for the Skill tool with the real plugin:skill name. Nesting then becomes derivable rather than a modelling choice frozen at write time, exactly as a profiler separates self time from total time.

    Question Answered from
    what did this session do? the sequence of starts and ends, unambiguous
    what did step A cost on its own? self cost — A minus whatever ran inside it
    what did A cost including everything it triggered? total cost — its start to its end

    So this issue's scope item about nesting stays, but it is a recording requirement — write both boundaries — not a question that has to be answered before building.

    The corollary matters as much

    A skill invoked outside the standard flow is a step like any other. Nobody is required to follow the path, and that is precisely what makes the measurement worth having: a task that went from specification to implementation with no review becomes visible, instead of assumed.

    That reframes what "steps" are for. Not conformance to a flow — a record of what actually happened, whatever it was.

  7. blafourcade commented on Aug 18, 2026

    @blafourcade
    ContributorAuthor

    Measured on all five tools. This issue's premise was wrong, and in our favour.

    Paid probes on Codex, Copilot and Cursor; Claude Code already measured; OpenCode from documentation. Per-step measurement is portable. Four of five tools name the skill in a schema-guaranteed field, and all five give a distinguishable start and end.

    Tool How a skill is invoked Event Field carrying the name Strength
    Claude Code Skill tool call PreToolUse / PostToolUse tool_input.skill = plugin:skill structural, measured
    Copilot dedicated skill function tool preToolUse / postToolUse toolArgs: {"skill":"…"} structural, measured
    Cursor Read of SKILL.md preToolUse, beforeReadFile, postToolUse file_path ending skills/<name>/SKILL.md structural, measured
    OpenCode native skill({name}) tool plugin tool.execute.before / .after input.tool === "skill", output.args structural, documented
    Codex the model reads SKILL.md with its own Bash call PreToolUse / PostToolUse, tool_name: "Bash" tool_input.command, free text heuristic, measured

    A correction worth recording

    I had ruled Codex out on its documentation saying PreToolUse intercepts "currently Bash only". That is outdated. The current page states: "PreToolUse and PostToolUse can observe more than shell and MCP calls. Most local function tools use the same hook path… Hosted tools, such as WebSearch [do not]." Measured: asking Codex to use a skill produced a PreToolUse/PostToolUse pair carrying sed -n '1,120p' .agents/skills/probe-echo/SKILL.md.

    So the conclusion this issue rested on — that nothing observes a step outside Claude Code — is false.

    What this issue becomes

    Not "emit boundaries because nothing observes them". Rather: normalise five different observations into one step record, and emit our own only where observation is weak or absent.

    • Four tools need an observer, not an emitter. Same shape as the host-detection and task-attachment work already shipped: a small per-tool extractor, a seam rather than an edit.
    • Codex needs a fallback. Its signal is real but heuristic — a hook must regex a SKILL.md path out of arbitrary shell text, and a differently phrased read (cat, head, a batched command) still fires the hook while making the identity harder to recover. This is the one tool where "no help from the model" is not strictly true, and where a framework-emitted boundary earns its place.
    • OpenCode needs an artefact that does not exist. Its capability is there, but AIDD ships no plugin surface for it at all — hooks: { supported: false }, no HasHooks. That is feat(cli): OpenCode joins the chain through its plugin API, not through hooks #676, and it is a build-pipeline gap rather than a capability gap.

    What does not change

    The step record stays the same everywhere, whatever the tool and whatever the kind of work: name, start, end, run_id. Only where the name comes from differs, and that belongs in a per-tool extractor.

    Steps are written to the run record, which git ignores — not to metadata.json, which is committed and would leave the working tree permanently dirty if a hook rewrote it on every skill invocation.

  8. blafourcade commented on Aug 19, 2026

    @blafourcade
    ContributorAuthor

    Où les étapes s'enregistrent, et pourquoi pas dans la tâche

    Deux décisions à poser avant d'écrire quoi que ce soit ici.

    Un fichier voisin, pas une onzième clé

    La fiche de run a dix clés, verrouillées par un test. Elle est écrite au démarrage de session et relue à la fin. Les étapes, elles, arrivent sur PostToolUse — des dizaines à des centaines de fois par session.

    Les mettre dans la fiche impose une lecture-modification-écriture du fichier entier à chaque appel d'outil. Coût quadratique, et surtout une fenêtre de corruption à chaque écriture : une session tuée au mauvais moment laisse un JSON tronqué, et on perd l'en-tête avec les étapes, donc le run entier.

    Un fichier voisin en JSONL s'écrit en ajout pur : rien à relire, rien à réécrire, ordre naturel, et une ligne corrompue ne coûte qu'une étape.

    La raison de fond compte plus que la performance : la fiche est un en-tête de taille bornée, fait pour être joint. Les étapes sont non bornées. Mettre du non borné dans une structure à forme fixe casse la propriété que le test des dix clés protège — ce test n'est pas un obstacle à contourner, il dit quelque chose de vrai.

    L'enregistrement ne doit jamais exiger une tâche

    Le metadata.json par tâche est le mauvais endroit, et pas pour une raison de forme : tout ce qui se passe hors flow n'y serait jamais enregistré. Le skill debug lancé à la volée, le prompt improvisé, la session qui ne suit aucun ticket. C'est exactement ce qu'on veut voir — c'est là que le process fuit.

    Le journal modélise déjà la bonne chose. task_id: null dans tasks veut dire « rattaché à aucune tâche » : ce n'est pas un trou, c'est une valeur.

    D'où la séparation :

    Où Quoi
    Enregistrer toujours au niveau du run chaque skill appelé, avec l'heure, qu'une tâche soit ouverte ou non
    Attribuer à la lecture une étape dans un intervalle de tâche lui appartient ; dans un intervalle null, elle est hors flow

    Le metadata.json par tâche devient un artefact de rapport — ce qu'une tâche a coûté — dérivé du journal, jamais la surface d'enregistrement.

    Deux conséquences directes :

    • Aucun skill n'écrit nulle part. Un skill qui devrait connaître sa tâche pour s'enregistrer est fastidieux à écrire et casse dès qu'il n'y a pas de tâche. Le hook observe, c'est tout.
    • Le coût hors flow se déduit sans travail supplémentaire : coût de session moins somme des étapes attribuées. Personne ne peut produire ce chiffre aujourd'hui.

    Ce qui reste à mesurer, pas à supposer

    Le hook lit le nom du skill sur PostToolUse, donc on tient l'instant où il se termine. Savoir si la charge utile porte aussi l'instant de départ — donc si on obtient une durée par étape ou seulement une borne — demande une mesure. À faire avant d'arrêter le format d'une ligne.

  9. 7 remaining items

  10. closed this as completedby moving to Done in AIDD Roadmapon Aug 20, 2026
  11. blafourcade commented on Aug 20, 2026

    @blafourcade
    ContributorAuthor

    Narrowed by a measurement taken while planning #687: Claude Code's transcript already carries the skill per assistant message, as attributionSkill, with the real name and no flag — measured across 40 transcripts, 2267 attributed messages, 25 distinct skills.

    On that one path the journal's step boundaries are the weaker source: the transcript's attribution is exact per message, where the journal's is a half-open interval closed by the next marker.

    This does not undo what landed here. The journal remains the only route for the export path, where the vendor attribute reads third-party, and for Codex, Cursor and Copilot, none of which was measured to have an equivalent. #687 records the per-path table and the two limits that constrain how the transcript field can be read.

  12. blafourcade commented on Aug 28, 2026

    @blafourcade
    ContributorAuthor

    Closed as completed, with three of five acceptance points unmet

    Found by a backlog audit on 2026-08-28, verified against the code, which contradicts them in its own comments.

    "Every supported tool opens a step." It does not. plugins/aidd-telemetry/hooks/lib/tools/opencode.cjs:16 declares stepStart: null — OpenCode opens no step at all, so a session there attributes by interval or not at all.

    "A skill interrupted or crashed leaves a step distinguishable from one still running." Nothing records a step ending. grep -rn "step_end\|stepEnd\|step_stop" over cli/src and plugins is empty, and hooks/lib/record.cjs:183 states the design outright: closing is the reader's derivation, from whichever boundary comes next. A crashed step and an open one are therefore the same observable state — which may well be the right call, since no tool measured so far reports when a skill's work finishes, but it is not what the box says.

    skill_activated appears only inside a comment, so no disagreement between it and the journal is ever surfaced.

    Not reopening. What shipped is real and substantial — steps open on four of five tools, from the tool's own arguments, and the interval attribution works. The three points above are a genuine gap between the text and the code, and they matter because two of them are load-bearing elsewhere: OpenCode's stepStart: null is why an OpenCode session cannot report per-skill, and the absent step end is why every interval is closed by the next boundary rather than by the step's own completion.

    If the intent was to narrow the scope, the honest move is to amend the three points here and open a follow-up for OpenCode's step; leaving them as ticked-in-spirit means the next person reads this issue as proof of something the code says it does not do.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Fields

    Priority

    High

    Projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions