thunderjar 0.1.3-preview-eee7160 → 0.1.3-preview-e09ffa1

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 (81) hide show
  1. package/dist/cli.js +1934 -1721
  2. package/package.json +14 -8
  3. package/src/api/checkoutCommit.ts +35 -0
  4. package/src/api/claudeCode.ts +74 -0
  5. package/src/api/declarations.ts +176 -133
  6. package/src/api/index.ts +44 -0
  7. package/src/api/parameterNames.ts +19 -0
  8. package/src/AGENTS.md +0 -156
  9. package/src/adapters/docker/docker.ts +0 -117
  10. package/src/api/fragments.ts +0 -30
  11. package/src/api/parameter-names.d.ts +0 -16
  12. package/src/cli/commands/build/build.docker.test.ts +0 -173
  13. package/src/cli/commands/build/build.test.ts +0 -234
  14. package/src/cli/commands/build/build.ts +0 -115
  15. package/src/cli/commands/declarations.typecheck.test.ts +0 -173
  16. package/src/cli/commands/executions/executions.test.ts +0 -84
  17. package/src/cli/commands/executions/executions.ts +0 -31
  18. package/src/cli/commands/format.ts +0 -33
  19. package/src/cli/commands/generate/generate.test.ts +0 -101
  20. package/src/cli/commands/generate/generate.ts +0 -18
  21. package/src/cli/commands/generate/parameter-names.ts +0 -131
  22. package/src/cli/commands/init/init.test.ts +0 -123
  23. package/src/cli/commands/init/init.ts +0 -189
  24. package/src/cli/commands/options.ts +0 -17
  25. package/src/cli/commands/plan/plan.test.ts +0 -168
  26. package/src/cli/commands/plan/plan.ts +0 -72
  27. package/src/cli/commands/remeasure/remeasure.test.ts +0 -536
  28. package/src/cli/commands/remeasure/remeasure.ts +0 -194
  29. package/src/cli/commands/run/run.test.ts +0 -622
  30. package/src/cli/commands/run/run.ts +0 -203
  31. package/src/cli/commands/show/show.test.ts +0 -288
  32. package/src/cli/commands/show/show.ts +0 -252
  33. package/src/cli/commands/validation.test.ts +0 -200
  34. package/src/cli/index.test.ts +0 -47
  35. package/src/cli/index.ts +0 -9
  36. package/src/cli/program.ts +0 -29
  37. package/src/images/prerun-builds.ts +0 -108
  38. package/src/images/prerun.test.ts +0 -243
  39. package/src/images/prerun.ts +0 -272
  40. package/src/images/repository.ts +0 -72
  41. package/src/index.ts +0 -24
  42. package/src/instruments/instruments.test.ts +0 -240
  43. package/src/instruments/instruments.ts +0 -205
  44. package/src/matrix/execution.ts +0 -122
  45. package/src/matrix/hashing.test.ts +0 -298
  46. package/src/matrix/hashing.ts +0 -131
  47. package/src/matrix/permutations.test.ts +0 -58
  48. package/src/matrix/permutations.ts +0 -50
  49. package/src/matrix/resolve.ts +0 -63
  50. package/src/matrix/tags.test.ts +0 -15
  51. package/src/matrix/tags.ts +0 -22
  52. package/src/matrix/ulid.test.ts +0 -28
  53. package/src/matrix/ulid.ts +0 -22
  54. package/src/measuring/aggregate.test.ts +0 -188
  55. package/src/measuring/aggregate.ts +0 -154
  56. package/src/measuring/measure.test.ts +0 -105
  57. package/src/measuring/measure.ts +0 -62
  58. package/src/measuring/remeasure-templates/v1/isPrime.test.template.txt +0 -4
  59. package/src/measuring/remeasure-templates/v2/isPrime.test.template.txt +0 -5
  60. package/src/measuring/remeasure.test.ts +0 -352
  61. package/src/measuring/remeasure.ts +0 -113
  62. package/src/platform/fs.test.ts +0 -89
  63. package/src/platform/fs.ts +0 -123
  64. package/src/platform/path.test.ts +0 -38
  65. package/src/platform/path.ts +0 -72
  66. package/src/platform/signals.test.ts +0 -25
  67. package/src/platform/signals.ts +0 -68
  68. package/src/project/config.test.ts +0 -68
  69. package/src/project/config.ts +0 -297
  70. package/src/running/runner.test.ts +0 -73
  71. package/src/running/runner.ts +0 -252
  72. package/src/store/run-data-store.test.ts +0 -232
  73. package/src/store/run-data-store.ts +0 -254
  74. package/src/testing/cli.ts +0 -65
  75. package/src/testing/fake-docker.test.ts +0 -197
  76. package/src/testing/fake-docker.ts +0 -104
  77. package/src/testing/fixture-project.ts +0 -177
  78. package/src/testing/measurement-context.ts +0 -5
  79. package/src/testing/seed-repo.test.ts +0 -10
  80. package/src/testing/seed-repo.ts +0 -83
  81. package/src/testing/test-setup.ts +0 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thunderjar",
