session-orchestrator 4.1.0 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/session-plan/SKILL.md +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/session-plan/SKILL.md +1 -1
- package/.cursor/skills/session-plan/SKILL.md +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +34 -0
- package/README.md +8 -8
- package/agents/ux-evaluator.md +1 -1
- package/commands/close.md +3 -3
- package/commands/go.md +2 -0
- package/commands/memory-cleanup.md +4 -3
- package/commands/persona-panel.md +1 -1
- package/commands/session.md +3 -2
- package/docs/README.md +4 -4
- package/docs/USER-GUIDE.md +115 -48
- package/docs/agent-authoring.md +2 -2
- package/docs/baseline.md +55 -1
- package/docs/ci-setup.md +1 -1
- package/docs/codex-setup.md +1 -0
- package/docs/components.md +2 -2
- package/docs/cursor-setup.md +1 -0
- package/docs/events-schema.md +4 -1
- package/docs/instruction-delivery.md +1 -1
- package/docs/memory-proposal-flow.md +3 -3
- package/docs/migration-v4.md +2 -2
- package/docs/owner-config-schema.md +74 -90
- package/docs/persona-panel.md +4 -4
- package/docs/pi-setup.md +1 -0
- package/docs/rule-authoring.md +13 -6
- package/docs/scope-collision-guard.md +2 -0
- package/docs/session-config-reference.md +55 -22
- package/docs/session-config-template.md +9 -5
- package/docs/vault-docs-architecture.md +4 -2
- package/hooks/_lib/hook-import-set.json +28 -3
- package/hooks/_lib/vcs-create-matcher.mjs +214 -16
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/pre-bash-issue-budget.mjs +123 -26
- package/hooks/subagent-telemetry.mjs +106 -20
- package/package.json +4 -4
- package/scripts/baseline-archetypes.mjs +28 -0
- package/scripts/lib/auto-dialectic.mjs +0 -68
- package/scripts/lib/baseline-archetypes.mjs +439 -0
- package/scripts/lib/build-live-signals.mjs +5 -6
- package/scripts/lib/config/issue-budget.mjs +68 -8
- package/scripts/lib/config/private-config-dir.mjs +3 -2
- package/scripts/lib/config/remote-hosts.mjs +2 -2
- package/scripts/lib/config-schema.mjs +79 -0
- package/scripts/lib/file-lock.mjs +47 -5
- package/scripts/lib/issue-budget-reconcile.mjs +392 -0
- package/scripts/lib/issue-budget.mjs +76 -3
- package/scripts/lib/learnings/evolve-telemetry.mjs +1 -2
- package/scripts/lib/maintenance-due-banner.mjs +440 -0
- package/scripts/lib/owner-config.example.yaml +29 -46
- package/scripts/lib/owner-yaml.mjs +14 -13
- package/scripts/lib/quality-gate.mjs +13 -6
- package/scripts/lib/resource-probe/evaluate.mjs +19 -21
- package/scripts/lib/rules-sync.mjs +34 -4
- package/scripts/lib/session-close-backfill.mjs +182 -40
- package/scripts/lib/session-end/phase-skip.mjs +85 -86
- package/scripts/lib/session-end/tail-runner.mjs +178 -0
- package/scripts/lib/session-schema/constants.mjs +6 -0
- package/scripts/lib/session-schema/validator.mjs +20 -0
- package/scripts/lib/session-shape.mjs +558 -0
- package/scripts/lib/session-start-probes.mjs +10 -3
- package/scripts/lib/session-token-rollup.mjs +95 -10
- package/scripts/lib/state-md/frontmatter-mutators.mjs +22 -34
- package/scripts/lib/state-md.mjs +1 -0
- package/scripts/lib/subagents-schema.mjs +77 -9
- package/scripts/lib/telemetry/pricing.mjs +197 -0
- package/scripts/lib/telemetry/sync.mjs +50 -1
- package/scripts/lib/validate/check-skill-script-paths.mjs +33 -10
- package/scripts/lib/validate/check-unwired-features.mjs +8 -7
- package/scripts/lib/vault-mirror/process.mjs +2 -1
- package/scripts/lib/vault-status/narrative-mirror.mjs +4 -4
- package/scripts/lib/wave-resource-gate.mjs +23 -27
- package/scripts/lib/wave-sizing.mjs +10 -3
- package/scripts/materialize-wave-scope.mjs +68 -14
- package/scripts/print-applicable-rules.mjs +7 -6
- package/scripts/print-learnings-index.mjs +3 -2
- package/scripts/session-shape.mjs +266 -0
- package/skills/_shared/config-reading.md +15 -9
- package/skills/_shared/private-capability-context.md +89 -0
- package/skills/bootstrap/SKILL.md +61 -13
- package/skills/bootstrap/_shared-template.md +99 -14
- package/skills/bootstrap/deep-template.md +36 -26
- package/skills/bootstrap/fast-template.md +44 -8
- package/skills/bootstrap/intensity-heuristic.md +10 -4
- package/skills/bootstrap/private-contract.md +119 -0
- package/skills/bootstrap/public-fallback.md +30 -18
- package/skills/bootstrap/standard-template.md +39 -24
- package/skills/discovery/probes-ui.md +1 -1
- package/skills/docs-orchestrator/audience-mapping.md +1 -1
- package/skills/evolve/SKILL.md +2 -2
- package/skills/gitlab-ops/SKILL.md +3 -3
- package/skills/grill/SKILL.md +1 -1
- package/skills/memory-cleanup/SKILL.md +2 -2
- package/skills/plan/mode-new.md +9 -0
- package/skills/reconcile/SKILL.md +1 -1
- package/skills/session-end/SKILL.md +3 -2
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +23 -65
- package/skills/session-end/phase-3-7a-recommendations.md +2 -2
- package/skills/session-end/references/phase-3-documentation-updates.md +8 -6
- package/skills/session-end/references/phase-5-issue-cleanup.md +26 -0
- package/skills/session-end/session-metrics-write.md +31 -12
- package/skills/session-plan/SKILL.md +56 -48
- package/skills/session-plan/wave-template.md +8 -15
- package/skills/session-start/SKILL.md +18 -2
- package/skills/session-start/phase-2-5-docs-planning.md +1 -1
- package/skills/session-start/phase-8-5-express-path.md +12 -9
- package/skills/session-start/references/phase-1-5-session-continuity.md +2 -0
- package/skills/session-start/references/phase-4-ssot-environment-check.md +21 -5
- package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +3 -1
- package/skills/test-runner/rubric-v1.md +2 -2
- package/skills/wave-executor/SKILL.md +42 -12
- package/skills/wave-executor/circuit-breaker.md +3 -1
- package/skills/wave-executor/references/wave-loop-dispatch.md +4 -2
- package/skills/wave-executor/references/wave-loop-review.md +1 -1
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +6 -2
- package/templates/nextjs-minimal/package.json +1 -1
- package/templates/node-minimal/package.json +1 -1
- package/scripts/lib/multi-provider-build/providers.mjs +0 -64
- package/scripts/lib/multi-provider-build/templating.mjs +0 -130
- package/scripts/lib/owner-config/coerce.mjs +0 -29
- package/scripts/lib/owner-config/constants.mjs +0 -21
- package/scripts/lib/owner-config/defaults.mjs +0 -50
- package/scripts/lib/owner-config/error.mjs +0 -19
- package/scripts/lib/owner-config/index.mjs +0 -13
- package/scripts/lib/owner-config/merge.mjs +0 -52
- package/scripts/lib/owner-config/validate.mjs +0 -259
- package/scripts/lib/owner-config-loader.mjs +0 -170
- package/scripts/lib/owner-config.mjs +0 -28
- package/scripts/lib/soul-resolve.mjs +0 -130
- package/scripts/lib/vault-mirror/render.mjs +0 -8
|
@@ -17,10 +17,12 @@
|
|
|
17
17
|
* Decision flow:
|
|
18
18
|
* G1 tool filter — only Bash is gated.
|
|
19
19
|
* G2 command is a non-empty string.
|
|
20
|
-
* G3 matcher — `gh|glab … issue create|new`
|
|
20
|
+
* G3 matcher — `gh|glab … issue create|new` plus the REST route
|
|
21
|
+
* (`gh|glab api … /issues`, #1163) only. PR/MR creation passes.
|
|
21
22
|
* Verb-resolved since #1145, so a wrapped (`nohup`), absolute-path or
|
|
22
23
|
* env-prefixed create is seen; a `--help` invocation is not (it creates
|
|
23
|
-
* nothing).
|
|
24
|
+
* nothing). Since #1163 the matcher returns EVERY create statement of the
|
|
25
|
+
* chain, and the cap charges ONE unit per statement.
|
|
24
26
|
* G4 config — `issue-budget` from CLAUDE.md/AGENTS.md. `mode: off` → allow.
|
|
25
27
|
* G3b bulk — a create inside a shell LOOP body creates an unknowable number
|
|
26
28
|
* of issues (#1145). `strict` → deny; `warn` → allow with an explicit
|
|
@@ -29,7 +31,8 @@
|
|
|
29
31
|
* G5 exemption — priority::critical / carryover class / broken-window /
|
|
30
32
|
* the overflow collector itself bypass the cap unconditionally, keeping
|
|
31
33
|
* the session-end promises at SKILL.md:319 and :1113 intact.
|
|
32
|
-
* G6 charge the counter
|
|
34
|
+
* G6 charge the counter ONCE PER ISSUE-CREATE STATEMENT in
|
|
35
|
+
* .orchestrator/runtime/issue-budget/<hash>.json
|
|
33
36
|
* (one file per session since #1141 — see scripts/lib/issue-budget.mjs
|
|
34
37
|
* `budgetStateRel`).
|
|
35
38
|
* under cap → allow; over cap + `warn` → allow with stderr notice;
|
|
@@ -50,13 +53,16 @@
|
|
|
50
53
|
import { readStdin, emitAllow, emitDeny, emitWarn } from '../scripts/lib/io.mjs';
|
|
51
54
|
import { resolveProjectDir } from '../scripts/lib/platform.mjs';
|
|
52
55
|
import { readJson } from '../scripts/lib/common.mjs';
|
|
53
|
-
import {
|
|
56
|
+
import { findIssueCreateStatements, isLoopedIssueCreate } from './_lib/vcs-create-matcher.mjs';
|
|
54
57
|
import {
|
|
55
58
|
loadIssueBudgetConfig,
|
|
56
59
|
resolveIssueBudgetSessionId,
|
|
57
60
|
chargeIssueBudget,
|
|
58
61
|
classifyExemption,
|
|
59
62
|
formatBlockReason,
|
|
63
|
+
readBudgetState,
|
|
64
|
+
writeBudgetState,
|
|
65
|
+
budgetStatePath,
|
|
60
66
|
} from '../scripts/lib/issue-budget.mjs';
|
|
61
67
|
|
|
62
68
|
import { shouldRunHook } from './_lib/profile-gate.mjs';
|
|
@@ -145,7 +151,13 @@ async function resolveSessionId(input, projectDir) {
|
|
|
145
151
|
*
|
|
146
152
|
* NAMED CEILING (BV-004): a loop is detected by `do`/`done` in command position
|
|
147
153
|
* (see `isLoopedIssueCreate`), so an UNROLLED bulk create — 50 create statements
|
|
148
|
-
* chained with `&&` — is not a "loop"
|
|
154
|
+
* chained with `&&` — is not a "loop". Since #1163 it is charged 50, once per
|
|
155
|
+
* issue-create STATEMENT: until then this comment CLAIMED that behaviour while
|
|
156
|
+
* the code called `chargeIssueBudget` exactly once per Bash tool call with the
|
|
157
|
+
* whole command string, so `glab issue create --title A && glab issue create
|
|
158
|
+
* --title B` charged 1 for 2 (measured 2026-09-09). The claim is now true
|
|
159
|
+
* because `findIssueCreateStatements` supplies the per-statement units and the
|
|
160
|
+
* exemption is classified per statement too. Revisit this
|
|
149
161
|
* choice if the overflow triage of a per-session counter file
|
|
150
162
|
* (`.orchestrator/runtime/issue-budget/<hash>.json`) shows operators routinely
|
|
151
163
|
* hitting this deny on loops over a KNOWN literal word list; the cheap answer
|
|
@@ -176,6 +188,59 @@ function formatLoopDenyReason(config) {
|
|
|
176
188
|
].join('\n');
|
|
177
189
|
}
|
|
178
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Park every chargeable statement of a command that does NOT fit under the cap,
|
|
193
|
+
* and return a `formatBlockReason`-shaped verdict for the deny envelope.
|
|
194
|
+
*
|
|
195
|
+
* ## Why this is not `chargeIssueBudget`
|
|
196
|
+
*
|
|
197
|
+
* `chargeIssueBudget` decides ONE creation against the current count, and its
|
|
198
|
+
* strict branch parks only when the count is ALREADY at the cap. A chain of
|
|
199
|
+
* statements that straddles the cap (count 11, max 12, three creates) has no
|
|
200
|
+
* single call shape in that API: the first statement would be ALLOWED and
|
|
201
|
+
* counted, and the deny that follows would leave that count standing for an
|
|
202
|
+
* issue nobody created. So the fit is judged for the chain as a whole and the
|
|
203
|
+
* whole chain is parked — count and exempt untouched, because nothing ran.
|
|
204
|
+
*
|
|
205
|
+
* The exemption CLASSIFICATION still comes from the shared core
|
|
206
|
+
* (`classifyExemption`, applied by the caller); what is local here is only the
|
|
207
|
+
* bookkeeping write, through the module's own public `writeBudgetState`.
|
|
208
|
+
*
|
|
209
|
+
* An identity-less invocation (no session key) must not write at all — the
|
|
210
|
+
* legacy flat path is shared across sessions and writing it would reset a live
|
|
211
|
+
* session's count and drop its parked overflow. Same rule `chargeIssueBudget`'s
|
|
212
|
+
* `persist` applies; the deny still happens, only unrecorded.
|
|
213
|
+
*
|
|
214
|
+
* @param {{ projectDir: string, sessionId: string|null,
|
|
215
|
+
* state: { count: number, exempt: number, overflow: object[], sessionId: string|null },
|
|
216
|
+
* chargeable: Array<{ text: string, title: string|null }>,
|
|
217
|
+
* config: { "max-per-session": number, mode: string, overflow: string },
|
|
218
|
+
* now?: string }} opts
|
|
219
|
+
* @returns {{ count: number, max: number, overflowPath: string,
|
|
220
|
+
* overflowSink: string, overflowCount: number }}
|
|
221
|
+
*/
|
|
222
|
+
function parkOverflow({
|
|
223
|
+
projectDir,
|
|
224
|
+
sessionId,
|
|
225
|
+
state,
|
|
226
|
+
chargeable,
|
|
227
|
+
config,
|
|
228
|
+
now = new Date().toISOString(),
|
|
229
|
+
}) {
|
|
230
|
+
state.sessionId = sessionId;
|
|
231
|
+
for (const s of chargeable) {
|
|
232
|
+
state.overflow.push({ title: s.title ?? null, command: String(s.text).slice(0, 500), at: now });
|
|
233
|
+
}
|
|
234
|
+
if (sessionId !== null) writeBudgetState(projectDir, state);
|
|
235
|
+
return {
|
|
236
|
+
count: state.count,
|
|
237
|
+
max: config['max-per-session'],
|
|
238
|
+
overflowPath: budgetStatePath(projectDir, sessionId),
|
|
239
|
+
overflowSink: config.overflow,
|
|
240
|
+
overflowCount: state.overflow.length,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
179
244
|
// ---------------------------------------------------------------------------
|
|
180
245
|
// Main
|
|
181
246
|
// ---------------------------------------------------------------------------
|
|
@@ -191,8 +256,11 @@ async function main() {
|
|
|
191
256
|
const command = input?.tool_input?.command;
|
|
192
257
|
if (typeof command !== 'string' || command.length === 0) return emitAllow();
|
|
193
258
|
|
|
194
|
-
// G3 — shared matcher. Only ISSUE creation is capped;
|
|
195
|
-
|
|
259
|
+
// G3 — shared matcher, PER STATEMENT (#1163). Only ISSUE creation is capped;
|
|
260
|
+
// `pr`/`mr` create pass. An empty list is the old `!isIssueCreate(command)`
|
|
261
|
+
// short-circuit, unchanged.
|
|
262
|
+
const statements = findIssueCreateStatements(command);
|
|
263
|
+
if (statements.length === 0) return emitAllow();
|
|
196
264
|
|
|
197
265
|
const projectDir = resolveProjectDir() || process.cwd();
|
|
198
266
|
|
|
@@ -202,9 +270,12 @@ async function main() {
|
|
|
202
270
|
|
|
203
271
|
// G3b — bulk creation whose multiplicity is not computable (#1145). The
|
|
204
272
|
// exemption is asked FIRST, through the same classifier chargeIssueBudget
|
|
205
|
-
// uses, so a looped carryover sweep keeps its unconditional pass.
|
|
273
|
+
// uses, so a looped carryover sweep keeps its unconditional pass. It is asked
|
|
274
|
+
// on the FIRST issue-create statement's text, which is the very statement
|
|
275
|
+
// `isLoopedIssueCreate` judges — classifying it on the whole command would
|
|
276
|
+
// let an exempt NEIGHBOUR statement lift the loop deny.
|
|
206
277
|
const uncountableBulk =
|
|
207
|
-
isLoopedIssueCreate(command) && !classifyExemption(
|
|
278
|
+
isLoopedIssueCreate(command) && !classifyExemption(statements[0].text).exempt;
|
|
208
279
|
if (uncountableBulk && config.mode === 'strict') {
|
|
209
280
|
// Nothing is charged and nothing is parked — the command is handed back
|
|
210
281
|
// whole, which is what makes unrolling it the correct next action.
|
|
@@ -213,26 +284,50 @@ async function main() {
|
|
|
213
284
|
|
|
214
285
|
const sessionId = await resolveSessionId(input, projectDir);
|
|
215
286
|
|
|
216
|
-
// G5
|
|
217
|
-
// the
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
287
|
+
// G5 pre-flight — a Bash call is ATOMIC from this hook's point of view: a
|
|
288
|
+
// deny refuses the WHOLE command, so not one of its statements runs. Charging
|
|
289
|
+
// statement-by-statement until one blocks would therefore count creations
|
|
290
|
+
// that never happened (and double-count them when the operator re-issues the
|
|
291
|
+
// command unrolled). So the fit is decided BEFORE any charge, and a command
|
|
292
|
+
// that does not fit parks every chargeable statement without counting any.
|
|
293
|
+
const chargeable = statements.filter((s) => !classifyExemption(s.text).exempt);
|
|
294
|
+
if (config.mode === 'strict' && chargeable.length > 0) {
|
|
295
|
+
const state = readBudgetState(projectDir, sessionId);
|
|
296
|
+
if (state.count + chargeable.length > config['max-per-session']) {
|
|
297
|
+
return emitDeny(formatBlockReason(parkOverflow({
|
|
298
|
+
projectDir, sessionId, state, chargeable, config,
|
|
299
|
+
})));
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// G6 — charge ONE unit per issue-create STATEMENT. The decision itself stays
|
|
304
|
+
// in the shared core (scripts/lib/issue-budget.mjs), so the programmatic path
|
|
305
|
+
// (scripts/lib/spiral-carryover.mjs runCli) decides identically; what changed
|
|
306
|
+
// in #1163 is only HOW MANY times it is asked. Each statement is judged on
|
|
307
|
+
// its OWN text: `glab issue create --title REAL && glab issue create
|
|
308
|
+
// --label carryover --title X` is 1 charge + 1 exemption, never 2 exemptions.
|
|
309
|
+
const verdicts = statements.map((s) =>
|
|
310
|
+
chargeIssueBudget({
|
|
311
|
+
repoRoot: projectDir,
|
|
312
|
+
sessionId,
|
|
313
|
+
command: s.text,
|
|
314
|
+
title: s.title,
|
|
315
|
+
config,
|
|
316
|
+
}),
|
|
317
|
+
);
|
|
318
|
+
const verdict = verdicts[verdicts.length - 1];
|
|
319
|
+
const exemptions = verdicts.filter((v) => v.decision === 'exempt');
|
|
320
|
+
const blocked = verdicts.find((v) => v.decision === 'block');
|
|
226
321
|
|
|
227
|
-
if (
|
|
322
|
+
if (exemptions.length > 0) {
|
|
323
|
+
const reasons = [...new Set(exemptions.map((v) => v.reason))].join(', ');
|
|
228
324
|
process.stderr.write(
|
|
229
|
-
`ℹ pre-bash-issue-budget: exempt (${
|
|
230
|
-
`(${verdict.count}/${verdict.max})\n`,
|
|
325
|
+
`ℹ pre-bash-issue-budget: ${exemptions.length} exempt statement(s) (${reasons}) — ` +
|
|
326
|
+
`cap not charged (${verdict.count}/${verdict.max})\n`,
|
|
231
327
|
);
|
|
232
|
-
return emitAllow();
|
|
233
328
|
}
|
|
234
329
|
|
|
235
|
-
if (
|
|
330
|
+
if (verdicts.some((v) => v.decision === 'warn')) {
|
|
236
331
|
process.stderr.write(
|
|
237
332
|
`⚠ pre-bash-issue-budget: session cap exceeded — ${verdict.count}/${verdict.max} ` +
|
|
238
333
|
`issues created (mode: warn — allowing). Set \`issue-budget.mode: strict\` to enforce.\n`,
|
|
@@ -240,7 +335,9 @@ async function main() {
|
|
|
240
335
|
return emitAllow();
|
|
241
336
|
}
|
|
242
337
|
|
|
243
|
-
if (
|
|
338
|
+
if (exemptions.length === statements.length) return emitAllow();
|
|
339
|
+
|
|
340
|
+
if (blocked) {
|
|
244
341
|
// Single channel (#906). formatBlockReason's multi-line text — overflow
|
|
245
342
|
// store path, the [Backlog-Sammel] fold-in promise, the exemption list and
|
|
246
343
|
// the cap-raising hint — used to go to stderr AND to a duplicated `exit 2`
|
|
@@ -249,7 +346,7 @@ async function main() {
|
|
|
249
346
|
// to Claude (the actor that must re-file or defer the issue), while the
|
|
250
347
|
// operator gets the first line as the systemMessage headline. Under exit 0
|
|
251
348
|
// a stderr write would only reach the debug log — dead, but alive-looking.
|
|
252
|
-
emitDeny(formatBlockReason(
|
|
349
|
+
return emitDeny(formatBlockReason(blocked));
|
|
253
350
|
}
|
|
254
351
|
|
|
255
352
|
// A PERMITTED bulk create is charged ONCE, which is an undercount by
|
|
@@ -64,6 +64,33 @@
|
|
|
64
64
|
* `subagent_transcript_found: true` as token-bearing; summing across the
|
|
65
65
|
* history double-counts the parent once per subagent.
|
|
66
66
|
*
|
|
67
|
+
* TOKEN-DATA PROVENANCE — SERIES BREAK 2026-09-09 (schema_version 2, #1244).
|
|
68
|
+
* Until this fix `token_input` was the raw `usage.input_tokens` sum only, and
|
|
69
|
+
* `cache_read_input_tokens` / `cache_creation_input_tokens` were read nowhere in
|
|
70
|
+
* the repo. Under prompt caching virtually the whole prompt is cache traffic, so
|
|
71
|
+
* that number described a rounding error rather than the run: measured
|
|
72
|
+
* 2026-09-09 on agent af50d1eda37099e17, the ledger recorded token_input 56
|
|
73
|
+
* where its transcript holds 56 + 3,528,536 cache_read + 147,587 cache_creation
|
|
74
|
+
* = 3,676,179 — an understatement of 65,646×; session-wide 1,518 vs 85,271,214.
|
|
75
|
+
* (token_output was within ~11% and is unaffected.)
|
|
76
|
+
*
|
|
77
|
+
* From schema_version 2 onward:
|
|
78
|
+
* token_input = uncached + cache_read + cache_creation
|
|
79
|
+
* (BILLABLE PROMPT VOLUME — a redefinition, not a
|
|
80
|
+
* widening: v1 and v2 values are not comparable)
|
|
81
|
+
* token_input_uncached = raw usage.input_tokens (additive)
|
|
82
|
+
* token_cache_read = usage.cache_read_input_tokens (additive)
|
|
83
|
+
* token_cache_creation = usage.cache_creation_input_tokens (additive)
|
|
84
|
+
* model = message.model, or null (additive; enables pricing)
|
|
85
|
+
* gen_ai.usage.input_tokens stays the RAW UNCACHED value (OTel semantic), so
|
|
86
|
+
* it is deliberately ≠ token_input under v2. Two new OTel aliases carry the
|
|
87
|
+
* cache buckets.
|
|
88
|
+
*
|
|
89
|
+
* FORWARD-ONLY. Nothing recomputes the v1 history, so consumers MUST gate any
|
|
90
|
+
* v2 sum on `schema_version >= 2` — `scripts/lib/session-token-rollup.mjs` does
|
|
91
|
+
* exactly that and reports the excluded v1 records as `legacy_v1_records`
|
|
92
|
+
* rather than silently folding them in.
|
|
93
|
+
*
|
|
67
94
|
* A SECOND, independent defect rode along until #950: the requestId dedup kept
|
|
68
95
|
* the FIRST usage block per id, which on a streaming transcript is a partial
|
|
69
96
|
* snapshot (typically `output_tokens: 1`). Any record written before #950 —
|
|
@@ -391,13 +418,27 @@ function readStdinJson() {
|
|
|
391
418
|
* so the present side still sums and the aggregate stays valid.
|
|
392
419
|
*
|
|
393
420
|
* NEVER throws. Any failure (missing/unreadable path, 0 assistant turns, parse
|
|
394
|
-
* error) yields
|
|
421
|
+
* error) yields an all-null result so the hook still exits 0.
|
|
422
|
+
*
|
|
423
|
+
* Four buckets, not two (#1244 / schema_version 2): `input_tokens`,
|
|
424
|
+
* `cache_read_input_tokens`, `cache_creation_input_tokens` and `output_tokens`
|
|
425
|
+
* are accumulated SEPARATELY per deduped turn, because they are billed at three
|
|
426
|
+
* different rates. `model` is captured from `message.model` of the last kept
|
|
427
|
+
* block that carries one (null when absent) — it is what makes a cost estimate
|
|
428
|
+
* possible at all downstream.
|
|
395
429
|
*
|
|
396
430
|
* @param {string|undefined|null} transcriptPath — absolute path from stdin
|
|
397
|
-
* @returns {{
|
|
431
|
+
* @returns {{ tokenInputUncached: number|null, tokenCacheRead: number|null,
|
|
432
|
+
* tokenCacheCreation: number|null, tokenOutput: number|null, model: string|null }}
|
|
398
433
|
*/
|
|
399
434
|
function extractTranscriptUsage(transcriptPath) {
|
|
400
|
-
const nullResult = {
|
|
435
|
+
const nullResult = {
|
|
436
|
+
tokenInputUncached: null,
|
|
437
|
+
tokenCacheRead: null,
|
|
438
|
+
tokenCacheCreation: null,
|
|
439
|
+
tokenOutput: null,
|
|
440
|
+
model: null,
|
|
441
|
+
};
|
|
401
442
|
try {
|
|
402
443
|
if (typeof transcriptPath !== 'string' || !transcriptPath.trim()) return nullResult;
|
|
403
444
|
if (!fs.existsSync(transcriptPath)) return nullResult;
|
|
@@ -447,9 +488,15 @@ function extractTranscriptUsage(transcriptPath) {
|
|
|
447
488
|
// Dedup by requestId — keep the LAST usage block per id (#950). The
|
|
448
489
|
// repeats are cumulative streaming snapshots, so overwriting is what
|
|
449
490
|
// promotes the partial first snapshot to the response's real total.
|
|
491
|
+
// Model id rides along with the usage block (#1244): it is per-turn data
|
|
492
|
+
// and the only thing that makes the record priceable downstream.
|
|
493
|
+
const turnModel = typeof obj.message?.model === 'string' && obj.message.model
|
|
494
|
+
? obj.message.model
|
|
495
|
+
: null;
|
|
496
|
+
|
|
450
497
|
const requestId = obj.requestId;
|
|
451
498
|
if (typeof requestId === 'string' && requestId) {
|
|
452
|
-
byRequestId.set(requestId, usage);
|
|
499
|
+
byRequestId.set(requestId, { usage, model: turnModel });
|
|
453
500
|
continue;
|
|
454
501
|
}
|
|
455
502
|
|
|
@@ -461,9 +508,9 @@ function extractTranscriptUsage(transcriptPath) {
|
|
|
461
508
|
// usable identity; anything else falls through to the individual count.
|
|
462
509
|
const messageId = obj.message?.id;
|
|
463
510
|
if (typeof messageId === 'string' && messageId) {
|
|
464
|
-
byMessageId.set(messageId, usage);
|
|
511
|
+
byMessageId.set(messageId, { usage, model: turnModel });
|
|
465
512
|
} else {
|
|
466
|
-
unkeyable.push(usage);
|
|
513
|
+
unkeyable.push({ usage, model: turnModel });
|
|
467
514
|
}
|
|
468
515
|
}
|
|
469
516
|
|
|
@@ -472,23 +519,34 @@ function extractTranscriptUsage(transcriptPath) {
|
|
|
472
519
|
// No assistant turns with usage → leave fields null (forward-compat).
|
|
473
520
|
if (kept.length === 0) return nullResult;
|
|
474
521
|
|
|
475
|
-
let
|
|
522
|
+
let tokenInputUncached = 0;
|
|
523
|
+
let tokenCacheRead = 0;
|
|
524
|
+
let tokenCacheCreation = 0;
|
|
476
525
|
let tokenOutput = 0;
|
|
477
|
-
|
|
526
|
+
let model = null;
|
|
527
|
+
for (const { usage, model: turnModel } of kept) {
|
|
478
528
|
// Per-turn clamp (#624): add a turn's value ONLY when it is a non-negative
|
|
479
529
|
// integer. A poisoned value (negative, NaN, float like 10.5) is skipped so
|
|
480
530
|
// the good turns survive. An absent side contributes 0, not null.
|
|
481
531
|
const inTok = usage.input_tokens;
|
|
532
|
+
const cacheRead = usage.cache_read_input_tokens;
|
|
533
|
+
const cacheCreation = usage.cache_creation_input_tokens;
|
|
482
534
|
const outTok = usage.output_tokens;
|
|
483
|
-
if (Number.isInteger(inTok) && inTok >= 0)
|
|
535
|
+
if (Number.isInteger(inTok) && inTok >= 0) tokenInputUncached += inTok;
|
|
536
|
+
if (Number.isInteger(cacheRead) && cacheRead >= 0) tokenCacheRead += cacheRead;
|
|
537
|
+
if (Number.isInteger(cacheCreation) && cacheCreation >= 0) tokenCacheCreation += cacheCreation;
|
|
484
538
|
if (Number.isInteger(outTok) && outTok >= 0) tokenOutput += outTok;
|
|
539
|
+
if (turnModel !== null) model = turnModel;
|
|
485
540
|
}
|
|
486
541
|
|
|
487
|
-
// The
|
|
488
|
-
// above (Σ of non-negative integers), so emit
|
|
542
|
+
// The aggregates are guaranteed non-negative integers by per-turn clamping
|
|
543
|
+
// above (Σ of non-negative integers), so emit them directly.
|
|
489
544
|
return {
|
|
490
|
-
|
|
545
|
+
tokenInputUncached,
|
|
546
|
+
tokenCacheRead,
|
|
547
|
+
tokenCacheCreation,
|
|
491
548
|
tokenOutput,
|
|
549
|
+
model,
|
|
492
550
|
};
|
|
493
551
|
} catch {
|
|
494
552
|
return nullResult;
|
|
@@ -657,7 +715,7 @@ async function main() {
|
|
|
657
715
|
timestamp: new Date().toISOString(),
|
|
658
716
|
event,
|
|
659
717
|
agent_id: agentId,
|
|
660
|
-
schema_version:
|
|
718
|
+
schema_version: 2,
|
|
661
719
|
...(agentType !== null ? { agent_type: agentType } : {}),
|
|
662
720
|
...(parentSessionId !== null ? { parent_session_id: parentSessionId } : {}),
|
|
663
721
|
};
|
|
@@ -705,23 +763,51 @@ async function main() {
|
|
|
705
763
|
// NO fallback to input.transcript_path: that path is the parent session
|
|
706
764
|
// transcript, and reading it is the #949 defect (every stop inherited the
|
|
707
765
|
// parent's running totals). A phantom stop gets null — the honest value.
|
|
708
|
-
const {
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
766
|
+
const { tokenInputUncached, tokenCacheRead, tokenCacheCreation, tokenOutput, model } =
|
|
767
|
+
subagentTranscriptFound
|
|
768
|
+
? extractTranscriptUsage(subagentTranscriptPath)
|
|
769
|
+
: {
|
|
770
|
+
tokenInputUncached: null,
|
|
771
|
+
tokenCacheRead: null,
|
|
772
|
+
tokenCacheCreation: null,
|
|
773
|
+
tokenOutput: null,
|
|
774
|
+
model: null,
|
|
775
|
+
};
|
|
776
|
+
|
|
777
|
+
// schema_version 2 (#1244): `token_input` is now BILLABLE PROMPT VOLUME —
|
|
778
|
+
// uncached + cache_read + cache_creation — and the three components are
|
|
779
|
+
// written additively beside it. See the file header § TOKEN-DATA PROVENANCE
|
|
780
|
+
// for the 2026-09-09 series break this creates.
|
|
781
|
+
if (tokenInputUncached !== null) {
|
|
782
|
+
record.token_input =
|
|
783
|
+
tokenInputUncached + (tokenCacheRead ?? 0) + (tokenCacheCreation ?? 0);
|
|
784
|
+
record.token_input_uncached = tokenInputUncached;
|
|
785
|
+
record.token_cache_read = tokenCacheRead;
|
|
786
|
+
record.token_cache_creation = tokenCacheCreation;
|
|
787
|
+
}
|
|
712
788
|
if (tokenOutput !== null) record.token_output = tokenOutput;
|
|
713
789
|
|
|
790
|
+
// Model id (#1244) — null when the transcript exposes none. Cost is NOT
|
|
791
|
+
// computed here: pricing lives in scripts/lib/telemetry/pricing.mjs and is
|
|
792
|
+
// applied by the session rollup, so this hot-path hook keeps its import
|
|
793
|
+
// graph unchanged.
|
|
794
|
+
record.model = model;
|
|
795
|
+
|
|
714
796
|
// Cost is best-effort / forward-compat (#624): the native transcript does
|
|
715
797
|
// NOT expose total_cost_usd today, so this is null in practice. No rate
|
|
716
|
-
// table — use the native cost only, default null when absent.
|
|
798
|
+
// table is applied HERE — use the native cost only, default null when absent.
|
|
717
799
|
const totalCostUsd =
|
|
718
800
|
typeof input.total_cost_usd === 'number' && Number.isFinite(input.total_cost_usd) && input.total_cost_usd >= 0
|
|
719
801
|
? input.total_cost_usd
|
|
720
802
|
: null;
|
|
721
803
|
record.total_cost_usd = totalCostUsd;
|
|
722
804
|
|
|
723
|
-
// OTel alias — #411 additive
|
|
724
|
-
|
|
805
|
+
// OTel alias — #411 additive. `gen_ai.usage.input_tokens` stays the RAW
|
|
806
|
+
// UNCACHED value (OTel semantic), which is why it is deliberately NOT equal
|
|
807
|
+
// to `token_input` under schema_version 2.
|
|
808
|
+
record['gen_ai.usage.input_tokens'] = tokenInputUncached;
|
|
809
|
+
record['gen_ai.usage.cache_read_input_tokens'] = tokenCacheRead;
|
|
810
|
+
record['gen_ai.usage.cache_creation_input_tokens'] = tokenCacheCreation;
|
|
725
811
|
record['gen_ai.usage.output_tokens'] = tokenOutput;
|
|
726
812
|
record['gen_ai.system'] = 'anthropic';
|
|
727
813
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "session-orchestrator",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.0",
|
|
4
4
|
"description": "Loop engineering for AI coding agents — turn ad-hoc sessions into a repeatable research → plan → wave-execute → close loop with verification gates. Runs on Claude Code, Codex CLI, Cursor, and Pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"homepage": "https://session-orchestrator.com",
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
"dependencies": {
|
|
70
70
|
"@babel/parser": "^7.26.0",
|
|
71
71
|
"ajv": "^8.17.1",
|
|
72
|
-
"js-yaml": "^4.3.
|
|
72
|
+
"js-yaml": "^4.3.2",
|
|
73
73
|
"mdast-util-to-string": "^4.0.0",
|
|
74
74
|
"picomatch": "^4.0.4",
|
|
75
75
|
"remark": "^15.0.0",
|
|
@@ -83,12 +83,12 @@
|
|
|
83
83
|
"@commitlint/cli": "^19.6.0",
|
|
84
84
|
"@commitlint/config-conventional": "^19.6.0",
|
|
85
85
|
"@eslint/js": "^10.0.1",
|
|
86
|
-
"@vitest/coverage-v8": "^4.1.
|
|
86
|
+
"@vitest/coverage-v8": "^4.1.11",
|
|
87
87
|
"eslint": "^10.2.1",
|
|
88
88
|
"husky": "^9.1.7",
|
|
89
89
|
"lint-staged": "^15.2.10",
|
|
90
90
|
"prettier": "^3.0.0",
|
|
91
|
-
"vitest": "^4.1.
|
|
91
|
+
"vitest": "^4.1.11"
|
|
92
92
|
},
|
|
93
93
|
"overrides": {
|
|
94
94
|
"vite": "^8.0.16",
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Data-only lookup for the configured local bootstrap baseline. */
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { realpathSync } from 'node:fs';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
import { loadBaselineArchetypes } from './lib/baseline-archetypes.mjs';
|
|
7
|
+
|
|
8
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
9
|
+
const options = {};
|
|
10
|
+
for (let index = 0; index < argv.length; index++) {
|
|
11
|
+
const arg = argv[index];
|
|
12
|
+
if (arg === '--help' || arg === '-h') {
|
|
13
|
+
process.stdout.write('Usage: node scripts/baseline-archetypes.mjs [--repo PATH] [--archetype ID]\nOffline, read-only JSON lookup. Exit 0: public/private; exit 2: invalid configuration or contract.\n');
|
|
14
|
+
return 0;
|
|
15
|
+
}
|
|
16
|
+
if (!['--repo', '--archetype'].includes(arg) || !argv[index + 1] || argv[index + 1].startsWith('--')) {
|
|
17
|
+
process.stdout.write(`${JSON.stringify({ status: 'error', reason: 'invalid-arguments', archetypes: [], selected: null })}\n`);
|
|
18
|
+
return 2;
|
|
19
|
+
}
|
|
20
|
+
options[arg === '--repo' ? 'repoRoot' : 'archetype'] = argv[++index];
|
|
21
|
+
}
|
|
22
|
+
if (options.repoRoot) options.repoRoot = path.resolve(options.repoRoot);
|
|
23
|
+
const result = await loadBaselineArchetypes(options);
|
|
24
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
25
|
+
return result.status === 'error' ? 2 : 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) process.exitCode = await main();
|
|
@@ -23,7 +23,6 @@ import { randomUUID } from 'node:crypto';
|
|
|
23
23
|
import path from 'node:path';
|
|
24
24
|
|
|
25
25
|
import { filterRealSessions } from './session-schema.mjs';
|
|
26
|
-
import { emitEvent, sessionAttribution } from './events.mjs';
|
|
27
26
|
|
|
28
27
|
// ---------------------------------------------------------------------------
|
|
29
28
|
// Constants
|
|
@@ -246,73 +245,6 @@ export async function shouldDispatchAutoDialectic({
|
|
|
246
245
|
};
|
|
247
246
|
}
|
|
248
247
|
|
|
249
|
-
// ---------------------------------------------------------------------------
|
|
250
|
-
// Decision + mechanical telemetry (#1200 part c)
|
|
251
|
-
// ---------------------------------------------------------------------------
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* `shouldDispatchAutoDialectic()` plus a MECHANICAL `orchestrator.dialectic.nudge_decided`
|
|
255
|
-
* record, so the nudge decision is observable without depending on the
|
|
256
|
-
* session-end skill prose actually reaching the emit step (#1200: 0 records of
|
|
257
|
-
* this class across 164k fleet events despite the nudge firing every close).
|
|
258
|
-
*
|
|
259
|
-
* Contract-preserving wrapper: calls `shouldDispatchAutoDialectic()` unchanged
|
|
260
|
-
* and returns its decision object verbatim — the emit is a side effect bolted
|
|
261
|
-
* on, never a change to the decision logic or its return shape.
|
|
262
|
-
*
|
|
263
|
-
* Emits on ALL FOUR return paths (kill-switch, no-new-input,
|
|
264
|
-
* cadence-threshold-met, under-threshold) — best-effort, try/catch-wrapped,
|
|
265
|
-
* because `emitEvent()` throws `EventValidationError` on a malformed record
|
|
266
|
-
* and a telemetry failure must never change what the caller decides to do
|
|
267
|
-
* (same posture as `scripts/lib/reconcile/engine.mjs`'s `emitReconcileCompleted`
|
|
268
|
-
* wrapper).
|
|
269
|
-
*
|
|
270
|
-
* @param {object} args
|
|
271
|
-
* @param {string} args.repoRoot
|
|
272
|
-
* @param {number} [args.cadence=DEFAULT_CADENCE] `dialectic.cadence` from config.
|
|
273
|
-
* @param {object} [args.signals] Pre-computed signals (skips disk reads) — forwarded verbatim.
|
|
274
|
-
* @param {Function|null} [args.emitFn=emitEvent] DI hook for testing / disabling
|
|
275
|
-
* emission; defaults to `emitEvent` from `./events.mjs`. Any error it throws
|
|
276
|
-
* is swallowed — it never changes the returned decision.
|
|
277
|
-
* @param {boolean} [args.record=true] When `false`, no event is emitted at all —
|
|
278
|
-
* for read-only PROBE callers (e.g. the session-end Phase 3.6.x tail-skip
|
|
279
|
-
* aggregator, `scripts/lib/session-end/phase-skip.mjs`, whose documented
|
|
280
|
-
* contract is side-effect-free) where the decision is computed for internal
|
|
281
|
-
* branching only and must never itself be recorded as a nudge decision.
|
|
282
|
-
* @returns {Promise<{trigger:boolean, reason:string, signals:object}>}
|
|
283
|
-
*/
|
|
284
|
-
export async function decideAndRecordAutoDialectic({
|
|
285
|
-
repoRoot,
|
|
286
|
-
cadence = DEFAULT_CADENCE,
|
|
287
|
-
signals,
|
|
288
|
-
emitFn = emitEvent,
|
|
289
|
-
record = true,
|
|
290
|
-
} = {}) {
|
|
291
|
-
const decision = await shouldDispatchAutoDialectic({ repoRoot, cadence, signals });
|
|
292
|
-
|
|
293
|
-
if (record && typeof emitFn === 'function') {
|
|
294
|
-
try {
|
|
295
|
-
await emitFn(
|
|
296
|
-
'orchestrator.dialectic.nudge_decided',
|
|
297
|
-
{
|
|
298
|
-
...sessionAttribution(repoRoot),
|
|
299
|
-
decided: decision.trigger,
|
|
300
|
-
reason: decision.reason,
|
|
301
|
-
cadence,
|
|
302
|
-
sessions_since: decision.signals?.sessionsSinceLast,
|
|
303
|
-
learnings_since: decision.signals?.learningsSinceLast,
|
|
304
|
-
},
|
|
305
|
-
{ repoRoot },
|
|
306
|
-
);
|
|
307
|
-
} catch {
|
|
308
|
-
// best-effort — a telemetry failure must never block or change the
|
|
309
|
-
// decision the caller already has in hand.
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
return decision;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
248
|
// ---------------------------------------------------------------------------
|
|
317
249
|
// last-run — atomic write
|
|
318
250
|
// ---------------------------------------------------------------------------
|