@sweny-ai/core 0.1.198 → 0.1.200

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.
Files changed (44) hide show
  1. package/dist/agent-env.d.ts +94 -2
  2. package/dist/agent-env.js +144 -7
  3. package/dist/budget.d.ts +77 -11
  4. package/dist/budget.js +132 -16
  5. package/dist/cli/renderer.js +5 -0
  6. package/dist/cli/run-history.d.ts +12 -0
  7. package/dist/cli/run-history.js +16 -0
  8. package/dist/cli/run-output.d.ts +16 -1
  9. package/dist/cli/run-output.js +46 -4
  10. package/dist/cli/runs.js +38 -1
  11. package/dist/cli/ticket.js +7 -2
  12. package/dist/eval/index.d.ts +10 -0
  13. package/dist/eval/index.js +27 -6
  14. package/dist/eval/judge.d.ts +3 -0
  15. package/dist/eval/judge.js +12 -2
  16. package/dist/executor.d.ts +5 -1
  17. package/dist/executor.js +276 -31
  18. package/dist/harness/acp.d.ts +5 -2
  19. package/dist/harness/acp.js +11 -23
  20. package/dist/harness/auth.js +2 -1
  21. package/dist/harness/claude-code.d.ts +18 -13
  22. package/dist/harness/claude-code.js +34 -15
  23. package/dist/harness/codex.d.ts +22 -5
  24. package/dist/harness/codex.js +67 -11
  25. package/dist/harness/compat.js +1 -0
  26. package/dist/harness/index.d.ts +1 -1
  27. package/dist/harness/index.js +1 -1
  28. package/dist/harness/pi.d.ts +11 -5
  29. package/dist/harness/pi.js +27 -9
  30. package/dist/harness/prompts.d.ts +11 -1
  31. package/dist/harness/prompts.js +21 -2
  32. package/dist/harness/sandbox-wrapper.d.ts +13 -2
  33. package/dist/harness/sandbox-wrapper.js +29 -4
  34. package/dist/harness/types.d.ts +9 -1
  35. package/dist/index.d.ts +4 -4
  36. package/dist/index.js +2 -2
  37. package/dist/journal.d.ts +48 -10
  38. package/dist/journal.js +164 -16
  39. package/dist/safe-outputs.d.ts +9 -0
  40. package/dist/safe-outputs.js +41 -26
  41. package/dist/skills/linear.js +3 -1
  42. package/dist/studio.js +2 -1
  43. package/dist/types.d.ts +34 -1
  44. package/package.json +1 -1
@@ -4,8 +4,16 @@
4
4
  * `ClaudeClient.ask` / `ClaudeClient.evaluate` built before the harness seam
