Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions cli/src/domain/models/flow-attribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import type {
RunJournalStepStart,
RunJournalTaskDeclared,
} from "../ports/run-journal-reader.js";
import { buildClosedIntervals, type ClosedInterval } from "./journal-intervals.js";
import {
buildClosedIntervals,
type ClosedInterval,
type IntervalClosure,
} from "./journal-intervals.js";
import { namesTheSameSkill } from "./skill-name.js";

/**
Expand Down Expand Up @@ -104,6 +108,10 @@ export function bareOrchestratingSkillNames(
* none at all. */
export interface FlowInterval extends ClosedInterval {
readonly skill: string;
/** Whether `endMs` is a moment this journal witnessed or the cap standing in for one it
* never did. Carried because `buildStepIntervals` composes these into the step axis and
* reads it there; no flow row of its own is printed differently for it. */
readonly closedBy: IntervalClosure;
}

/**
Expand Down Expand Up @@ -147,6 +155,6 @@ export function buildFlowIntervals(
boundary.type === "step_start" && ORCHESTRATING_SKILLS.has(boundary.skill),
(boundary, opener) =>
boundary.type === "step_end" && namesTheSameSkill(boundary.skill, opener.skill),
(opener, startMs, endMs) => ({ skill: opener.skill, startMs, endMs })
(opener, startMs, endMs, closedBy) => ({ skill: opener.skill, startMs, endMs, closedBy })
);
}
34 changes: 29 additions & 5 deletions cli/src/domain/models/journal-intervals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,22 @@ function cappedLastMoment(witnessedLastMs: number, periodEndMs: number | undefin
return periodEndMs === undefined ? witnessedLastMs : Math.min(witnessedLastMs, periodEndMs);
}

/** What ended an interval. `boundary` is a moment the journal actually witnessed - a
* closer, or a later opener; `journal-end` is the fallback, meaning nothing in the journal
* ever said this interval was over and it was capped at the last moment the journal
* witnessed at all.
*
* The distinction is a caller's to act on, not this walk's: an interval capped at the
* journal's end is a bound, and reading it as a measured extent is how a step that opened
* shortly before a long session went quiet comes to be credited with everything that
* followed. `step-attribution.ts` is the caller that reads it today.
*
* Two values and not three. "Closed by its own `step_end`" and "closed by a later opener"
* are genuinely different strengths of evidence, and both were considered; both are
* nevertheless a moment the journal witnessed, and no caller distinguishes them, so a third
* value would be structure nothing reads. */
export type IntervalClosure = "boundary" | "journal-end";