3
- "version": "0.1.3-preview-eee7160",
3
+ "version": "0.1.3-preview-e09ffa1",
4
4
  "type": "module",
5
5
  "description": "Thunderjar",
6
6
  "license": "MIT",
@@ -11,24 +11,30 @@
11
11
  "bin": {
12
12
  "thunderjar": "./dist/cli.js"
13
13
  },
14
- "main": "./src/index.ts",
15
- "types": "./src/index.ts",
14
+ "main": "./src/api/index.ts",
15
+ "types": "./src/api/index.ts",
16
+ "exports": {
17
+ ".": "./src/api/index.ts"
18
+ },
16
19
  "files": [
17
20
  "dist",
18
- "src"
21
+ "src/api",
22
+ "!src/**/*.test.ts",
23
+ "!src/**/*.result.json"
19
24
  ],
20
25
  "publishConfig": {
21
26
  "access": "public"
22
27
  },
23
28
  "scripts": {
24
29
  "build": "bun build src/cli/index.ts --target bun --outfile dist/cli.js",
25
- "lint": "bun scripts/sync-oxlint-layout.ts && bun scripts/sync-oxlint-layout.ts --check && oxlint",
30
+ "lint": "bun scripts/lint-layers.ts --check && oxlint",
31
+ "lint:generate": "bun scripts/lint-layers.ts",
26
32
  "typecheck": "tsc --noEmit",
27
33
  "test": "bun test",
28
- "test:prepare": "bun scripts/prepare-tests.ts",
29
- "test:docker": "THUNDERJAR_TEST_DOCKER=1 bun test docker",
34
+ "test:docker": "THUNDERJAR_DOCKER_TESTS=1 bun test --timeout 600000 .docker.test",
35
+ "test:agent": "THUNDERJAR_AGENT_TESTS=1 bun test --timeout 900000 .agent.test",
36
+ "test:package": "bun scripts/package-smoke-test.ts",
30
37
  "fixture": "bun scripts/fixture.ts",
31
- "dev:link": "bun scripts/dev-link.ts",
32
38
  "docs:dev": "vitepress dev docs",
33
39
  "docs:build": "vitepress build docs",
34
40
  "docs:build:versions": "bun scripts/build-versioned-docs.ts",
@@ -0,0 +1,35 @@
1
+ import type { ApplyParameterCtx } from "./declarations.ts";
2
+
3
+ // How checkoutCommit tells Thunderjar which commit to check out: a code state's ctx carries a
4
+ // function under this key. Symbol.for, so it is found even when the user's project resolves a
5
+ // different copy of this module.
6
+ export const CHECKOUT_COMMIT = Symbol.for("thunderjar.checkoutCommit");
7
+
8
+ /** A code state's ctx, as Thunderjar makes it. */
9
+ export type CheckoutCtx = ApplyParameterCtx & { [CHECKOUT_COMMIT]?: (commit: string) => void };
10
+
11
+ /**
12
+ * Checks `commit`, a hash, out into `/workspace` as a real repository. For a code state's
13
+ * `applyParameter` only.
14
+ *
15
+ * Thunderjar bundles the commit and its ancestors, and nothing after it, into the build context.
16
+ * Straight after the base image's fragment, the prerun build clones it to `/workspace`, checks
17
+ * the commit out detached, removes the remote, and sets a git identity so the agent can commit.
18
+ * Those lines are Thunderjar's, so this returns an empty fragment, for the code state's own
19
+ * lines to follow:
20
+ *
21
+ * ```ts
22
+ * export default declareCodeState({
23
+ * config: { commit: "a1b2c3" },
24
+ * applyParameter: (config, ctx) => `${checkoutCommit(ctx, config.commit)}\nRUN npm ci`,
25
+ * });
26
+ * ```
27
+ *
28
+ * The commit must already be in the repository the project is in, or nothing is built.
29
+ */
30
+ export function checkoutCommit(ctx: ApplyParameterCtx, commit: string): string {
31
+ const checkout = (ctx as CheckoutCtx)[CHECKOUT_COMMIT];
32
+ if (!checkout) throw new Error("checkoutCommit is only for a code state's applyParameter.");
33
+ checkout(commit);
34
+ return "";
35
+ }
@@ -0,0 +1,74 @@
1
+ // Reading what `claude -p --output-format json` writes: one JSON object, with the reply as
2
+ // `result`, and the tokens and cost of each model it used as `modelUsage`. For a harness's
3
+ // `collectReply` and `collectTokenCosts`, so a Claude Code harness needn't parse it itself.
4
+ //
5
+ // `modelUsage` is read, not the flat `usage`: `usage` counts only the top-level agent loop, so it
6
+ // leaves out what sub-agents spent. Its keys are camelCase, where `usage`'s are snake_case.
7
+ import type { ModelTokenCosts, TokenCosts } from "./declarations.ts";
8
+
9
+ type ModelUsage = {
10
+ inputTokens?: unknown;
11
+ outputTokens?: unknown;
12
+ cacheReadInputTokens?: unknown;
13
+ cacheCreationInputTokens?: unknown;
14
+ costUSD?: unknown;
15
+ costBasis?: unknown;
16
+ };
17
+
18
+ type ClaudeCodeResult = { type?: unknown; result?: unknown; modelUsage?: unknown; duration_api_ms?: unknown };
19
+
20
+ const COST_BASES = ["list", "managed", "unknown"] as const;
21
+
22
+ const count = (value: unknown) => (typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined);
23
+
24
+ /** `raw` parsed as Claude Code's result, or undefined if it isn't one. */
25
+ function parse(raw: string): ClaudeCodeResult | undefined {
26
+ let parsed: unknown;
27
+ try {
28
+ parsed = JSON.parse(raw);
29
+ } catch {
30
+ return undefined;
31
+ }
32
+ return typeof parsed === "object" && parsed !== null && (parsed as ClaudeCodeResult).type === "result"
33
+ ? (parsed as ClaudeCodeResult)
34
+ : undefined;
35
+ }
36
+
37
+ /** The agent's final reply, from the result file Claude Code wrote. Throws if it isn't one. */
38
+ export function claudeCodeReply(raw: string): string {
39
+ const result = parse(raw)?.result;
40
+ if (typeof result !== "string") throw new Error("not a result of `claude -p --output-format json`");
41
+ return result;
42
+ }
43
+
44
+ /**
45
+ * The tokens and cost of each model, from the result file Claude Code wrote. `unparseable` if it
46
+ * isn't one, and `notReported` if it counts no tokens at all, as a crashed session's does. Its
47
+ * cost is its own estimate, from a price table bundled with it: `costBasis` says whether that
48
+ * table knew the model.
49
+ */
50
+ export function claudeCodeTokenCosts(raw: string): TokenCosts {
51
+ const result = parse(raw);
52
+ if (!result || typeof result.modelUsage !== "object" || result.modelUsage === null) return { outcome: "unavailable", reason: "unparseable" };
53
+ const models: ModelTokenCosts[] = [];
54
+ for (const [model, usage] of Object.entries(result.modelUsage as Record<string, ModelUsage>)) {
55
+ const tokens = [usage.inputTokens, usage.outputTokens, usage.cacheCreationInputTokens, usage.cacheReadInputTokens].map(count);
56
+ if (tokens.some((n) => n === undefined)) return { outcome: "unavailable", reason: "unparseable" };
57
+ const [inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens] = tokens as number[];
58
+ const costUsd = count(usage.costUSD);
59
+ const costBasis = COST_BASES.find((basis) => basis === usage.costBasis);
60
+ models.push({
61
+ model,
62
+ inputTokens: inputTokens as number,
63
+ outputTokens: outputTokens as number,
64
+ cacheCreationTokens: cacheCreationTokens as number,
65
+ cacheReadTokens: cacheReadTokens as number,
66
+ ...(costUsd !== undefined && { costUsd }),
67
+ ...(costBasis !== undefined && { costBasis }),
68
+ });
69
+ }
70
+ const total = models.reduce((sum, m) => sum + m.inputTokens + m.outputTokens + m.cacheCreationTokens + m.cacheReadTokens, 0);
71
+ if (total === 0) return { outcome: "unavailable", reason: "notReported" };
72
+ const apiDurationMs = count(result.duration_api_ms);
73
+ return { outcome: "collected", models, ...(apiDurationMs !== undefined && { apiDurationMs }) };
74
+ }
@@ -1,14 +1,17 @@
1
- // The `declareX()` functions a Thunderjar configuration file's default export is
2
- // wrapped in — see spec/022-coding-conventions.md#declarex-functions-not-bare-exports.
3
- // Each is the identity function on its argument; the point is the parameter type, which
4
- // gives every `ctx` its shape with nothing written at the call site.
5
- //
6
- // `BaseImageName`, `CodeStateName`, `PromptSetName`, `TaskName`, `InitialPromptNames` and
7
- // `HarnessModelName` are ambient globals with no import — see src/api/parameter-names.d.ts.
8
-
9
- // Each declareX() marks what it returns with its own name, so the config loader can tell
10
- // a wrapped default export from a bare one. Symbol.for, so the mark is recognised even
11
- // when the user's project resolves a different copy of this module.
1
+ // The `declareX()` functions that a `.thunderjar/` file's default export is wrapped in. Each
2
+ // returns what it is given: the point is the type of its argument.
3
+ import type {
4
+ BaseImageNames,
5
+ CodeStateNames,
6
+ HarnessModelNames,
7
+ InitialPromptNamesByTask,
8
+ PromptSetNames,
9
+ TaskNames,
10
+ } from "./parameterNames.ts";
11
+
12
+ // Each declareX() marks what it returns with its own name, so the config loader can tell a wrapped
13
+ // default export from a bare one. Symbol.for, so the mark is recognised even when the user's
14
+ // project resolves a different copy of this module.
12
15
  const DECLARED_BY = Symbol.for("thunderjar.declaredBy");
13
16
 
14
17
  function declared<T extends object>(declareFn: string, value: T): T {
@@ -25,202 +28,244 @@ export function declaredBy(value: unknown): string | undefined {
25
28
  return typeof mark === "string" ? mark : undefined;
26
29
  }
27
30
 
28
- // --- applyParameter: spec/060-test-case-parameters.md#declarations-and-applyparameter ---
31
+ // --- applyParameter ---
29
32
  //
30
- // Each parameter's `applyParameter(config, ctx)` returns the Dockerfile fragment that applies
31
- // it. `config` is the declaration's own `config`, as written (undefined if it has none); the
32
- // declaration file is hashed, so it is part of the parameter hash.
33
+ // Each parameter's `applyParameter(config, ctx)` returns the Dockerfile fragment that applies it
34
+ // to the prerun image; see src/execution/prerunDockerfile.ts for the order they are built in.
35
+ // `config` is the declaration's own `config`, as written (undefined if it has none).
33
36
 
34
37
  /**
35
- * Puts things from the repository Thunderjar is run in into the build context, and returns
36
- * the path to COPY them from. A commit named here must be in that repository: it is checked
37
- * before anything is built.
38
+ * Reads the repository the project is in, without fetching. Each commit is a hash, full or
39
+ * abbreviated, and must already be in the repository, or nothing is built.
38
40
  */
39
- export interface GitBuildContext {
40
- /** A git bundle holding `commit` and its ancestors, and nothing else, with one branch, `code-state`. */
41
- bundle(commit: string): string;
42
- /** The file at `path` in `commit`. */
41
+ export type GitCtx = {
42
+ /**
43
+ * Puts `path`'s content at `commit` into the build context, and returns where it is there, to
44
+ * COPY: `COPY ${ctx.git.show("e4f5a6", "prompts/snerk.md")} /workspace/CLAUDE.md`. `path` is
45
+ * relative to the repository's root.
46
+ */
43
47
  show(commit: string, path: string): string;
44
- }
48
+ };
45
49
 
46
50
  /** What every fragment gets. */
47
- interface FragmentCtx {
48
- resolvedHash?: string;
49
- git: GitBuildContext;
50
- }
51
+ export type FragmentCtx = {
52
+ git: GitCtx;
53
+ };
51
54
 
52
55
  /**
53
- * What a fragment gets when its parameter is a folder: `dir` is that folder's path in the
54
- * build context, so files bundled beside the declaration can be copied in —
55
- * `COPY ${ctx.dir}/files/ /workspace/`. It names the parameter's role, not its folder, so
56
- * renaming the folder changes nothing.
56
+ * What a fragment gets when its parameter is a folder. `dir` is that folder's path in the build
57
+ * context, so files kept beside the declaration can be copied in: `COPY ${ctx.dir}/files/ /workspace/`.
58
+ * It names the parameter's role, not its folder, so renaming the folder changes nothing.
57
59
  */
58
- export interface ApplyParameterCtx extends FragmentCtx {
60
+ export type ApplyParameterCtx = FragmentCtx & {
59
61
  dir: string;
60
- }
61
-
62
- // spec/060-test-case-parameters.md#controlling-the-parameter-hash
63
- interface HashOverrides {
64
- additionalHash?: () => string;
65
- determineHash?: () => string;
66
- }
62
+ };
67
63
 
68
- /** A parameter declaration's `config`, and the `applyParameter` that receives it. */
69
- interface Applies<C, Ctx> {
64
+ /**
65
+ * A parameter declaration's `config`, and the `applyParameter` that receives it. A method, so a
66
+ * declaration with any `config` is still a `XDeclaration<unknown>` once loaded.
67
+ */
68
+ type Applies<C, Ctx> = {
70
69
  config?: C;
71
- applyParameter: (config: C, ctx: Ctx) => string;
72
- }
70
+ applyParameter?(config: C, ctx: Ctx): string;
71
+ };
73
72
 
74
- // --- Base image: spec/061-base-image.md ---
75
-
76
- interface BaseImageDeclaration<C> extends HashOverrides, Applies<C, ApplyParameterCtx> {}
73
+ /** A base image's fragment is required: it carries the prerun image's `FROM`. */
74
+ export type BaseImageDeclaration<C = unknown> = Applies<C, ApplyParameterCtx> & {
75
+ applyParameter(config: C, ctx: ApplyParameterCtx): string;
76
+ /**
77
+ * More `docker run` options for the agent's containers, for agents that need more of the
78
+ * kernel than Docker allows by default, e.g. Claude Code's bubblewrap sandbox, which needs user
79
+ * namespaces: `["--security-opt", "seccomp=unconfined", "--security-opt", "apparmor=unconfined"]`.
80
+ * Each option and value is its own string. Ask for the least you need: `--privileged` works, but
81
+ * gives the agent the whole machine.
82
+ */
83
+ runOptions?: string[];
84
+ };
77
85
 
78
86
  export function declareBaseImage<C = undefined>(declaration: BaseImageDeclaration<C>): BaseImageDeclaration<C> {
79
87
  return declared("declareBaseImage", declaration);
80
88
  }
81
89
 
82
- // --- Code state: spec/062-code-state.md ---
83
-
84
- interface CodeStateDeclaration<C> extends HashOverrides, Applies<C, ApplyParameterCtx> {}
90
+ export type CodeStateDeclaration<C = unknown> = Applies<C, ApplyParameterCtx>;
85
91
 
86
92
  export function declareCodeState<C = undefined>(declaration: CodeStateDeclaration<C>): CodeStateDeclaration<C> {
87
93
  return declared("declareCodeState", declaration);
88
94
  }
89
95
 
90
- // --- Prompt set: spec/063-prompt-set.md ---
91
-
92
- interface PromptSetDeclaration<C> extends HashOverrides, Applies<C, ApplyParameterCtx> {}
96
+ export type PromptSetDeclaration<C = unknown> = Applies<C, ApplyParameterCtx>;
93
97
 
94
98
  export function declarePromptSet<C = undefined>(declaration: PromptSetDeclaration<C>): PromptSetDeclaration<C> {
95
99
  return declared("declarePromptSet", declaration);
96
100
  }
97
101
 
98
- // --- Harness/model pair: spec/064-harness.md ---
99
-
100
- /** `dir` is the family folder, less models.ts, versions.ts and collectTokenCosts.ts. */
101
- export interface HarnessApplyCtx extends ApplyParameterCtx {
102
+ /** `dir` is the harness's folder; `version` is the pair's version, as its `versions.ts` entry gives it. */
103
+ export type HarnessApplyCtx = ApplyParameterCtx & {
102
104
  version: string;
103
- }
105
+ };
104
106
 
105
- export interface HarnessCliCtx {
107
+ /**
108
+ * What a harness's `cli` gets, to build the command that runs the agent. An initial prompt of
109
+ * several turns runs `cli` once per turn, in the same container, one after another.
110
+ */
111
+ export type HarnessCliCtx = {
106
112
  version: string;
107
113
  modelId: string;
114
+ /**
115
+ * The turn's prompt text. Pasted into a shell command it breaks on the first quote, `$` or
116
+ * backtick, so read `promptPath` instead.
117
+ */
108
118
  initialPrompt: string;
119
+ /**
120
+ * A file in the container holding the turn's prompt, exactly, with no newline added: pass it to
121
+ * the agent as `"$(cat ${ctx.promptPath})"`, or as a file if it takes one.
122
+ */
123
+ promptPath: string;
124
+ /** Where the agent's own report of the turn goes. Each turn has its own. */
109
125
  resultPath: string;
110
- }
111
-
112
- interface HarnessExecutionOverrides {
113
- /** Replaces the family's for this version. Gets the family's `config`, which versions.ts can't type. */
114
- applyParameter?: (config: unknown, ctx: HarnessApplyCtx) => string;
115
- cli?: (ctx: HarnessCliCtx) => string;
116
- requiredEnv?: string[];
117
- }
126
+ /** Which turn this is, counting from 0. A single prompt is turn 0. */
127
+ turn: number;
128
+ /**
129
+ * A fresh UUID for each container run, the same for each of its turns: start the agent's
130
+ * session with it on turn 0, and resume it on later turns, e.g. `--session-id`, then `--resume`.
131
+ */
132
+ sessionId: string;
133
+ };
118
134
 
119
- export interface HarnessDeclaration<C = unknown> extends Applies<C, HarnessApplyCtx> {
135
+ export type HarnessDeclaration<C = unknown> = Applies<C, HarnessApplyCtx> & {
120
136
  cli: (ctx: HarnessCliCtx) => string;
137
+ /**
138
+ * The agent's final reply, read from the text of the result file its `cli` left, for
139
+ * instruments such as `replyMatches` to measure. Without it, a run has no reply.
140
+ */
141
+ collectReply?: (raw: string) => string;
142
+ /**
143
+ * The names of the environment variables the agent needs, e.g. its API key. Each is passed into
144
+ * this harness's containers only, as `docker run -e NAME`, so its value comes from the shell that
145
+ * runs Thunderjar, and is never written down. `plan` and `run` stop if one is unset.
146
+ */
121
147
  requiredEnv?: string[];
122
- }
148
+ };
123
149
 
124
150
  export function declareHarness<C = undefined>(declaration: HarnessDeclaration<C>): HarnessDeclaration<C> {
125
151
  return declared("declareHarness", declaration);
126
152
  }
127
153
 
128
- export function declareHarnessModels(models: Record<string, string>): Record<string, string> {
154
+ /** A harness's models: the name a test case uses, to the ID the agent is given. */
155
+ export type HarnessModelsDeclaration = Record<string, string>;
156
+
157
+ export function declareHarnessModels(models: HarnessModelsDeclaration): HarnessModelsDeclaration {
129
158
  return declared("declareHarnessModels", models);
130
159
  }
131
160
 
132
- export type HarnessVersionEntry = ({ version: string } | { determineHash: () => string }) & HarnessExecutionOverrides;
161
+ /** A harness's versions, by the name a test case uses. */
162
+ export type HarnessVersionsDeclaration = Record<string, { version: string }>;
133
163
 
134
- export function declareHarnessVersions(
135
- versions: Record<string, HarnessVersionEntry>,
136
- ): Record<string, HarnessVersionEntry> {
164
+ export function declareHarnessVersions(versions: HarnessVersionsDeclaration): HarnessVersionsDeclaration {
137
165
  return declared("declareHarnessVersions", versions);
138
166
  }
139
167
 
140
- // spec/087-collecting-token-costs.md#normalising-collecttokencosts
141
- interface CollectorContext {
142
- version: string;
143
- modelId: string;
144
- }
145
-
146
- interface ModelTokenCosts {
168
+ /** One model's tokens, in four buckets priced differently, and its cost if the harness reports one. */
169
+ export type ModelTokenCosts = {
147
170
  model: string;
148
171
  inputTokens: number;
149
172
  outputTokens: number;
150
173
  cacheCreationTokens: number;
151
174
  cacheReadTokens: number;
175
+ /** The harness's own estimate, never computed by Thunderjar. Absent if it reports none. */
152
176
  costUsd?: number;
177
+ /** How far to trust `costUsd`: `unknown` when the harness's price table didn't know the model. */
153
178
  costBasis?: "list" | "managed" | "unknown";
154
- }
179
+ };
155
180
 
156
- type TokenCosts =
157
- | { outcome: "collected"; models: ModelTokenCosts[]; apiDurationMs?: number }
181
+ /**
182
+ * A container run's token costs, per model, or why there are none: no result file (`absent`), one
183
+ * that couldn't be read (`unparseable`), or one that counted nothing (`notReported`).
184
+ */
185
+ export type TokenCosts =
186
+ | { outcome: "collected"; models: ModelTokenCosts[]; /** Time spent waiting on the model, if the harness reports it. */ apiDurationMs?: number }
158
187
  | { outcome: "unavailable"; reason: "absent" | "unparseable" | "notReported" };
159
188
 
160
- type TokenCostCollector = (raw: string, ctx: CollectorContext) => TokenCosts;
189
+ /**
190
+ * Reads the token costs from what the harness's `cli` wrote to its result path. Thunderjar calls
191
+ * it only for a result file that exists, once per turn, and sums them. A throw is `unparseable`.
192
+ */
193
+ export type TokenCostCollector = (raw: string, ctx: { version: string; modelId: string }) => TokenCosts;
161
194
 
162
195
  export function declareTokenCostCollector(collector: TokenCostCollector): TokenCostCollector {
163
196
  return declared("declareTokenCostCollector", collector);
164
197
  }
165
198
 
166
- // --- Initial prompt: spec/066-task.md#initial-prompts ---
167
-
168
199
  /**
169
- * An initial prompt is one file, not a folder, so its fragment has no `ctx.dir`: nothing beside
170
- * it is part of its hash.
200
+ * An initial prompt is one file, not a folder, so its fragment has no `ctx.dir`. It is used when
201
+ * the harness runs, so its fragment is usually left out.
202
+ *
203
+ * It's a single `prompt`, or a list of `turns`, each run by the harness's `cli` in turn, in the
204
+ * same container and agent session, so a later turn can ask about an earlier one. Its
205
+ * `timeoutSeconds` covers every turn together.
171
206
  */
172
- export interface InitialPromptDeclaration<C = unknown> extends HashOverrides, Applies<C, FragmentCtx> {
173
- prompt: string;
207
+ export type InitialPromptDeclaration<C = unknown> = Applies<C, FragmentCtx> & {
174
208
  timeoutSeconds: number;
175
- }
209
+ } & ({ prompt: string; turns?: never } | { turns: string[]; prompt?: never });
176
210
 
177
211
  export function declareInitialPrompt<C = undefined>(declaration: InitialPromptDeclaration<C>): InitialPromptDeclaration<C> {
178
212
  return declared("declareInitialPrompt", declaration);
179
213
  }
180
214
 
181
- // --- Measurement: spec/060-test-case-parameters.md#measuring-instruments--measurements ---
215
+ // --- Measurements ---
182
216
 
183
- /** What an instrument's `evaluate` gives for one measurement of one container run. */
217
+ /** What an instrument gives for one measurement of one container run. */
184
218
  export type MeasurementResult<T> =
185
219
  | { outcome: "measured"; data: T }
186
- | { outcome: "skipped"; reason?: string } // e.g. no output existed to check; `show` prints the reason
187
- | { outcome: "erroredWhileMeasuring"; error: Error }; // the instrument itself threw
220
+ /** Nothing to measure, e.g. no file matched. */
221
+ | { outcome: "skipped"; reason?: string }
222
+ /** The instrument itself failed. */
223
+ | { outcome: "erroredWhileMeasuring"; error: Error };
188
224
 
189
225
  /**
190
- * What an instrument runs against: the postrun state of one container run —
191
- * spec/060-test-case-parameters.md#measurement-context. Relative paths and glob patterns
192
- * resolve in `workdir`.
226
+ * What an instrument runs against: a measurement container, started from a container run's
227
+ * postrun image, and discarded once the measure is taken. Relative paths, glob patterns and
228
+ * commands resolve in `workdir`.
193
229
  */
194
- export interface MeasurementContext {
230
+ export type MeasurementContext = {
195
231
  exec(command: string): Promise<{ stdout: string; stderr: string; exitCode: number }>;
196
232
  readFile(path: string): Promise<string>;
197
233
  writeFile(path: string, contents: string): Promise<void>;
198
- /** Files matching `pattern`, relative to `workdir`, sorted. */
234
+ /** The files matching `pattern`, relative to `workdir`, sorted. */
199
235
  glob(pattern: string): Promise<string[]>;
200
- /** Raw contents of the file the harness's `cli` wrote. */
236
+ /** The text of the result file the harness's `cli` left, or "" if it left none. */
201
237
  harnessResult: string;
238
+ /** The agent's final reply, from its harness's `collectReply`, or why there's none. */
239
+ reply: Reply;
240
+ /** Its reply to each turn, in order: one, unless its initial prompt has several turns. */
241
+ replies: Reply[];
202
242
  workdir: string;
203
- }
243
+ };
204
244
 
205
- /**
206
- * The built-in instruments' config and value types, by name. src/instruments/instruments.ts implements
207
- * each against exactly these types.
208
- */
209
- export interface InstrumentTypes {
245
+ /** The agent's final reply, as its harness's `collectReply` read it from the result file, or why there's none. */
246
+ export type Reply = { outcome: "collected"; text: string } | { outcome: "unavailable"; reason: string };
247
+
248
+ /** Each built-in instrument's config and value, by name. */
249
+ export type InstrumentTypes = {
210
250
  /** Did the agent leave a file matching `glob`? */
211
251
  fileCreated: { config: { glob: string }; value: boolean };
212
- /** How many lines, across the files matching `glob`, match `pattern`, a JavaScript regex? */
252
+ /** How many lines, across the files matching `glob`, match `pattern`, a JavaScript regular expression? */
213
253
  grep: { config: { glob: string; pattern: string }; value: number };
214
254
  /**
215
- * How many tests pass and fail when `template`, a path relative to the measurement file, is
255
+ * How many tests pass and fail when `template`, a path relative to the measurement's file, is
216
256
  * rendered against each file matching `subject` and run with `bun test`?
217
257
  */
218
258
  templateTest: { config: { subject: string; template: string }; value: { passed: number; failed: number } };
219
- }
259
+ /**
260
+ * Does the agent's reply match `pattern`, a JavaScript regular expression? Its reply to `turn`,
261
+ * counting from 1, of an initial prompt of several turns, or by default its last.
262
+ */
263
+ replyMatches: { config: { pattern: string; turn?: number }; value: boolean };
264
+ };
220
265
 
221
266
  export type InstrumentName = keyof InstrumentTypes;
222
267
 
223
- /** The ways a value type's measures can combine — spec/085-test-case-results.md#how-a-measures-values-combine. */
268
+ /** The ways values of type `T` can combine across iterations. */
224
269
  export type AggregateFor<T> = [T] extends [boolean]
225
270
  ? "rate" | "count"
226
271
  : [T] extends [number]
@@ -229,12 +274,12 @@ export type AggregateFor<T> = [T] extends [boolean]
229
274
  ? "sum" | "mean"
230
275
  : never;
231
276
 
232
- /** An instrument plus its config, typed by the instrument: a `config` that doesn't fit it is a type error. */
277
+ /** An instrument and its config: a `config` that doesn't fit the instrument is a type error. */
233
278
  export type MeasurementDeclaration = {
234
279
  [I in InstrumentName]: {
235
280
  instrument: I;
236
281
  config: InstrumentTypes[I]["config"];
237
- /** How this measurement's values combine across iterations — see src/measuring/aggregate.ts. */
282
+ /** How its values combine across iterations, e.g. `rate`. */
238
283
  aggregate: AggregateFor<InstrumentTypes[I]["value"]>[];
239
284
  };
240
285
  }[InstrumentName];
@@ -243,37 +288,35 @@ export function declareMeasurement(declaration: MeasurementDeclaration): Measure
243
288
  return declared("declareMeasurement", declaration);
244
289
  }
245
290
 
246
- // --- Test case: spec/055-declaring-test-cases.md ---
247
-
248
- export interface TestCaseDeclaration<T extends TaskName = TaskName> {
249
- baseImage: BaseImageName[];
250
- codeState: CodeStateName[];
251
- promptSet: PromptSetName[];
252
- harnessModel: HarnessModelName[];
291
+ /**
292
+ * Every name must be declared under `test-case-parameters/`, and each initial prompt must be one of
293
+ * `task`'s, once `thunderjar generate` has run.
294
+ */
295
+ export type TestCaseDeclaration<T extends TaskNames = TaskNames> = {
296
+ baseImage: BaseImageNames[];
297
+ codeState: CodeStateNames[];
298
+ promptSet: PromptSetNames[];
299
+ harnessModel: HarnessModelNames[];
253
300
  task: T;
254
- initialPrompt: Array<T extends keyof InitialPromptNames ? InitialPromptNames[T] : never>;
301
+ initialPrompt: (T extends keyof InitialPromptNamesByTask ? InitialPromptNamesByTask[T] : never)[];
255
302
  iterations: number;
256
- }
303
+ };
257
304
 
258
- export function declareTestCase<T extends TaskName>(declaration: TestCaseDeclaration<T>): TestCaseDeclaration<T> {
305
+ export function declareTestCase<T extends TaskNames>(declaration: TestCaseDeclaration<T>): TestCaseDeclaration<T> {
259
306
  return declared("declareTestCase", declaration);
260
307
  }
261
308
 
262
- // --- Global config: spec/050-setup-and-configure.md#thunderjarconfigts ---
309
+ export type SqliteStore = { kind: "sqlite"; path: string };
263
310
 
264
- interface SqliteStoreOptions {
265
- path: string;
266
- }
267
-
268
- export function sqliteStore(options: SqliteStoreOptions): { kind: "sqlite" } & SqliteStoreOptions {
311
+ export function sqliteStore(options: { path: string }): SqliteStore {
269
312
  return { kind: "sqlite", ...options };
270
313
  }
271
314
 
272
- interface ThunderjarConfigDeclaration {
315
+ export type ConfigDeclaration = {
273
316
  imageStore: { repository: string };
274
- runDataStore: ReturnType<typeof sqliteStore>;
275
- }
317
+ runDataStore: SqliteStore;
318
+ };
276
319
 
277
- export function declareConfig(config: ThunderjarConfigDeclaration): ThunderjarConfigDeclaration {
320
+ export function declareConfig(config: ConfigDeclaration): ConfigDeclaration {
278
321
  return declared("declareConfig", config);
279
322
  }
@@ -0,0 +1,44 @@
1
+ // The `thunderjar` library: what a project's `.thunderjar/` files import.
2
+ export { checkoutCommit } from "./checkoutCommit.ts";
3
+ export { claudeCodeReply, claudeCodeTokenCosts } from "./claudeCode.ts";
4
+ export {
5
+ declareBaseImage,
6
+ declareCodeState,
7
+ declareConfig,
8
+ declareHarness,
9
+ declareHarnessModels,
10
+ declareHarnessVersions,
11
+ declareInitialPrompt,
12
+ declareMeasurement,
13
+ declarePromptSet,
14
+ declareTestCase,
15
+ declareTokenCostCollector,
16
+ sqliteStore,
17
+ } from "./declarations.ts";
18
+ export type {
19
+ AggregateFor,
20
+ ApplyParameterCtx,
21
+ BaseImageDeclaration,
22
+ CodeStateDeclaration,
23
+ ConfigDeclaration,
24
+ FragmentCtx,
25
+ GitCtx,
26
+ HarnessApplyCtx,
27
+ HarnessCliCtx,
28
+ HarnessDeclaration,
29
+ HarnessModelsDeclaration,
30
+ HarnessVersionsDeclaration,
31
+ InitialPromptDeclaration,
32
+ InstrumentName,
33
+ InstrumentTypes,
34
+ MeasurementContext,
35
+ MeasurementDeclaration,
36
+ MeasurementResult,
37
+ ModelTokenCosts,
38
+ PromptSetDeclaration,
39
+ Reply,
40
+ SqliteStore,
41
+ TestCaseDeclaration,
42
+ TokenCostCollector,
43
+ TokenCosts,
44
+ } from "./declarations.ts";