5
5
  * (#330); prompts.test.ts pins them against the old builders.
6
6
  */
7
- import type { Claude, JSONSchema, Logger } from "../types.js";
7
+ import type { Claude, JSONSchema, Logger, NodeUsage } from "../types.js";
8
8
  import type { AgentHarness } from "./types.js";
9
+ /**
10
+ * Hand a completion's usage to the caller's `onUsage`, once, before the
11
+ * adapter looks at whether the completion succeeded (a failed one was still
12
+ * billed). A hook that throws is logged and ignored: it never changes the
13
+ * completion's result, so a good answer is never turned into a failure (and
14
+ * a judge or route call is never repeated because of it).
15
+ */
16
+ export declare function reportCompletionUsage(onUsage: ((usage: NodeUsage) => void) | undefined, usage: NodeUsage | undefined, logger?: Logger): void;
9
17
  /** @internal Registers the legacy object behind a compat harness. */
10
18
  export declare function registerLegacyClaude(harness: AgentHarness, claude: Claude): void;
11
19
  /** @internal The legacy `Claude` behind a compat harness, if any. */
@@ -48,6 +56,7 @@ export declare function ask(harness: AgentHarness, opts: {
48
56
  model?: string;
49
57
  timeoutMs?: number;
50
58
  signal?: AbortSignal;
59
+ onUsage?: (usage: NodeUsage) => void;
51
60
  }): Promise<string>;
52
61
  /**
53
62
  * Route evaluation: pick one of N choices. Fails closed: null when the
@@ -62,4 +71,5 @@ export declare function evaluate(harness: AgentHarness, opts: {
62
71
  }[];
63
72
  timeoutMs?: number;
64
73
  signal?: AbortSignal;
74
+ onUsage?: (usage: NodeUsage) => void;
65
75
  }, logger?: Logger): Promise<string | null>;
@@ -6,6 +6,23 @@
6
6
  */
7
7
  import { consoleLogger } from "../types.js";
8
8
  import { fenceUntrustedJson } from "../untrusted.js";
9
+ /**
10
+ * Hand a completion's usage to the caller's `onUsage`, once, before the
11
+ * adapter looks at whether the completion succeeded (a failed one was still
12
+ * billed). A hook that throws is logged and ignored: it never changes the
13
+ * completion's result, so a good answer is never turned into a failure (and
14
+ * a judge or route call is never repeated because of it).
15
+ */
16
+ export function reportCompletionUsage(onUsage, usage, logger) {
17
+ if (!onUsage || !usage)
18
+ return;
19
+ try {
20
+ onUsage(usage);
21
+ }
22
+ catch (err) {
23
+ (logger ?? consoleLogger).warn(`usage hook failed: ${err instanceof Error ? err.message : String(err)}`);
24
+ }
25
+ }
9
26
  /** Legacy `Claude` objects wrapped by `claudeCompat()`, so their own ask/evaluate still win. */
10
27
  const LEGACY = new WeakMap();
11
28
  /** @internal Registers the legacy object behind a compat harness. */
@@ -76,13 +93,14 @@ export async function ask(harness, opts) {
76
93
  const legacy = legacyClaudeOf(harness);
77
94
  if (legacy)
78
95
  return legacy.ask(opts);
79
- const { instruction, context, model, timeoutMs, signal } = opts;
96
+ const { instruction, context, model, timeoutMs, signal, onUsage } = opts;
80
97
  const response = await harness.complete({
81
98
  prompt: buildAskPrompt(instruction, context),
82
99
  model,
83
100
  timeoutMs,
84
101
  signal,
85
102
  purpose: "ask",
103
+ ...(onUsage ? { onUsage } : {}),
86
104
  });
87
105
  return (response ?? "").trim();
88
106
  }
@@ -95,12 +113,13 @@ export async function evaluate(harness, opts, logger) {
95
113
  if (legacy)
96
114
  return legacy.evaluate(opts);
97
115
  const log = logger ?? harness.logger ?? consoleLogger;
98
- const { question, context, choices, timeoutMs, signal } = opts;
116
+ const { question, context, choices, timeoutMs, signal, onUsage } = opts;
99
117
  const response = await harness.complete({
100
118
  prompt: buildEvaluatePrompt(question, context, choices),
101
119
  timeoutMs,
102
120
  signal,
103
121
  purpose: "evaluate",
122
+ ...(onUsage ? { onUsage } : {}),
104
123
  });
105
124
  // The adapter already logged why; fail closed with no decision.
106
125
  if (response === null)
@@ -107,11 +107,22 @@ export interface SrtSettings {
107
107
  * each with an optional `:port`. Dropping an entry only narrows egress.
108
108
  */
109
109
  export declare function isSrtDomainPattern(entry: string): boolean;
110
+ /**
111
+ * Credential files an agent env points at outside `credentialHome`: the
112
+ * {@link CREDENTIAL_PATHS} under its `HOME` when that is another directory (a
113
+ * harness `env` overlay), `$CLAUDE_CONFIG_DIR/.credentials.json` and
114
+ * `$CODEX_HOME/auth.json`. A relative value resolves against the agent's
115
+ * `cwd`, where the agent would resolve it, never sweny's. Absolute paths.
116
+ */
117
+ export declare function envCredentialPaths(env: Record<string, string> | undefined, credentialHome: string, cwd: string): string[];
110
118
  /**
111
119
  * The srt settings for one wrapped spawn. Pure apart from `exists`.
112
- * `credentialHome` is the operator's real HOME (not the scratch HOME).
120
+ * `credentialHome` is the operator's real HOME (not the scratch HOME); the
121
+ * login files `req.env` selects elsewhere are denied too ({@link envCredentialPaths}).
113
122
  */
114
- export declare function buildSrtSettings(req: Pick<SandboxWrapRequest, "cwd" | "egress" | "readOnly" | "denyRead">, opts: {
123
+ export declare function buildSrtSettings(req: Pick<SandboxWrapRequest, "cwd" | "egress" | "readOnly" | "denyRead"> & {
124
+ env?: Record<string, string>;
125
+ }, opts: {
115
126
  home: string;
116
127
  credentialHome: string;
117
128
  exists?: (p: string) => boolean;
@@ -116,16 +116,41 @@ function real(p) {
116
116
  return p;
117
117
  }
118
118
  }
119
+ /**
120
+ * Credential files an agent env points at outside `credentialHome`: the
121
+ * {@link CREDENTIAL_PATHS} under its `HOME` when that is another directory (a
122
+ * harness `env` overlay), `$CLAUDE_CONFIG_DIR/.credentials.json` and
123
+ * `$CODEX_HOME/auth.json`. A relative value resolves against the agent's
124
+ * `cwd`, where the agent would resolve it, never sweny's. Absolute paths.
125
+ */
126
+ export function envCredentialPaths(env, credentialHome, cwd) {
127
+ if (!env)
128
+ return [];
129
+ const at = (dir, ...rest) => path.resolve(cwd, dir, ...rest);
130
+ const out = [];
131
+ if (env.HOME && at(env.HOME) !== path.resolve(credentialHome)) {
132
+ out.push(...CREDENTIAL_PATHS.map((rel) => at(env.HOME, rel)));
133
+ }
134
+ if (env.CLAUDE_CONFIG_DIR)
135
+ out.push(at(env.CLAUDE_CONFIG_DIR, ".credentials.json"));
136
+ if (env.CODEX_HOME)
137
+ out.push(at(env.CODEX_HOME, "auth.json"));
138
+ return out;
139
+ }
119
140
  /**
120
141
  * The srt settings for one wrapped spawn. Pure apart from `exists`.
121
- * `credentialHome` is the operator's real HOME (not the scratch HOME).
142
+ * `credentialHome` is the operator's real HOME (not the scratch HOME); the
143
+ * login files `req.env` selects elsewhere are denied too ({@link envCredentialPaths}).
122
144
  */
123
145
  export function buildSrtSettings(req, opts) {
124
146
  const exists = opts.exists ?? existsSync;
125
147
  const allowedDomains = [...new Set(req.egress.map((d) => d.trim()).filter(isSrtDomainPattern))];
126
- const denyRead = (opts.credentialPaths ?? CREDENTIAL_PATHS)
127
- .map((rel) => (path.isAbsolute(rel) ? rel : path.join(opts.credentialHome, rel)))
128
- .filter((p) => exists(p));
148
+ const denyRead = [
149
+ ...new Set([
150
+ ...(opts.credentialPaths ?? CREDENTIAL_PATHS).map((rel) => path.isAbsolute(rel) ? rel : path.join(opts.credentialHome, rel)),
151
+ ...envCredentialPaths(req.env, opts.credentialHome, req.cwd),
152
+ ]),
153
+ ].filter((p) => exists(p));
129
154
  // Deny the parent, not a snapshot of sibling homes: later-created runs
130
155
  // must stay hidden too. srt allowRead carves out only our own HOME.
131
156
  if (opts.isolationRoot)
@@ -9,7 +9,7 @@
9
9
  * Browser-safe: types only, no runtime imports.
10
10
  */
11
11
  import type { AgentAccess } from "../agent-env.js";
12
- import type { Claude, Logger, NodeResult } from "../types.js";
12
+ import type { Claude, Logger, NodeResult, NodeUsage } from "../types.js";
13
13
  export type HarnessId = "claude-code" | "mock" | "codex" | "pi" | "hermes" | "gemini" | `acp:${string}`;
14
14
  /** Portable tool vocabulary for node policy. */
15
15
  export type ToolClass = "shell" | "write" | "edit" | "net" | "subagent";
@@ -163,6 +163,14 @@ export interface HarnessCompleteRequest {
163
163
  * logs ("claude.ask: ..." vs "claude.evaluate: ..."); it never changes the query.
164
164
  */
165
165
  purpose?: "ask" | "evaluate";
166
+ /**
167
+ * Called at most once per completion with that completion's total usage
168
+ * (not cumulative, unlike the run request's `onUsage`), before any failure
169
+ * check: a failed completion was still billed. An adapter that cannot report
170
+ * usage never calls it. Guard the call so a throwing hook never changes the
171
+ * result or repeats the completion (in-tree adapters use `reportCompletionUsage`).
172
+ */
173
+ onUsage?: (usage: NodeUsage) => void;
166
174
  }
167
175
  export interface AgentHarness {
168
176
  readonly id: HarnessId;
package/dist/index.d.ts CHANGED
@@ -29,14 +29,14 @@ export { execute, RouteEvaluationError } from "./executor.js";
29
29
  export type { ExecuteOptions } from "./executor.js";
30
30
  export { systemOneProvider } from "./decider.js";
31
31
  export type { DeciderConfig, DeciderOperatorConfig } from "./decider.js";
32
- export { BudgetGuard, describeOverrun, minLimits, toLimits } from "./budget.js";
33
- export type { Budget, BudgetOverrun, BudgetUnit, SpendLimits } from "./budget.js";
32
+ export { BudgetGuard, SPEND_KINDS, describeOverrun, minLimits, toLimits } from "./budget.js";
33
+ export type { Budget, BudgetOverrun, BudgetUnit, KindSpend, SeedSpend, SpendBreakdown, SpendKind, SpendLimits, } from "./budget.js";
34
34
  export { resolveNodePermissions, buildNodePolicy } from "./node-policy.js";
35
35
  export type { ResolvedPermissions } from "./node-policy.js";
36
36
  export { applySafeOutputs, createEmitOutputTool, resolveActor, EMIT_OUTPUT_TOOL } from "./safe-outputs.js";
37
37
  export type { SafeOutputIntent, ActorInfo, WriteStageState } from "./safe-outputs.js";
38
- export { createHarness, claudeCompat, asClaude, policyGate, ClaudeCodeHarness, CodexHarness, PiHarness, SUPPORTED_AGENTS, } from "./harness/index.js";
39
- export type { AgentHarness, HarnessCapabilities, HarnessId, HarnessInfo, HarnessRunRequest, HarnessRunResult, NodePolicy, PolicyGateResult, PolicyWrappers, ToolClass, ClaudeCodeHarnessOptions, CodexHarnessOptions, PiHarnessOptions, } from "./harness/index.js";
38
+ export { createHarness, claudeCompat, asClaude, policyGate, ClaudeCodeHarness, CodexHarness, PiHarness, SUPPORTED_AGENTS, reportCompletionUsage, } from "./harness/index.js";
39
+ export type { AgentHarness, HarnessCapabilities, HarnessCompleteRequest, HarnessId, HarnessInfo, HarnessRunRequest, HarnessRunResult, NodePolicy, PolicyGateResult, PolicyWrappers, ToolClass, ClaudeCodeHarnessOptions, CodexHarnessOptions, PiHarnessOptions, } from "./harness/index.js";
40
40
  export { ClaudeClient, resolveAuthEnv } from "./harness/claude-code.js";
41
41
  export type { ClaudeClientOptions, SwenyAuthMode, ResolveAuthEnvOpts } from "./harness/claude-code.js";
42
42
  export { resolveExecutionModel } from "./model.js";
package/dist/index.js CHANGED
@@ -30,11 +30,11 @@ export { execute, RouteEvaluationError } from "./executor.js";
30
30
  // Decision models (#357): config types and the provider adapter only
31
31
  export { systemOneProvider } from "./decider.js";
32
32
  // Least-privilege nodes and safe outputs (#365)
33
- export { BudgetGuard, describeOverrun, minLimits, toLimits } from "./budget.js";
33
+ export { BudgetGuard, SPEND_KINDS, describeOverrun, minLimits, toLimits } from "./budget.js";
34
34
  export { resolveNodePermissions, buildNodePolicy } from "./node-policy.js";
35
35
  export { applySafeOutputs, createEmitOutputTool, resolveActor, EMIT_OUTPUT_TOOL } from "./safe-outputs.js";
36
36
  // Agent harness (the seam) and its adapters: Claude Code, Codex and pi
37
- export { createHarness, claudeCompat, asClaude, policyGate, ClaudeCodeHarness, CodexHarness, PiHarness, SUPPORTED_AGENTS, } from "./harness/index.js";
37
+ export { createHarness, claudeCompat, asClaude, policyGate, ClaudeCodeHarness, CodexHarness, PiHarness, SUPPORTED_AGENTS, reportCompletionUsage, } from "./harness/index.js";
38
38
  // Claude client (back-compat: ClaudeClient is ClaudeCodeHarness, @deprecated)
39
39
  export { ClaudeClient, resolveAuthEnv } from "./harness/claude-code.js";
40
40
  // Execution model resolution
package/dist/journal.d.ts CHANGED
@@ -53,6 +53,9 @@
53
53
  * route the edge taken after the visit (null = the run ended), and the rung that chose it
54
54
  * usage spend of one agent attempt (cumulative; one per model turn that raised it, written
55
55
  * synchronously, and the final figure when the attempt returns)
56
+ * meter spend of one of the executor's own completions (judge, route, reflect, screen),
57
+ * with a unique call id, written when it is counted. Judge, reflect and screen
58
+ * sit inside the visit; route sits after its node:end, before its route record
56
59
  * agent:unsandboxed the visit's agent ran without an enforced sandbox
57
60
  * run:end success | failed | crashed
58
61
  *
@@ -61,8 +64,9 @@
61
64
  * the node with the reason, so a resume never under-counts spend or repeats a
62
65
  * write the journal did not see.
63
66
  *
64
- * Spend: a resume seeds the run budget with every attempt's journaled usage,
65
- * from authenticated records only, so a crash never resets the whole-run ceiling.
67
+ * Spend: a resume seeds the run budget with every attempt's journaled usage
68
+ * and every metered completion, by kind, from authenticated records only, so
69
+ * a crash never resets the whole-run ceiling.
66
70
  *
67
71
  * Recovery: an unterminated tail that is not complete JSON (power loss
68
72
  * mid-append) is truncated. An unterminated tail that IS complete JSON is
@@ -99,9 +103,9 @@
99
103
  * `--allow-repeat-writes` does not override a refusal: its outcome is known,
100
104
  * not unconfirmed.
101
105
  */
102
- import type { Logger, NodeResult, NodeUsage, RouteRung, Skill, Workflow } from "./types.js";
106
+ import type { Logger, NodeResult, NodeUsage, RouteRung, Skill, ToolContext, Workflow } from "./types.js";
103
107
  import type { SafeOutputIntent, WriteStageState } from "./safe-outputs.js";
104
- import { type Spend } from "./budget.js";
108
+ import { type SeedSpend, type Spend, type SpendKind } from "./budget.js";
105
109
  import type { DeciderCounters } from "./decider.js";
106
110
  /**
107
111
  * v3: each record's HMAC chains the run id and the previous record's HMAC.
@@ -386,8 +390,14 @@ export interface ResumePlan {
386
390
  receipts: Map<string, OutputRecord>;
387
391
  /** Writes whose handler refused (`output:refused`, #544): replayed on resume, never taken for applied. */
388
392
  refusals: Map<string, OutputRecord>;
389
- /** Spend every earlier attempt journaled: the run budget starts here on resume. */
390
- priorSpend: Spend;
393
+ /** Spend every earlier attempt and metered completion journaled, by kind: the run budget starts here on resume. */
394
+ priorSpend: SeedSpend;
395
+ /**
396
+ * Spend journaled per visit (`node#iteration`): its agent attempts and its
397
+ * judge, reflect and screen calls, every segment. A checkpointed visit's
398
+ * node budget resumes from it.
399
+ */
400
+ visitSpend: Map<string, Spend>;
391
401
  /**
392
402
  * Nodes whose agent ran without an enforced sandbox in an earlier attempt.
393
403
  * Such an agent could read the run key, so the journal's spend and write
@@ -402,7 +412,10 @@ export interface ResumePlan {
402
412
  * first; nothing but run:resume after run:end; a visit starts only where the
403
413
  * last route pointed (or the entry), or re-starts an unfinished or failed
404
414
  * visit after a resume; checkpoints, writes, usage and node:end only inside
405
- * the open visit; one route per finished visit, from that visit's node.
415
+ * the open visit; one route per finished visit, from that visit's node. A
416
+ * judge, reflect or screen meter record sits inside the open visit; a route
417
+ * meter record after the visit's node:end and before its route record (the
418
+ * route call is made then). Every meter record has its own call id.
406
419
  * Throws {@link JournalIntegrityError}.
407
420
  */
408
421
  export declare function checkRecordSequence(records: JournalRecord[]): void;
@@ -463,6 +476,7 @@ export type JournalReplay = {
463
476
  rung?: RouteRung;
464
477
  } | ({
465
478
  kind: "checkpoint";
479
+ spent?: Spend;
466
480
  } & JournalCheckpoint);
467
481
  /**
468
482
  * The executor's view of a journal (`ExecuteOptions.journal`). A hook that
@@ -477,6 +491,15 @@ export interface ExecutionJournal {
477
491
  checkpoint(node: string, iteration: number, cp: JournalCheckpoint): void;
478
492
  /** The skill map the write stage applies through: write tools journaled and made idempotent. */
479
493
  wrapWrites(node: string, iteration: number, skills: Map<string, Skill>): Map<string, Skill>;
494
+ /** Spend an earlier segment journaled for this visit (resume): its node budget starts there. */
495
+ visitSpent?(node: string, iteration: number): Spend | undefined;
496
+ /**
497
+ * Resume: whether this write was applied before the crash, from its receipt
498
+ * or, for an outstanding intent, the provider's read-only probes (a found
499
+ * write is recorded as recovered). `unknown` when the probe could not tell
500
+ * and repeat writes are not allowed.
501
+ */
502
+ reconcile?(node: string, iteration: number, skill: Skill, tool: string, args: unknown, ctx: ToolContext): Promise<"applied" | "unknown" | "absent">;
480
503
  nodeEnd(node: string, iteration: number, result: NodeResult, writeState: WriteStageState): void;
481
504
  /** The edge taken after a visit, and for a decision the rung that chose it (#357), so resume replays it. */
482
505
  route(from: string, to: string | null, rung?: RouteRung, decider?: DeciderCounters): void;
@@ -485,10 +508,16 @@ export interface ExecutionJournal {
485
508
  * attempt returned, otherwise a live report (written at once when it raises the spend).
486
509
  */
487
510
  usage?(node: string, iteration: number, attempt: number, usage: NodeUsage, final: boolean): void;
511
+ /**
512
+ * Spend of one of the executor's own completions (judge, route, reflect,
513
+ * screen), written when it is counted. `node`/`iteration` name the visit it
514
+ * belongs to; for a route call, the visit being routed from.
515
+ */
516
+ meter?(kind: Exclude<SpendKind, "node">, node: string, iteration: number, usage: NodeUsage): void;
488
517
  /** The visit's agent ran without an enforced sandbox: recorded so a resume can say so. */
489
518
  uncontained?(node: string, iteration: number): void;
490
- /** Spend earlier attempts of this run already journaled (resume); the run budget starts from it. */
491
- priorSpend?(): Spend | undefined;
519
+ /** Spend earlier attempts of this run already journaled (resume), by kind when known; the run budget starts from it. */
520
+ priorSpend?(): SeedSpend | undefined;
492
521
  /** Receipt `degraded` entries this journal adds to the run (e.g. `journal_unsandboxed`). */
493
522
  degraded?(): string[];
494
523
  /** The decider state earlier attempts of this run journaled (resume); undefined otherwise. */
@@ -607,12 +636,21 @@ export declare class RunJournal implements ExecutionJournal {
607
636
  * at most the turn it was in.
608
637
  */
609
638
  usage(node: string, iteration: number, attempt: number, usage: NodeUsage, final: boolean): void;
639
+ /** Journal one metered completion, synchronously, before the executor acts on it. */
640
+ meter(kind: Exclude<SpendKind, "node">, node: string, iteration: number, usage: NodeUsage): void;
610
641
  uncontained(node: string, iteration: number): void;
611
642
  degraded(): string[];
612
- priorSpend(): Spend | undefined;
643
+ priorSpend(): SeedSpend | undefined;
613
644
  /** Close the run: `run:end` (when the journal still works), then release the lock. Never throws. */
614
645
  end(status: "success" | "failed" | "crashed"): void;
615
646
  wrapWrites(node: string, iteration: number, skills: Map<string, Skill>): Map<string, Skill>;
647
+ visitSpent(node: string, iteration: number): Spend | undefined;
648
+ /** Outputs of writes {@link reconcile} found on the provider, replayed by the write handler. */
649
+ private recoveredOutputs;
650
+ /** Provider probes already made for a write key: the handler reuses reconcile's, never probes twice. */
651
+ private probes;
652
+ reconcile(node: string, iteration: number, skill: Skill, tool: string, args: unknown, ctx: ToolContext): Promise<"applied" | "unknown" | "absent">;
653
+ private probe;
616
654
  private wrapTool;
617
655
  }
618
656
  export {};
package/dist/journal.js CHANGED
@@ -53,6 +53,9 @@
53
53
  * route the edge taken after the visit (null = the run ended), and the rung that chose it
54
54
  * usage spend of one agent attempt (cumulative; one per model turn that raised it, written
55
55
  * synchronously, and the final figure when the attempt returns)
56
+ * meter spend of one of the executor's own completions (judge, route, reflect, screen),
57
+ * with a unique call id, written when it is counted. Judge, reflect and screen
58
+ * sit inside the visit; route sits after its node:end, before its route record
56
59
  * agent:unsandboxed the visit's agent ran without an enforced sandbox
57
60
  * run:end success | failed | crashed
58
61
  *
@@ -61,8 +64,9 @@
61
64
  * the node with the reason, so a resume never under-counts spend or repeats a
62
65
  * write the journal did not see.
63
66
  *
64
- * Spend: a resume seeds the run budget with every attempt's journaled usage,
65
- * from authenticated records only, so a crash never resets the whole-run ceiling.
67
+ * Spend: a resume seeds the run budget with every attempt's journaled usage
68
+ * and every metered completion, by kind, from authenticated records only, so
69
+ * a crash never resets the whole-run ceiling.
66
70
  *
67
71
  * Recovery: an unterminated tail that is not complete JSON (power loss
68
72
  * mid-append) is truncated. An unterminated tail that IS complete JSON is
@@ -917,9 +921,12 @@ const RECORD_TYPES = new Set([
917
921
  "node:end",
918
922
  "route",
919
923
  "usage",
924
+ "meter",
920
925
  "agent:unsandboxed",
921
926
  "run:end",
922
927
  ]);
928
+ /** The executor's own completions a `meter` record can name. */
929
+ const METER_KINDS = new Set(["judge", "route", "reflect", "screen"]);
923
930
  const isPosInt = (v) => typeof v === "number" && Number.isInteger(v) && v > 0;
924
931
  const isName = (v) => typeof v === "string" && v.length > 0;
925
932
  const isAmount = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
@@ -935,7 +942,10 @@ const isCounters = (v) => typeof v === "object" &&
935
942
  * first; nothing but run:resume after run:end; a visit starts only where the
936
943
  * last route pointed (or the entry), or re-starts an unfinished or failed
937
944
  * visit after a resume; checkpoints, writes, usage and node:end only inside
938
- * the open visit; one route per finished visit, from that visit's node.
945
+ * the open visit; one route per finished visit, from that visit's node. A
946
+ * judge, reflect or screen meter record sits inside the open visit; a route
947
+ * meter record after the visit's node:end and before its route record (the
948
+ * route call is made then). Every meter record has its own call id.
939
949
  * Throws {@link JournalIntegrityError}.
940
950
  */
941
951
  export function checkRecordSequence(records) {
@@ -949,6 +959,7 @@ export function checkRecordSequence(records) {
949
959
  const visits = new Map();
950
960
  const checkpointed = new Set();
951
961
  const intentKeys = new Set();
962
+ const meterCalls = new Set();
952
963
  const inVisit = (r) => {
953
964
  if (!cur || cur.ended)
954
965
  bad(r, "is outside any open node visit");
@@ -1029,6 +1040,25 @@ export function checkRecordSequence(records) {
1029
1040
  bad(r, "has invalid amounts");
1030
1041
  }
1031
1042
  break;
1043
+ case "meter": {
1044
+ if (!METER_KINDS.has(r.kind))
1045
+ bad(r, "has an unknown kind");
1046
+ if (!isAmount(r.tokens) || !isAmount(r.cost_usd))
1047
+ bad(r, "has invalid amounts");
1048
+ if (!isName(r.call) || meterCalls.has(r.call))
1049
+ bad(r, "has no call id, or one already used");
1050
+ meterCalls.add(r.call);
1051
+ if (r.kind !== "route") {
1052
+ inVisit(r);
1053
+ break;
1054
+ }
1055
+ if (!cur || !cur.ended || cur.routed)
1056
+ bad(r, "meters a route call outside a finished, unrouted visit");
1057
+ if (r.node !== cur.node || r.iteration !== cur.iteration) {
1058
+ bad(r, `names ${String(r.node)}#${String(r.iteration)}, but the visit being routed is ${cur.node}#${cur.iteration}`);
1059
+ }
1060
+ break;
1061
+ }
1032
1062
  case "agent:unsandboxed":
1033
1063
  inVisit(r);
1034
1064
  break;
@@ -1102,10 +1132,18 @@ export function checkJournalAgainstWorkflow(records, workflow) {
1102
1132
  */
1103
1133
  function journaledSpend(records) {
1104
1134
  const perAttempt = new Map();
1135
+ const metered = new Map();
1105
1136
  let segment = 1;
1106
1137
  for (const r of records) {
1107
1138
  if (r.type === "run:resume")
1108
1139
  segment++;
1140
+ if (r.type === "meter") {
1141
+ // One record per completion, each counted once (call ids are unique).
1142
+ const kind = r.kind;
1143
+ const prev = metered.get(kind) ?? { tokens: 0, costUsd: 0 };
1144
+ metered.set(kind, { tokens: prev.tokens + r.tokens, costUsd: prev.costUsd + r.cost_usd });
1145
+ continue;
1146
+ }
1109
1147
  if (r.type !== "usage")
1110
1148
  continue;
1111
1149
  const k = `${segment}|${visitKey(r.node, r.iteration)}|${String(r.attempt)}`;
@@ -1115,12 +1153,50 @@ function journaledSpend(records) {
1115
1153
  costUsd: Math.max(prev.costUsd, r.cost_usd),
1116
1154
  });
1117
1155
  }
1118
- const total = { tokens: 0, costUsd: 0 };
1156
+ const node = { tokens: 0, costUsd: 0 };
1119
1157
  for (const s of perAttempt.values()) {
1120
- total.tokens += s.tokens;
1121
- total.costUsd += s.costUsd;
1158
+ node.tokens += s.tokens;
1159
+ node.costUsd += s.costUsd;
1160
+ }
1161
+ if (metered.size === 0)
1162
+ return node;
1163
+ const byKind = { node: { ...node } };
1164
+ const total = { ...node };
1165
+ for (const [k, v] of metered) {
1166
+ byKind[k] = v;
1167
+ total.tokens += v.tokens;
1168
+ total.costUsd += v.costUsd;
1169
+ }
1170
+ return { ...total, byKind };
1171
+ }
1172
+ /** Per visit: the largest usage of each agent attempt (per segment) plus its own judge, reflect and screen meters. */
1173
+ function visitSpendOf(records) {
1174
+ const attempts = new Map();
1175
+ const out = new Map();
1176
+ const add = (visit, s) => {
1177
+ const prev = out.get(visit) ?? { tokens: 0, costUsd: 0 };
1178
+ out.set(visit, { tokens: prev.tokens + s.tokens, costUsd: prev.costUsd + s.costUsd });
1179
+ };
1180
+ let segment = 1;
1181
+ for (const r of records) {
1182
+ if (r.type === "run:resume")
1183
+ segment++;
1184
+ const visit = visitKey(r.node, r.iteration);
1185
+ if (r.type === "meter" && r.kind !== "route") {
1186
+ add(visit, { tokens: r.tokens, costUsd: r.cost_usd });
1187
+ }
1188
+ else if (r.type === "usage") {
1189
+ const k = `${segment}|${visit}|${String(r.attempt)}`;
1190
+ const prev = attempts.get(k)?.s ?? { tokens: 0, costUsd: 0 };
1191
+ attempts.set(k, {
1192
+ visit,
1193
+ s: { tokens: Math.max(prev.tokens, r.tokens), costUsd: Math.max(prev.costUsd, r.cost_usd) },
1194
+ });
1195
+ }
1122
1196
  }
1123
- return total;
1197
+ for (const { visit, s } of attempts.values())
1198
+ add(visit, s);
1199
+ return out;
1124
1200
  }
1125
1201
  /** Work out what a resume replays and where it picks up. Throws {@link JournalIntegrityError} for an impossible sequence. */
1126
1202
  export function buildResumePlan(records) {
@@ -1268,6 +1344,7 @@ export function buildResumePlan(records) {
1268
1344
  receipts,
1269
1345
  refusals,
1270
1346
  priorSpend: journaledSpend(records),
1347
+ visitSpend: visitSpendOf(records),
1271
1348
  unsandboxed: [...new Set(records.filter((r) => r.type === "agent:unsandboxed").map((r) => String(r.node)))],
1272
1349
  ...(start.decider
1273
1350
  ? { decider: { ...start.decider, ...(deciderCounters ? { counters: { ...deciderCounters } } : {}) } }
@@ -1394,13 +1471,19 @@ async function probeProvider(toolName, args, key, skill, ctx, since) {
1394
1471
  return { state: "applied", output: toolName === "github_create_issue" ? hit : {} };
1395
1472
  }
1396
1473
  case "linear_create_issue": {
1397
- const out = (await callRead(skill, "linear_search_issues", { query: token, limit: 5 }, ctx));
1474
+ const out = (await callRead(skill, "linear_search_issues", { query: token, limit: 5, withDescription: true }, ctx));
1398
1475
  const nodes = out?.searchIssues?.nodes;
1399
1476
  if (!Array.isArray(nodes))
1400
1477
  return { state: "unknown", reason: "search returned no result list" };
1401
- return nodes.length > 0
1402
- ? { state: "applied", output: { issueCreate: { issue: nodes[0] } } }
1403
- : { state: "absent" };
1478
+ // Search is fuzzy: only an issue whose description carries this write's marker is ours.
1479
+ const hit = nodes.find((n) => typeof n?.description === "string" && n.description.includes(token));
1480
+ if (hit)
1481
+ return { state: "applied", output: { issueCreate: { issue: hit } } };
1482
+ // A hit without a description cannot be checked for the marker: unknown, never assumed either way.
1483
+ if (nodes.some((n) => typeof n?.description !== "string")) {
1484
+ return { state: "unknown", reason: "search hits carry no description to check for the marker" };
1485
+ }
1486
+ return { state: "absent" };
1404
1487
  }
1405
1488
  case "linear_add_comment": {
1406
1489
  const out = (await callRead(skill, "linear_list_comments", { issueId: args.issueId }, ctx));
@@ -1791,6 +1874,7 @@ export class RunJournal {
1791
1874
  intents: structuredClone(cp.intents),
1792
1875
  agentRunFailed: cp.agent_failed,
1793
1876
  attempt: cp.attempt,
1877
+ ...(plan.visitSpend.has(k) ? { spent: { ...plan.visitSpend.get(k) } } : {}),
1794
1878
  };
1795
1879
  }
1796
1880
  return undefined;
@@ -1845,6 +1929,20 @@ export class RunJournal {
1845
1929
  });
1846
1930
  this.append("usage", { node, iteration, attempt, tokens, cost_usd: costUsd, final });
1847
1931
  }
1932
+ /** Journal one metered completion, synchronously, before the executor acts on it. */
1933
+ meter(kind, node, iteration, usage) {
1934
+ const s = spendOf(usage);
1935
+ if (s.tokens === undefined && s.costUsd === undefined)
1936
+ return;
1937
+ this.append("meter", {
1938
+ kind,
1939
+ call: crypto.randomUUID(),
1940
+ node,
1941
+ iteration,
1942
+ tokens: s.tokens ?? 0,
1943
+ cost_usd: s.costUsd ?? 0,
1944
+ });
1945
+ }
1848
1946
  uncontained(node, iteration) {
1849
1947
  const k = visitKey(node, iteration);
1850
1948
  if (this.uncontainedVisits.has(k))
@@ -1862,7 +1960,16 @@ export class RunJournal {
1862
1960
  ];
1863
1961
  }
1864
1962
  priorSpend() {
1865
- return this.resume ? { ...this.resume.plan.priorSpend } : undefined;
1963
+ if (!this.resume)
1964
+ return undefined;
1965
+ const p = this.resume.plan.priorSpend;
1966
+ return {
1967
+ tokens: p.tokens,
1968
+ costUsd: p.costUsd,
1969
+ ...(p.byKind
1970
+ ? { byKind: Object.fromEntries(Object.entries(p.byKind).map(([k, v]) => [k, { ...v }])) }
1971
+ : {}),
1972
+ };
1866
1973
  }
1867
1974
  /** Close the run: `run:end` (when the journal still works), then release the lock. Never throws. */
1868
1975
  end(status) {
@@ -1888,12 +1995,56 @@ export class RunJournal {
1888
1995
  }
1889
1996
  return out;
1890
1997
  }
1998
+ visitSpent(node, iteration) {
1999
+ const s = this.resume?.plan.visitSpend.get(visitKey(node, iteration));
2000
+ return s ? { ...s } : undefined;
2001
+ }
2002
+ /** Outputs of writes {@link reconcile} found on the provider, replayed by the write handler. */
2003
+ recoveredOutputs = new Map();
2004
+ /** Provider probes already made for a write key: the handler reuses reconcile's, never probes twice. */
2005
+ probes = new Map();
2006
+ async reconcile(node, iteration, skill, tool, args, ctx) {
2007
+ const plan = this.resume?.plan;
2008
+ if (!plan)
2009
+ return "absent";
2010
+ const key = canonicalHash({ node, iteration, tool, args });
2011
+ if (plan.receipts.has(key) || this.recoveredOutputs.has(key))
2012
+ return "applied";
2013
+ const refusal = plan.refusals.get(key);
2014
+ if (refusal && refusal.retry !== true)
2015
+ return "absent";
2016
+ const pending = plan.intents.has(key);
2017
+ if (!pending && !plan.checkpoints.has(visitKey(node, iteration)))
2018
+ return "absent";
2019
+ const probe = await this.probe(key, tool, args, skill, ctx);
2020
+ if (probe.state === "applied") {
2021
+ const output = slimOutput(probe.output);
2022
+ this.append("output:applied", { node, iteration, key, tool, output, recovered: true });
2023
+ this.recoveredOutputs.set(key, output);
2024
+ this.logger.info(` run journal: ${tool} found on the provider; not re-sent`, { node });
2025
+ return "applied";
2026
+ }
2027
+ return probe.state === "unknown" && pending && !this.resume?.allowRepeatWrites ? "unknown" : "absent";
2028
+ }
2029
+ probe(key, tool, args, skill, ctx) {
2030
+ const cached = this.probes.get(key);
2031
+ if (cached)
2032
+ return cached;
2033
+ // Look back from a little before the intent (clock skew between this host and the provider).
2034
+ const intentAt = Date.parse(String(this.resume?.plan.intents.get(key)?.at ?? ""));
2035
+ const since = Number.isFinite(intentAt) ? new Date(intentAt - 5 * 60_000).toISOString() : undefined;
2036
+ const p = probeProvider(tool, (args ?? {}), key, skill, ctx, since);
2037
+ this.probes.set(key, p);
2038
+ return p;
2039
+ }
1891
2040
  wrapTool(node, iteration, skill, tool) {
1892
2041
  return {
1893
2042
  ...tool,
1894
2043
  handler: async (args, ctx) => {
1895
2044
  const key = canonicalHash({ node, iteration, tool: tool.name, args });
1896
2045
  const plan = this.resume?.plan;
2046
+ if (this.recoveredOutputs.has(key))
2047
+ return this.recoveredOutputs.get(key) ?? null;
1897
2048
  const receipt = plan?.receipts.get(key);
1898
2049
  if (receipt) {
1899
2050
  this.logger.info(` run journal: ${tool.name} already applied before the crash; not re-sent`, { node });
@@ -1908,10 +2059,7 @@ export class RunJournal {
1908
2059
  const replayingStage = !!plan?.checkpoints.has(visitKey(node, iteration));
1909
2060
  const pending = plan?.intents.has(key) === true;
1910
2061
  if (pending || replayingStage) {
1911
- // Look back from a little before the intent (clock skew between this host and the provider).
1912
- const intentAt = Date.parse(String(plan?.intents.get(key)?.at ?? ""));
1913
- const since = Number.isFinite(intentAt) ? new Date(intentAt - 5 * 60_000).toISOString() : undefined;
1914
- const probe = await probeProvider(tool.name, (args ?? {}), key, skill, ctx, since);
2062
+ const probe = await this.probe(key, tool.name, args, skill, ctx);
1915
2063
  if (probe.state === "applied") {
1916
2064
  const output = slimOutput(probe.output);
1917
2065
  this.append("output:applied", { node, iteration, key, tool: tool.name, output, recovered: true });