/** The two facts every closed interval this module builds actually needs — `path`
* (`TaskInterval`) or `skill` (`FlowInterval`) rides beside these, never inside this shape
* itself. */
Expand Down Expand Up @@ -81,8 +97,8 @@ export function momentFallsWithin(
* interval would go on attributing everything a long-running session does afterward to the
* first opener it ever saw.
*
* `toInterval` turns one opener plus its resolved bounds into the caller's own interval
* shape, or `null` to close the interval without emitting a row for it -
* `toInterval` turns one opener plus its resolved bounds, and how those bounds were
* reached (`IntervalClosure`), into the caller's own interval shape, or `null` to close the interval without emitting a row for it -
* `buildTaskIntervals` uses this to skip a declared path `taskIdentityFromWrittenPath`
* cannot resolve while still letting it close whatever interval came before it;
* `buildFlowIntervals` never returns `null`, since every orchestrating `step_start` names a
Expand All @@ -97,7 +113,12 @@ export function buildClosedIntervals<
periodEndMs: number | undefined,
isOpener: (boundary: TBoundary) => boundary is TOpener,
isCloser: (boundary: TBoundary, opener: TOpener) => boolean,
toInterval: (opener: TOpener, startMs: number, endMs: number) => TInterval | null
toInterval: (
opener: TOpener,
startMs: number,
endMs: number,
closedBy: IntervalClosure
) => TInterval | null
): readonly TInterval[] {
const everyWitnessedMoment = timed(boundaryLike);
// Not one readable moment in the whole journal: no interval either, and nothing below
Expand All @@ -112,8 +133,11 @@ export function buildClosedIntervals<
for (let i = 0; i < everyWitnessedMoment.length; i++) {
const { atMs: startMs, boundary } = everyWitnessedMoment[i];
if (!isOpener(boundary)) continue;
const endMs = firstCloserAfter(everyWitnessedMoment, i, boundary, isOpener, isCloser) ?? lastMs;
const interval = toInterval(boundary, startMs, endMs);
const closerMs = firstCloserAfter(everyWitnessedMoment, i, boundary, isOpener, isCloser);
const interval =
closerMs === undefined
? toInterval(boundary, startMs, lastMs, "journal-end")
: toInterval(boundary, startMs, closerMs, "boundary");
if (interval !== null) intervals.push(interval);
}
return intervals;
Expand Down
135 changes: 126 additions & 9 deletions cli/src/domain/models/step-attribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import type {
RunJournalStepStart,
RunJournalTaskDeclared,
} from "../ports/run-journal-reader.js";
import { buildClosedIntervals, type ClosedInterval } from "./journal-intervals.js";
import { buildFlowIntervals, ORCHESTRATING_SKILLS } from "./flow-attribution.js";
import {
buildClosedIntervals,
type ClosedInterval,
type IntervalClosure,
} from "./journal-intervals.js";
import { namesTheSameSkill } from "./skill-name.js";

/** How a record's step came to be known. Never collapsed into one field with the step
Expand Down Expand Up @@ -74,6 +79,10 @@ const UNATTRIBUTED: StepAttribution = { source: "unattributed" };
* Claude Code, Cursor and OpenCode by `journal.cjs`'s own `HOOK_EVENT_NAME_TO_CANONICAL`. */
export interface StepInterval extends ClosedInterval {
readonly skill: string;
/** Whether `endMs` is a moment the journal witnessed or the cap standing in for one it
* never did - `answersFor` reads it, and it is the whole reason the cap above is safe to
* apply. */
readonly closedBy: IntervalClosure;
}

/** Journal lines in, closed intervals out - no filesystem, no record. Run through the one
Expand All @@ -93,9 +102,13 @@ export interface StepInterval extends ClosedInterval {
* Two runs of the very same skill in one session yield two distinct intervals, never one
* merged by name, exactly as the boundaries dictate; nothing here decides which record
* falls into which, that is `attributeMoment`'s job. */
export function buildStepIntervals(
/** Every step a session opened that does not orchestrate - each closed by its own
* `step_end`, by the next `step_start` whatever that one is, or by the journal's own last
* witnessed moment. Two ordinary skills in a row are a sequence, so the second ends the
* first; that reading is unchanged. */
function buildInvokedStepIntervals(
journal: RunJournal,
periodEndMs?: number
periodEndMs: number | undefined
): readonly StepInterval[] {
return buildClosedIntervals<
RunJournalBoundary | RunJournalTaskDeclared | RunJournalFileWritten,
Expand All @@ -104,26 +117,130 @@ export function buildStepIntervals(
>(
[...journal.boundaries, ...journal.taskDeclarations, ...journal.filesWritten],
periodEndMs,
(boundary): boundary is RunJournalStepStart => boundary.type === "step_start",
(boundary): boundary is RunJournalStepStart =>
boundary.type === "step_start" && !ORCHESTRATING_SKILLS.has(boundary.skill),
// Any `step_start` closes one of these, an orchestrating one included: a session that
// starts orchestrating is no longer running the plain skill it was running before.
// `isOpener` already covers the non-orchestrating half; naming the whole rule here is
// what keeps the orchestrating half from being an omission nobody wrote down.
(boundary, opener) =>
boundary.type === "step_end" && namesTheSameSkill(boundary.skill, opener.skill),
(opener, startMs, endMs) => ({ skill: opener.skill, startMs, endMs })
boundary.type === "step_start" ||
(boundary.type === "step_end" && namesTheSameSkill(boundary.skill, opener.skill)),
(opener, startMs, endMs, closedBy) => ({ skill: opener.skill, startMs, endMs, closedBy })
);
}

/**
* Journal lines in, closed intervals out - no filesystem, no record.
*
* **An invoked step no longer closes the orchestration that invoked it**, changed
* 2026-09-05. Reading every `step_start` as the end of whatever was open assumes a session
* only ever runs one skill after another, and an orchestrating skill's whole job is to
* invoke others. 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 - which is
* why this axis named 1 record for that skill while `by_flow`, reading the same journal
* under the rule this now adopts, named 1,052.
*
* Which skills orchestrate is `ORCHESTRATING_SKILLS`'s declaration, never inferred from the
* lines: nesting and sequence produce the identical journal, so no rule read off the
* boundaries alone can separate them. That is also the limit - a skill that invokes another
* without being declared an orchestrator is still read as a sequence, and is still cut short
* by its own child.
*
* Built as two walks over the same lines rather than one with a branch inside it. The
* orchestrating half **is** `buildFlowIntervals` - a flow is an orchestrating step, and
* saying so by calling it is what keeps the two axes from drifting apart again.
*/
export function buildStepIntervals(
journal: RunJournal,
periodEndMs?: number
): readonly StepInterval[] {
return [
...buildFlowIntervals(journal, periodEndMs),
...buildInvokedStepIntervals(journal, periodEndMs),
];
}

/** Where a record's own moment falls inside one interval, that interval's skill is the
* attribution, marked as derived. A record with no moment, or one earlier than every
* interval, is unattributed — never folded into the first step, which would assume work
* began the instant a marker happened to be written rather than sometime before it. */
/** The most specific interval a moment falls in: the latest to have opened, and among
* equals the first to close. An invoked step and the orchestration around it both contain
* the moment, and both claims are true - the inner one is the one that says more, and the
* outer one goes on answering for every moment the inner one does not cover. Order in the
* array decides nothing: the two walks that build these run separately, so a rule that
* read the first match would answer differently for the same journal depending on which
* walk happened to run first. */
function innermostOf(intervals: readonly StepInterval[]): StepInterval | undefined {
let best: StepInterval | undefined;
for (const interval of intervals) {
if (
best === undefined ||
interval.startMs > best.startMs ||
(interval.startMs === best.startMs && interval.endMs < best.endMs)
) {
best = interval;
}
}
return best;
}

/** Whether an interval nothing closed sits inside another that nothing closed either.
*
* Every unclosed interval ends at the same moment - the journal's own last witnessed one,
* capped identically for all of them - so containment between two of them reduces to which
* opened first, and comparing the ends would be a clause no input can make false. The
* enclosing one is the answer because the inner one's extent rests on no evidence at all,
* while the enclosing one is at least still known to have been open at that moment. */
function enclosedByAnotherUnclosed(
covering: readonly StepInterval[],
interval: StepInterval
): boolean {
if (interval.closedBy !== "journal-end") return false;
return covering.some(
(other) => other.closedBy === "journal-end" && other.startMs < interval.startMs
);
}

/** The interval that answers for a moment.
*
* The innermost one covering it, *except* that an interval nothing ever closed yields to
* one that encloses it and was never closed either. An unclosed interval ends at the
* journal's own last witnessed moment, so its extent is a bound and not a measurement; a
* step opened shortly before a long session goes on working would otherwise 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: 972 records attributed to
* `aidd-dev:01-plan`, opened at 06:00:50 and never closed, inside an orchestration opened
* at 05:56:27 and never closed either.
*
* Yielding is between two unclosed intervals and no wider. Where the enclosing interval
* states its own end, the inner one runs past it and nothing encloses it, so the innermost
* claim stands - the same answer it gets when both ends are witnessed. And an unclosed
* interval that nothing encloses still answers: what is refused is preferring a bound over
* a wider claim that covers the same moment, never the bound itself.
*
* No tie between two unclosed *sibling* steps can arise to be broken here, and it is not
* this function 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. */
function answersFor(
intervals: readonly StepInterval[],
momentMs: number
): StepInterval | undefined {
const covering = intervals.filter(
(interval) => momentMs >= interval.startMs && momentMs < interval.endMs
);
return innermostOf(covering.filter((interval) => !enclosedByAnotherUnclosed(covering, interval)));
}

export function attributeMoment(
intervals: readonly StepInterval[],
momentIso: string | undefined
): StepAttribution {
if (momentIso === undefined) return UNATTRIBUTED;
const momentMs = Date.parse(momentIso);
if (Number.isNaN(momentMs)) return UNATTRIBUTED;
const hit = intervals.find(
(interval) => momentMs >= interval.startMs && momentMs < interval.endMs
);
const hit = answersFor(intervals, momentMs);
return hit ? { source: "journal-interval", step: hit.skill } : UNATTRIBUTED;
}
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,7 @@ describe("buildCostReportArtefact — the flow axis states its own limits with t
skill: "aidd-orchestrator:01-sdlc",
startMs: Date.parse("2026-08-17T10:00:00Z"),
endMs: Date.parse("2026-08-17T11:00:00Z"),
closedBy: "boundary" as const,
},
],
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const JOURNALS: readonly CostReportSessionJournal[] = [
skill: "aidd-orchestrator:01-sdlc",
startMs: Date.parse("2026-08-18T09:00:00Z"),
endMs: Date.parse("2026-08-18T10:30:00Z"),
closedBy: "boundary",
},
],
},
Expand Down
10 changes: 10 additions & 0 deletions cli/tests/domain/models/cost-report.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1184,11 +1184,13 @@ describe("buildCostReport — by_flow reads the journal's own sequence, nothing
skill: "aidd-orchestrator:01-sdlc",
startMs: Date.parse("2026-08-17T10:00:00Z"),
endMs: Date.parse("2026-08-17T11:00:00Z"),
closedBy: "boundary",
},
{
skill: "aidd-orchestrator:01-sdlc",
startMs: Date.parse("2026-08-17T11:00:00Z"),
endMs: Date.parse("2026-08-17T12:00:00Z"),
closedBy: "boundary",
},
],
},
Expand Down Expand Up @@ -1226,6 +1228,7 @@ describe("buildCostReport — by_flow reads the journal's own sequence, nothing
skill: "aidd-orchestrator:01-sdlc",
startMs: Date.parse("2026-08-17T10:00:00Z"),
endMs: Date.parse("2026-08-17T12:00:00Z"),
closedBy: "boundary",
},
],
},
Expand Down Expand Up @@ -1258,6 +1261,7 @@ describe("buildCostReport — by_flow reads the journal's own sequence, nothing
skill: "aidd-orchestrator:01-sdlc",
startMs: Date.parse("2026-08-17T10:00:00Z"),
endMs: Date.parse("2026-08-17T11:00:00Z"),
closedBy: "boundary",
},
],
},
Expand Down Expand Up @@ -1286,6 +1290,7 @@ describe("buildCostReport — by_flow reads the journal's own sequence, nothing
skill: "aidd-orchestrator:01-sdlc",
startMs: Date.parse("2026-08-17T10:00:00Z"),
endMs: Date.parse("2026-08-17T11:00:00Z"),
closedBy: "boundary",
},
],
},
Expand Down Expand Up @@ -1345,6 +1350,7 @@ describe("buildCostReport — by_flow reads the journal's own sequence, nothing
skill: "aidd-orchestrator:01-sdlc",
startMs: Date.parse("2026-08-17T10:00:00Z"),
endMs: Date.parse("2026-08-17T10:00:00Z"),
closedBy: "boundary",
},
],
},
Expand Down Expand Up @@ -1399,6 +1405,7 @@ describe("buildCostReport — by_flow reads the journal's own sequence, nothing
skill: "aidd-orchestrator:01-sdlc",
startMs: Date.parse("2026-08-17T10:00:00Z"),
endMs: Date.parse("2026-08-17T11:00:00Z"),
closedBy: "boundary",
},
],
},
Expand Down Expand Up @@ -1469,6 +1476,7 @@ describe("buildCostReport — by_flow reads the journal's own sequence, nothing
skill: "aidd-orchestrator:01-sdlc",
startMs: Date.parse("2026-08-17T10:00:00Z"),
endMs: Date.parse("2026-08-17T11:00:00Z"),
closedBy: "boundary",
},
],
},
Expand Down Expand Up @@ -1508,11 +1516,13 @@ describe("buildCostReport — by_flow reads the journal's own sequence, nothing
skill: "aidd-orchestrator:01-sdlc",
startMs: Date.parse("2026-08-17T10:00:00Z"),
endMs: Date.parse("2026-08-17T11:00:00Z"),
closedBy: "boundary",
},
{
skill: "aidd-orchestrator:02-backlog",
startMs: Date.parse("2026-08-17T11:00:00Z"),
endMs: Date.parse("2026-08-17T12:00:00Z"),
closedBy: "boundary",
},
],
},
Expand Down
Loading