@orkestrel/scaffold 0.0.67 → 0.0.68

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 (74) hide show
  1. package/dist/bin/main.js +67 -44
  2. package/dist/bin/main.js.map +1 -1
  3. package/dist/host/agents/templates/brief.md +9 -0
  4. package/dist/host/claude/agents/orkestrel.md +4 -4
  5. package/dist/host/claude/rules/names.md +15 -0
  6. package/dist/host/claude/rules/tests.md +33 -4
  7. package/dist/host/claude/rules/workspace.md +14 -2
  8. package/dist/host/dotfiles/prettierignore +3 -0
  9. package/dist/host/guides/README.md +65 -0
  10. package/dist/host/guides/abort.md +169 -0
  11. package/dist/host/guides/agent.md +1509 -0
  12. package/dist/host/guides/brief.md +1266 -0
  13. package/dist/host/guides/browser.md +2200 -0
  14. package/dist/host/guides/budget.md +196 -0
  15. package/dist/host/guides/codec.md +519 -0
  16. package/dist/host/guides/console.md +785 -0
  17. package/dist/host/guides/contract.md +1193 -0
  18. package/dist/host/guides/csv.md +541 -0
  19. package/dist/host/guides/database.md +2518 -0
  20. package/dist/host/guides/emitter.md +233 -0
  21. package/dist/host/guides/form.md +1791 -0
  22. package/dist/host/guides/html.md +717 -0
  23. package/dist/host/guides/indexeddb.md +505 -0
  24. package/dist/host/guides/interpret.md +1029 -0
  25. package/dist/host/guides/lsp.md +515 -0
  26. package/dist/host/guides/markdown.md +964 -0
  27. package/dist/host/guides/mcp.md +5554 -0
  28. package/dist/host/guides/middleware.md +927 -0
  29. package/dist/host/guides/msg.md +440 -0
  30. package/dist/host/guides/ndjson.md +120 -0
  31. package/dist/host/guides/ollama.md +380 -0
  32. package/dist/host/guides/pool.md +280 -0
  33. package/dist/host/guides/probe.md +1210 -0
  34. package/dist/host/guides/process.md +1620 -0
  35. package/dist/host/guides/program.md +1110 -0
  36. package/dist/host/guides/qualifier.md +854 -0
  37. package/dist/host/guides/queue.md +370 -0
  38. package/dist/host/guides/rater.md +330 -0
  39. package/dist/host/guides/reason.md +1122 -0
  40. package/dist/host/guides/relation.md +373 -0
  41. package/dist/host/guides/router.md +753 -0
  42. package/dist/host/guides/scaffold.md +192 -31
  43. package/dist/host/guides/sea.md +383 -0
  44. package/dist/host/guides/server.md +752 -0
  45. package/dist/host/guides/sqlite.md +330 -0
  46. package/dist/host/guides/sse.md +187 -0
  47. package/dist/host/guides/supervisor.md +4890 -0
  48. package/dist/host/guides/table.md +1556 -0
  49. package/dist/host/guides/template.md +280 -0
  50. package/dist/host/guides/terminal.md +1145 -0
  51. package/dist/host/guides/test.md +2969 -0
  52. package/dist/host/guides/timeout.md +252 -0
  53. package/dist/host/guides/tool.md +311 -0
  54. package/dist/host/guides/toolbox.md +1038 -0
  55. package/dist/host/guides/websocket.md +282 -0
  56. package/dist/host/guides/worker.md +615 -0
  57. package/dist/host/guides/workflow.md +1507 -0
  58. package/dist/host/guides/workspace.md +595 -0
  59. package/dist/host/manifest.json +1218 -10
  60. package/dist/host/tests/policy.test.ts +279 -2
  61. package/dist/host/tests/setupPolicy.ts +437 -6
  62. package/dist/src/core/index.cjs +38 -16
  63. package/dist/src/core/index.cjs.map +1 -1
  64. package/dist/src/core/index.d.cts +33 -9
  65. package/dist/src/core/index.d.ts +33 -9
  66. package/dist/src/core/index.js +37 -17
  67. package/dist/src/core/index.js.map +1 -1
  68. package/dist/src/server/index.cjs +1750 -1567
  69. package/dist/src/server/index.cjs.map +1 -1
  70. package/dist/src/server/index.d.cts +106 -24
  71. package/dist/src/server/index.d.ts +106 -24
  72. package/dist/src/server/index.js +1751 -1570
  73. package/dist/src/server/index.js.map +1 -1
  74. package/package.json +3 -3
@@ -0,0 +1,1210 @@
1
+ # Probe
2
+
3
+ > The claim prover for the `@orkestrel` line: an instrument that runs a claim's case and its
4
+ > negative control through the workspace's own TypeScript, Oxlint, and Vitest, and returns a
5
+ > `Verdict` carrying every issue — and a `receipt` when the case ran clean and the control broke
6
+ > where it said it would.
7
+
8
+ A `Claim`, a `Verdict`, and a `receipt` carry the package. A `Claim` is the question: a case, a
9
+ control that must break, and the TypeScript project both are judged under. A `Verdict` is the
10
+ answer: one `Check` per stage in each phase, the case and the control. A `receipt` is the verdict's
11
+ one-line summary of the conditions it was reached under, and it exists only when the claim proved
12
+ itself.
13
+
14
+ The type stage runs the workspace's own compiler over a mirror of the tree, and the lint and runtime
15
+ stages hold resident Oxlint and Vitest engines. Source: [`src/core`](../src/core),
16
+ [`src/server`](../src/server), [`src/bin`](../src/bin). Published through `@orkestrel/probe` and
17
+ `@orkestrel/probe/server`.
18
+
19
+ **An agent is the caller this exists for.** Deciding whether an edit compiles by reasoning about it
20
+ costs more than asking, and the answer is a guess.
21
+
22
+ **Mechanism, not policy.** probe reports evidence and mints a receipt under stated conditions. It
23
+ holds no key, signs nothing, and compels nothing. It also **executes caller-supplied test code with
24
+ the privileges of the process that hosts it**, so give a probe a workspace and a caller you already
25
+ trust with a shell.
26
+
27
+ ## Surface
28
+
29
+ ### Contracts
30
+
31
+ The data shapes, from [`types.ts`](../src/core/types.ts). Every property is readonly, and an absent
32
+ optional field is absent rather than empty.
33
+
34
+ A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an optional member and `plus` introducing its call-signature members, and a type alias's own type literal with a union's arms escaped as `\|`. An extended interface's name comes before `plus`, with the members it adds after.
35
+
36
+ | Name | Kind | Shape | Summary |
37
+ | ------------------- | --------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
38
+ | `Stage` | type | `'type' \| 'lint' \| 'runtime'` | Names an inspection a claim passes through, derived from `PROBE_STAGES`. |
39
+ | `Draft` | interface | `{ path, text }` | Carries one proposed file's location and its contents. |
40
+ | `Case` | interface | `{ files, test }` | Carries the candidate drafts a claim asserts about and the test that exercises them. |
41
+ | `Control` | interface | `Case plus { stage, reason }` | Extends a case with the stage it must fail at and the reason it must fail there. |
42
+ | `Claim` | interface | `{ project, case, control }` | Carries everything the service needs to produce one verdict. |
43
+ | `Party` | type | `'claimant' \| 'workspace' \| 'instrument'` | Names who must act on an issue or probe failure. |
44
+ | `Issue` | interface | `{ origin, path, message, range? }` | Carries one message a stage reported, where it reported it, and whose fault it names. |
45
+ | `Check` | interface | `{ stage, elapsed, issues }` | Carries one stage's outcome: what it cost and what it reported. |
46
+ | `Toolchain` | interface | `{ typescript, oxlint, vitest }` | Names the tool versions a verdict was produced with. |
47
+ | `Project` | interface | `{ path, digest }` | Names the TypeScript project that judged a verdict's candidate drafts. |
48
+ | `Verdict` | interface | `{ id, digest, toolchain, project, reason?, case, control, elapsed, receipt? }` | Carries the full result of one claim: every stage, for both the case and its control. |
49
+ | `ProbeEventMap` | type | `{ arm, prove, expire, error }` | Reports what a probe observes while it serves. |
50
+ | `ProbeOptions` | interface | `{ on?, error?, workspace?, deadline? }` | Configures a probe. |
51
+ | `ProbeInterface` | interface | `{ emitter, toolchain } plus prove, destroy` | Answers a claim with type, lint, and runtime evidence in one call. |
52
+ | `ProbeErrorCode` | type | `'refused' \| 'missing' \| 'malformed' \| 'destroyed' \| 'deadline'` | Names the condition that ended a probe operation, derived from `PROBE_ERROR_CODES`. |
53
+ | `ProbeErrorContext` | interface | `{ stage?, path?, project?, name?, deadline?, value? }` | Carries the structured detail one probe failure reports beside its message. |
54
+ | `ProbeErrorOptions` | interface | `{ origin, code, context?, cause? }` | Configures one probe failure at construction. |
55
+
56
+ A row whose `Shape` cell names call-signature members after `plus` carries those members in
57
+ [`## Methods`](#methods); the data members before `plus` stay here.
58
+
59
+ ### Constants
60
+
61
+ From [`constants.ts`](../src/core/constants.ts). Each is frozen.
62
+
63
+ A `Shape` cell holds the constant's declared type.
64
+
65
+ | Name | Kind | Shape | Summary |
66
+ | ---------------------- | ----- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
67
+ | `PROBE_STAGES` | const | `readonly Stage[]` | Lists the stages a claim passes through, in the order a verdict reports them: `['type', 'lint', 'runtime']`. |
68
+ | `PROBE_PARTIES` | const | `readonly Party[]` | Lists the parties that can own action on an issue or probe failure: `['claimant', 'workspace', 'instrument']`. |
69
+ | `RECEIPT_PREFIX` | const | `string` | Names the leading token every receipt carries, `'probe'`. |
70
+ | `RECEIPT_SEPARATOR` | const | `string` | Names the character joining a receipt's tokens, `':'`. |
71
+ | `PROBE_ERROR_CODES` | const | `readonly ProbeErrorCode[]` | Lists the conditions that can end a probe operation: `['refused', 'missing', 'malformed', 'destroyed', 'deadline']`. |
72
+ | `PROBE_DEADLINE` | const | `number` | Names the default inspection deadline a `Probe` applies when its construction omits one, 30,000 ms. |
73
+ | `LINT_DEADLINE` | const | `number` | Names the 2,000 ms bound the lint stage holds over the lifecycle exchanges the protocol leaves to the server: the `initialize` reply warming waits for and the `shutdown` reply ending waits for. |
74
+ | `PROBE_KEYS` | const | `number` | Names the total enumerable key bound `ProbeServer` applies to inbound metadata and to produced tool content alike, 4096. |
75
+ | `PROBE_SPECIFICATIONS` | const | `number` | Names the specification lifetime the runtime stage replaces its resident Vitest service at, 64 specifications. |
76
+ | `RUNTIME_PLUGIN` | const | `string` | Names the Vite plugin the runtime stage installs into a target workspace's Vitest configuration, `'orkestrel-runtime-overlay'`. |
77
+ | `TYPE_MIRROR` | const | `string` | Names the workspace-relative directory the type stage keeps its workspace mirror under, `'tmp/type'`. |
78
+
79
+ ### Errors
80
+
81
+ The failure type every served claim reports through, and its guard, from
82
+ [`errors.ts`](../src/core/errors.ts).
83
+
84
+ | Name | Kind | Signature | Summary |
85
+ | ---------------------- | -------- | --------------------------------------------------- | ----------------------------------------------------------------------------- |
86
+ | `ProbeError` | class | `new (message: string, options: ProbeErrorOptions)` | Reports one probe failure under stable ownership and condition axes. |
87
+ | `isProbeError` | function | `(value: unknown) => value is ProbeError` | Checks whether an unknown value is a `ProbeError`. |
88
+ | `createDestroyedError` | function | `(subject: string) => ProbeError` | Creates the failure raised when an instrument is used after it was torn down. |
89
+
90
+ ### Shapes
91
+
92
+ The blueprints behind both the published tool schema and the guard applied to an arriving call,
93
+ from [`shapers.ts`](../src/core/shapers.ts). `CLAIM_SHAPE` compiles to the `prove` tool's JSON
94
+ Schema. The schema is the wire contract's shape and `isClaim` is the admission rule, and the rule is
95
+ narrower on `Draft.path`: see
96
+ [The advertised schema is wider than the admission rule](#registering-the-server).
97
+
98
+ A `Shape` cell holds the constant's declared type.
99
+
100
+ | Name | Kind | Shape | Summary |
101
+ | --------------- | ----- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
102
+ | `DRAFT_SHAPE` | const | `ObjectShape<{ path, text }>` | Describes one proposed file a claim carries. |
103
+ | `CASE_SHAPE` | const | `ObjectShape<{ files, test }>` | Describes the drafts a claim asserts about and the test that exercises them. |
104
+ | `CONTROL_SHAPE` | const | `ObjectShape<{ files, test, stage, reason }>` | Describes the negative control, which is a case plus where and why it must break. |
105
+ | `CLAIM_SHAPE` | const | `ObjectShape<{ project, case, control }>` | Describes one claim and is the sole source of both the published tool schema and the guard applied to an arriving claim. |
106
+
107
+ ### Validators
108
+
109
+ Total guards, from [`validators.ts`](../src/core/validators.ts). Each returns a boolean for any
110
+ input and never throws.
111
+
112
+ In a guard table a `Shape` cell holds the type the guard narrows to.
113
+
114
+ | Name | Kind | Shape | Summary |
115
+ | ------------- | ----- | ----------- | ----------------------------------------------------------------------------------------------- |
116
+ | `isStage` | const | `Stage` | Checks whether a value names a stage. |
117
+ | `isParty` | const | `Party` | Checks whether a value names a party an issue carries. |
118
+ | `isDraft` | const | `Draft` | Checks whether a value carries a proposed file's path and text. |
119
+ | `isCase` | const | `Case` | Checks whether a value carries a claim's candidate drafts and its test. |
120
+ | `isControl` | const | `Control` | Checks whether a value carries a case plus the stage and reason it must fail for. |
121
+ | `isClaim` | const | `Claim` | Checks whether a value carries a project, a case, and a control. |
122
+ | `isIssue` | const | `Issue` | Checks whether a value carries one message, its location, and the origin of the fault it names. |
123
+ | `isCheck` | const | `Check` | Checks whether a value carries one stage's outcome. |
124
+ | `isToolchain` | const | `Toolchain` | Checks whether a value names every resolved tool version. |
125
+ | `isProject` | const | `Project` | Checks whether a value names one resolved TypeScript project and what it contained. |
126
+ | `isVerdict` | const | `Verdict` | Checks whether a value carries a complete verdict. |
127
+
128
+ ### Formatters and the token
129
+
130
+ Pure leaves, from [`helpers.ts`](../src/core/helpers.ts).
131
+
132
+ | Name | Kind | Signature | Summary |
133
+ | ---------------------- | -------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
134
+ | `formatIssue` | function | `(issue: Issue) => string` | Renders one tool message as a single line an agent can classify and locate. |
135
+ | `formatCheck` | function | `(check: Check) => string` | Renders one stage's outcome as its summary line followed by every message it reported. |
136
+ | `formatProof` | function | `(verdict: Verdict) => string` | Renders the closing line a rendered verdict ends with: the receipt it earned, or its absence. |
137
+ | `formatReceipt` | function | `(verdict: Verdict) => string` | Renders the smallest text a verdict can travel as: what it judged, and how it ended. |
138
+ | `formatVerdict` | function | `(verdict: Verdict) => string` | Renders a whole verdict as the text an agent reads. |
139
+ | `computeReceipt` | function | `(verdict: Verdict, stage: Stage) => string \| undefined` | Computes the proof token a verdict carries, or returns nothing when the claim was not proven. |
140
+ | `formatSpecification` | function | `(text: string, revision: string) => string` | Renders one generated specification: the caller's own test text, then the marker naming the revision that wrote it. |
141
+ | `matchesSpecification` | function | `(text: string, revision: string) => boolean` | Checks whether one file's text is the generated specification written for one revision. |
142
+
143
+ ### Server contracts
144
+
145
+ From [`types.ts`](../src/server/types.ts).
146
+
147
+ A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an optional member and `plus` introducing its call-signature members, and a type alias's own type literal with a union's arms escaped as `\|`. An extended interface's name comes before `plus`, with the members it adds after.
148
+
149
+ | Name | Kind | Shape | Summary |
150
+ | ---------------------- | --------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
151
+ | `Inspection` | interface | `{ subject, claim }` | Carries one queued inspection: the case a stage reads and the claim it belongs to. |
152
+ | `InspectionOptions` | interface | `{ signal }` | Carries the bound a caller holds over one stage inspection. |
153
+ | `OverlayInterface` | interface | `{ revision, paths } plus set, text, covers, clear` | Holds the candidate drafts one inspection substitutes for the files a tool would read from disk. |
154
+ | `StageInterface` | interface | `{ stage, progress } plus inspect, destroy` | Inspects one case with the workspace's own tool. |
155
+ | `TypeStageInterface` | interface | `StageInterface plus inspect, resolve` | Inspects TypeScript source against a caller-named project and reports what that project is. |
156
+ | `LintStageInterface` | interface | `StageInterface plus inspect` | Inspects one case under a bound the caller supplies. |
157
+ | `WorkspaceManifest` | interface | `{ path, contents }` | Carries one parsed package manifest and the path it came from. |
158
+ | `Diagnostic` | interface | `{ path?, range?, message }` | Carries one diagnostic line a compiler run reported, in this package's own coordinates. |
159
+ | `ProjectConfig` | interface | `{ compilerOptions, files?, include? }` | Carries what one TypeScript project resolved to, as the compiler itself printed it. |
160
+ | `Execution` | interface | `{ status?, stdout, stderr }` | Carries what one spawned workspace command reported when it closed. |
161
+ | `ProbeServerInterface` | interface | `{} plus start, destroy` | Serves one probe over this process's Model Context Protocol stdio transport. |
162
+ | `ListenerCapture` | type | `ReadonlyMap<string, readonly Function[]>` | Holds the listeners one emitter carried for a set of events at the moment it was captured. |
163
+
164
+ `StageInterface.progress` is the seam a foreign coordinator reads to decide whose budget an expiry
165
+ belongs to, and this is the proof behind it.
166
+ [`RuntimeStage.test.ts`](../tests/src/server/stages/RuntimeStage.test.ts) proves the gauge boundary
167
+ deterministically: it holds one inspection at the results cache with a FIFO, reads `progress`
168
+ elevated while the caller's run is in flight, and reads it level with its pre-inspection value while
169
+ the stage evicts.
170
+ [`LintStage.test.ts`](../tests/src/server/stages/LintStage.test.ts) reads the same boundary at the
171
+ other stage that owns cleanup: it fills the pipe the stage writes to, so the `didClose` the stage
172
+ owes its own language server is still waiting for room while the gauge reads level.
173
+ Claimant-side expiry is proven end to end through `Probe`, which rejects a claim
174
+ that outran the budget with `origin: 'claimant'`, `code: 'deadline'`, and the expired stage in
175
+ `context`. The composed instrument-side expiry — a real expiry during stage-owned work, attributed
176
+ through `Probe` — has no executed proof, and the gauge is the seam a proof of it reads.
177
+
178
+ ### The engine
179
+
180
+ The classes, each exported from its own file, and the contract each one implements:
181
+ [`Probe`](../src/server/Probe.ts) implements `ProbeInterface`,
182
+ [`ProbeServer`](../src/server/ProbeServer.ts) implements `ProbeServerInterface`,
183
+ [`TypeStage`](../src/server/stages/TypeStage.ts) implements `TypeStageInterface`,
184
+ [`LintStage`](../src/server/stages/LintStage.ts) implements `LintStageInterface`,
185
+ [`RuntimeStage`](../src/server/stages/RuntimeStage.ts) implements `StageInterface`, and
186
+ [`Overlay`](../src/server/Overlay.ts) implements `OverlayInterface`.
187
+
188
+ | Name | Kind | Summary |
189
+ | -------------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
190
+ | `Probe` | class | Answers claims through its type, lint, and runtime stages. |
191
+ | `ProbeServer` | class | Implements `ProbeServerInterface` over a `PassThrough` stream this server owns, binding the published `prove` tool and the dual-era dispatcher to this process's Model Context Protocol stdio transport. |
192
+ | `TypeStage` | class | Inspects TypeScript source by running the target workspace's own compiler over a mirror of it. |
193
+ | `LintStage` | class | Inspects virtual documents through one resident Oxlint language server. |
194
+ | `RuntimeStage` | class | Inspects tests through one resident Vitest service from the target workspace. |
195
+ | `Overlay` | class | Implements `OverlayInterface` over a private map from normalized absolute path to candidate text, minting at construction the `revision` a resident tool caches its answers against. |
196
+
197
+ Each stage takes one optional `workspace` argument and defaults to the working directory. A stage
198
+ serves one inspection at a time and admits none itself, so drive stages through `Probe` unless you
199
+ are building your own coordinator.
200
+
201
+ `new Overlay()` takes no arguments, so a coordinator of your own can mint one. Mint it per
202
+ inspection and release it when that inspection ends. An overlay shared across inspections keeps the
203
+ identity a resident tool caches its answers against, so the second inspection reads the first one's
204
+ answer as a fresh one.
205
+
206
+ `RuntimeStage` is the stage that holds one. A lookup key matches a recorded path exactly, after both
207
+ sides pass through `normalizePath`, and case is never folded. On a host that resolves two spellings
208
+ of one file name to one file, Vite serves a covered path under whichever spelling it met first, and
209
+ the stage reports that as the `workspace` issue `The workspace configuration served this module
210
+ before the runtime overlay` rather than leaving it answered silently. `TypeStage` holds no overlay:
211
+ it writes each draft into its mirror as a real file, so the host's own file-name comparison decides
212
+ what a draft shadows.
213
+
214
+ ### Server helpers
215
+
216
+ Pure leaves and workspace readers, from [`helpers.ts`](../src/server/helpers.ts).
217
+
218
+ | Name | Kind | Signature | Summary |
219
+ | -------------------------- | -------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
220
+ | `normalizePath` | function | `(path: string) => string` | Rewrites one path into the forward-slash spelling this package compares and reports paths in. |
221
+ | `readFaultCode` | function | `(error: unknown) => string \| undefined` | Reads the condition code a native fault carries. |
222
+ | `escapesRoot` | function | `(root: string, target: string) => boolean` | Reports whether one path resolves outside the root it is read against. |
223
+ | `resolveWorkspaceFile` | function | `(workspace: string, target: string, mutate?: boolean) => string` | Resolves a path inside a target workspace and rejects traversal outside it. |
224
+ | `overwriteFile` | function | `(file: string, text: string) => void` | Overwrites a file that already exists, through a descriptor that refuses a symbolic link at the final component. |
225
+ | `isRefusedName` | function | `(file: string, error: unknown) => boolean` | Reports whether a fault means the host refuses the name a caller supplied for a file to create. |
226
+ | `relativeWorkspaceFile` | function | `(workspace: string, file: string) => string` | Projects an absolute tool path into the workspace-relative form issues expose. |
227
+ | `relativeWorkspaceMessage` | function | `(workspace: string, message: string) => string` | Projects the paths one tool named in a message into the forms this package's issues expose. |
228
+ | `scanDiagnostics` | function | `(text: string) => readonly Diagnostic[]` | Scans the plain-text output of one compiler run into the diagnostics it reported. |
229
+ | `resolveWorkspaceModule` | function | `(workspace: string, specifier: string) => string` | Resolves one installed module from the target workspace. |
230
+ | `loadWorkspaceVitest` | function | `(workspace: string) => typeof import('vitest/node')` | Loads the installed `vitest/node` module from a target workspace. |
231
+ | `readWorkspaceManifest` | function | `(workspace: string, name: string) => WorkspaceManifest` | Reads one installed package manifest from the target workspace. |
232
+ | `resolveWorkspaceBinary` | function | `(workspace: string, name: string, command?: string) => string` | Resolves a package's portable JavaScript binary from the target workspace. |
233
+ | `inferTypeProject` | function | `(path: string) => string` | Selects the scoped TypeScript project for one candidate draft path. |
234
+ | `inferTestProject` | function | `(path: string) => string \| undefined` | Selects the Vitest project whose environment matches one test path. |
235
+ | `inferDocumentLanguage` | function | `(path: string) => string` | Selects the Language Server Protocol language identifier for a source path. |
236
+ | `buildRevisionPath` | function | `(workspace: string, path: string, revision: string) => string` | Builds the fresh sibling path a revision's file is written at, preserving the test's resolution directory. |
237
+ | `matchesWorkspaceModule` | function | `(path: string) => boolean` | Reports whether a path is a workspace module Vitest can cache. |
238
+ | `matchesLiveProcess` | function | `(id: number) => boolean` | Reports whether the host that wrote one file is still running. |
239
+ | `collectWorkspaceFiles` | function | `(workspace: string) => readonly string[]` | Collects every regular file a target workspace holds, skipping the trees no inspection reads. |
240
+ | `filterUniqueIssues` | function | `(issues: readonly Issue[]) => readonly Issue[]` | Filters one issue list to the distinct issues it carries, in the order they arrived. |
241
+ | `describeUnknown` | function | `(value: unknown) => string` | Normalizes a caught or foreign error into readable text. |
242
+ | `guardStage` | function | `<T>(stage: Stage, operation: Promise<T>) => Promise<T>` | Guards one stage operation with the stage failure contract. |
243
+ | `findRefusedPaths` | function | `(value: unknown) => readonly string[]` | Names every draft member of a claim-shaped value whose `path` this package's guard refuses. |
244
+ | `normalizeValue` | function | `(workspace: string, value: unknown) => unknown` | Rewrites every workspace-contained absolute path in a value to its workspace-relative form and sorts every record's keys. |
245
+ | `computeDigest` | function | `(workspace: string, value: unknown) => string` | Computes the canonical digest of one value as it stands in a target workspace. |
246
+ | `captureListeners` | function | `(emitter: EventEmitter, events: readonly string[]) => ListenerCapture` | Records the listeners one emitter carries for a set of events. |
247
+ | `releaseListeners` | function | `(emitter: EventEmitter, capture: ListenerCapture) => void` | Removes every listener one emitter gained for the captured events since its capture. |
248
+
249
+ ### Server parsers
250
+
251
+ Coercers over the text a tool wrote, from [`parsers.ts`](../src/server/parsers.ts). Each returns
252
+ `undefined` for text it cannot read rather than throwing.
253
+
254
+ | Name | Kind | Signature | Summary |
255
+ | -------------------- | -------- | ---------------------------------------------- | --------------------------------------------------------------------------- |
256
+ | `parseProjectConfig` | function | `(text: string) => ProjectConfig \| undefined` | Parses the configuration one compiler run printed for a TypeScript project. |
257
+ | `parseRevisionOwner` | function | `(revision: string) => number \| undefined` | Parses the process id one revision identity names. |
258
+
259
+ ## Methods
260
+
261
+ The public call-signature members of each behavioral interface, one table per interface.
262
+
263
+ #### `ProbeInterface`
264
+
265
+ | Method | Returns | Summary |
266
+ | --------- | ------------------ | --------------------------------------------------------------------------- |
267
+ | `prove` | `Promise<Verdict>` | Answers one claim with every stage's evidence. |
268
+ | `destroy` | `Promise<void>` | Tears down every stage and releases the processes and the mirror they hold. |
269
+
270
+ #### `StageInterface`
271
+
272
+ | Method | Returns | Summary |
273
+ | --------- | ---------------- | ---------------------------------------------------------------------- |
274
+ | `inspect` | `Promise<Check>` | Inspects one case. |
275
+ | `destroy` | `Promise<void>` | Tears down the resident tool or the mirror and releases its resources. |
276
+
277
+ #### `RuntimeStage`
278
+
279
+ | Method | Returns | Summary |
280
+ | --------- | ---------------- | ---------------------------------------------------------------------- |
281
+ | `inspect` | `Promise<Check>` | Inspects one case. |
282
+ | `destroy` | `Promise<void>` | Tears down the resident tool or the mirror and releases its resources. |
283
+
284
+ #### `TypeStageInterface`
285
+
286
+ | Method | Returns | Summary |
287
+ | --------- | ------------------ | ----------------------------------------------------------------------------- |
288
+ | `inspect` | `Promise<Check>` | Inspects one case, against a caller-named project where the caller names one. |
289
+ | `resolve` | `Promise<Project>` | Resolves one project to the path and digest the stage applies for it. |
290
+ | `destroy` | `Promise<void>` | Tears down the resident tool or the mirror and releases its resources. |
291
+
292
+ #### `LintStageInterface`
293
+
294
+ | Method | Returns | Summary |
295
+ | --------- | ---------------- | ---------------------------------------------------------------------- |
296
+ | `inspect` | `Promise<Check>` | Inspects one case, under the bound the caller supplies. |
297
+ | `destroy` | `Promise<void>` | Tears down the resident tool or the mirror and releases its resources. |
298
+
299
+ #### `OverlayInterface`
300
+
301
+ | Method | Returns | Summary |
302
+ | -------- | --------------------- | ------------------------------------------------------------------------ |
303
+ | `set` | `void` | Records one candidate's text against the absolute path it stands in for. |
304
+ | `text` | `string \| undefined` | Reads the candidate text recorded for one absolute path. |
305
+ | `covers` | `boolean` | Checks whether a candidate sits beneath one directory. |
306
+ | `clear` | `void` | Releases every candidate. |
307
+
308
+ #### `ProbeServerInterface`
309
+
310
+ | Method | Returns | Summary |
311
+ | --------- | --------------- | ------------------------------------------------------------------------- |
312
+ | `start` | `void` | Serves the probe over this process's standard input and output. |
313
+ | `destroy` | `Promise<void>` | Releases the transport, the process listeners, and the probe behind them. |
314
+
315
+ ## What a probe proves
316
+
317
+ Measure a performance claim first through a guarded bench block beside the probe test, run by the
318
+ `test:bench` script; a settled magnitude then proves through `prove` as an ordinary runtime claim.
319
+
320
+ A `Claim` is a question with a falsifier attached. Its `case` is the edit you believe is correct.
321
+ Its `control` is the same edit deliberately broken, plus the `stage` you say the breakage lands at
322
+ and the `reason` in your own words. Both are judged under the TypeScript project the claim's
323
+ `project` member names.
324
+
325
+ Every verdict returned by `prove` carries that explanation unchanged as `Verdict.reason`. The
326
+ member reports why the claimant chose the control. No receipt condition reads it, and it still
327
+ reaches the token: the reason is part of the control, so it enters `verdict.digest`, and the digest
328
+ is a field of the token. Two claims that differ only in the reason's prose are two claims, and they
329
+ digest differently.
330
+
331
+ `verdict.digest` covers these things and nothing else: the case bytes, the control bytes including
332
+ the reason, and the workspace those bytes were read against. The workspace enters because probe
333
+ rewrites every absolute string in a claim relative to the workspace before hashing, which is what
334
+ keeps one commit checked out at two paths reading as one claim. A claim carrying no absolute string
335
+ therefore digests the same in every workspace; a claim that carries one digests per workspace, so
336
+ compare two such tokens only when both were minted against the same tree.
337
+
338
+ `prove` runs every stage over the case, then every stage over the control, and returns one `Check`
339
+ per stage for each. A stage that cannot start throws rather than returning an empty check, so no
340
+ verdict ever reports a stage that did not run.
341
+
342
+ The receipt is minted on these conditions together:
343
+
344
+ - both phases report one check per stage; and
345
+ - the case produced no issue at any stage; and
346
+ - the control produced an `origin: 'claimant'` issue at the stage it declared, and neither phase
347
+ produced an `origin: 'instrument'` issue; and
348
+ - every other control stage produced no `origin: 'claimant'` issue.
349
+
350
+ A control that also breaks somewhere else has falsified the instrument rather than the claim, so no
351
+ receipt is minted for it. An `origin: 'instrument'` issue in either phase says the inspection did
352
+ not complete, so nothing was learned about the code and no receipt is minted either. An
353
+ `origin: 'workspace'` issue in the control decides neither break condition: it names the target
354
+ tree rather than the candidate. A case carrying any issue did not run clean and earns no receipt.
355
+ The check-per-stage condition binds both phases, because the clean-elsewhere condition reads the
356
+ control entries a verdict carries: a control that omits a stage would otherwise read as a stage
357
+ that stayed clean. `prove` records every stage for both phases, so that condition refuses only a
358
+ verdict you assembled by hand and passed to `computeReceipt` yourself.
359
+
360
+ `Issue.origin` names the party that must act, and the receipt conditions read that value rather
361
+ than the message beside it. **A `claimant` issue is a tool's diagnostic about a candidate draft,
362
+ and nothing else. Every other claimant fault is a throw.** That invariant is what lets one union
363
+ serve both an issue and a failure: without it, a caller's own mistake — a test path no project
364
+ collects, a `Claim.project` path that escapes the workspace — would arrive as a `claimant`
365
+ issue and satisfy the condition that a test which never ran must never satisfy. A `workspace` issue
366
+ carries the target tree's own defect, such as a symbolic link in a mutation path, a mutation path
367
+ whose existing components cannot be inspected, a specification directory the target tree blocks
368
+ probe from creating, a Vitest project the root configuration declares as a path string, into which
369
+ the runtime stage can install no overlay, or a covered module the workspace's own configuration
370
+ served before the runtime overlay. An `instrument` issue carries this package's own
371
+ message about an inspection that did not complete — a specification it could not write, after its
372
+ directory exists, for a reason the target tree does not own, a module that ran no test, or a covered
373
+ module probe's own loader received and did not resolve.
374
+ `formatIssue` renders the value first as `[claimant]`, `[workspace]`, or `[instrument]`, so the
375
+ ownership survives `formatVerdict`. A clean runtime check means every collected test passed, not
376
+ that the module reported itself passed.
377
+
378
+ **A diagnostic about a project belongs to the workspace.** The compiler reports a configuration
379
+ fault — a JSON syntax error, an option it cannot apply, a `types` entry or an extended project it
380
+ cannot resolve — against a `.json` file, or against no file at all. Either shape raises
381
+ `origin: 'workspace'`, `code: 'malformed'`, naming the project in `context`, and the inspection
382
+ reports nothing further, unless the named `.json` file is one the claim itself drafted: a claim that
383
+ proposes its own `.json` file owns a diagnostic about it like any other draft. The target tree holds
384
+ the only file that closes an undrafted configuration fault, so charging a claimant for it would
385
+ refuse every receipt that tree could earn until someone repaired a configuration nobody else owns.
386
+ The diagnostic's own path is what separates the two, not the compiler's exit code: the supported
387
+ majors disagree on that code, and a run reporting a candidate's type error and a run reporting a
388
+ malformed project exit alike.
389
+
390
+ A compiler that ends without diagnostics and without a successful exit raises an instrument
391
+ fault. Its message preserves the host's reported termination: a numeric exit code when present,
392
+ or a signal ending when no code was reported. A child sending itself `SIGTERM` can be reported as
393
+ a numeric exit on Windows, so the diagnostic does not infer a POSIX signal from the requested action.
394
+
395
+ **Every other diagnostic the run reported is a claimant issue**, whichever file it names. A draft
396
+ replaces the file it names, so a consumer of that path is judged against the draft's text and its
397
+ diagnostic is the draft's doing. The consequence is worth stating plainly: a workspace whose own
398
+ `check` script is red reports those diagnostics against every claim, and no claim earns a receipt
399
+ until the tree is green. That is the same reading the gate gives.
400
+
401
+ **Every message a stage reports is rendered in the workspace's own terms.** The host's directory
402
+ layout is removed from each path a message names, in whichever spelling the tool wrote — the
403
+ absolute path, the backslash spelling a Windows tool writes, and the `file:` URL a runtime names a
404
+ module by. A spelling of the root counts only where a path begins, so a directory whose own name
405
+ ends in the root's text keeps its whole path. The runtime stage removes one further name: the
406
+ generated sibling it ran, rewritten by exact basename to the declared test's own name.
407
+
408
+ ## Failures
409
+
410
+ Every failure probe raises while serving a claim is a `ProbeError`. Narrow a caught value with
411
+ `isProbeError`, then read two independent members: `origin` is the party that must act and `code` is
412
+ the condition that ended the operation. Read `message` to print it and `context` for the detail
413
+ behind it.
414
+
415
+ `origin` carries the same values `Issue.origin` carries, and it answers the question a caller has
416
+ to answer first — is this my fault, the target tree's, or the tool's — from a value rather than from
417
+ the message. One branch routes every failure this package raises:
418
+
419
+ ```ts
420
+ import { isProbeError } from '@orkestrel/probe'
421
+
422
+ try {
423
+ await probe.prove(claim)
424
+ } catch (error) {
425
+ if (!isProbeError(error)) throw error
426
+ if (error.origin === 'claimant') console.log('repair the claim', error.code, error.message)
427
+ if (error.origin === 'workspace') console.log('repair the workspace', error.code, error.message)
428
+ if (error.origin === 'instrument') console.log('report a probe defect', error.code, error.message)
429
+ if (error.code === 'deadline') console.log(error.context?.stage, error.context?.deadline)
430
+ }
431
+ ```
432
+
433
+ `code` names the repair rather than the party: `refused` changes the value a guard rejected before
434
+ work started, `missing` creates or installs the named thing, `malformed` repairs a value that exists
435
+ and does not match the contract it is read against, `destroyed` builds a replacement because
436
+ teardown is permanent, and `deadline` changes the budget or the work it bounds. Neither axis is
437
+ derivable from the other. These are the pairs this package raises:
438
+
439
+ | Party | Code | Raised when |
440
+ | ------------ | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
441
+ | `claimant` | `refused` | An input is rejected: a path escaping the workspace, a caller-supplied name the host refuses to inspect or create — an overlong component and one carrying a NUL byte are the shapes this package meets — a claim the tool guard rejects, a control repeating the case's candidate drafts and test byte for byte, a candidate naming no scoped project, a caller-named project whose diagnostic names no file, or a lint inspection its caller supplied no bound for. |
442
+ | `claimant` | `missing` | The declared test path names no configured Vitest project, or names one the root configuration does not define. |
443
+ | `claimant` | `destroyed` | A probe, a server, or a stage is used after its `destroy`. |
444
+ | `claimant` | `deadline` | `ProbeOptions.deadline` expired while the stage was performing claimant-owned work, so the claim outran the budget. That stage was replaced before the next inspection began. The lint stage raises the same pair on its own when the bound its caller supplied stops a diagnostics wait, and a coordinator that armed that bound replaces the pair with its own refusal. |
445
+ | `workspace` | `refused` | A mutation path crosses a symbolic link in the target tree. |
446
+ | `workspace` | `missing` | The target tree does not install a tool probe resolves from it, or publishes no binary under that tool's name. |
447
+ | `workspace` | `malformed` | The target tree publishes something probe cannot read: an unparsable manifest, a `bin` entry that is not a path, a TypeScript project its own compiler refuses, an unsupported tool version, a mutation path whose existing components cannot be inspected, or a directory it blocks probe from creating for the boot workbench. |
448
+ | `instrument` | `malformed` | probe's own tooling could not serve: a boot control that did not report red, a language server frame it could not parse, a schema or verdict of its own it could not validate. |
449
+ | `instrument` | `deadline` | A stage was not performing claimant-owned work when its budget expired, or a language server did not answer its teardown exchange within the stage's own bound. |
450
+
451
+ A TypeScript project whose JSON the compiler cannot parse reaches you as that `workspace` and
452
+ `malformed` pair, carrying the compiler's own diagnostic, the stage, and the project you named in
453
+ `context`, rather than as the compiler's internal assertion.
454
+
455
+ An `instrument` failure carries the same meaning it carries on `Issue.origin`: the inspection did
456
+ not complete, so nothing was learned about the code. Do not read it as evidence about a candidate.
457
+
458
+ **The pure leaves are total over a validated claim and not over every value.** `computeDigest`,
459
+ `normalizeValue`, and the other exported leaves are written for the values a `Claim` the guards
460
+ admit can carry, so a caller that hands one a value `isClaim` would refuse — a cyclic record, a
461
+ `BigInt` — gets the host's own `RangeError` or `TypeError` unchanged rather than a `ProbeError`.
462
+ Measured on 2026-08-20: `computeDigest(workspace, cyclic)` raises
463
+ `RangeError: Maximum call stack size exceeded`, and `computeDigest(workspace, { n: 1n })` raises
464
+ `TypeError: Do not know how to serialize a BigInt`. Validate with `isClaim` before you reach past
465
+ `prove` into a leaf.
466
+
467
+ `isProbeError` reads a global brand rather than the constructor, so it admits a failure raised by a
468
+ second copy of this package — a duplicate installation, or the ESM and CommonJS builds loaded
469
+ together — where `instanceof` refuses a failure the other copy raised.
470
+
471
+ ## Prerequisites
472
+
473
+ probe borrows the target workspace's own toolchain and configuration, so a workspace missing any of
474
+ these returns a failure the caller cannot diagnose from the verdict alone. Check them before you
475
+ make a claim. A direct `Probe` runs its boot controls at construction. `ProbeServer` leaves discovery
476
+ independent of the workspace toolchain and runs those controls when an admitted `prove` call
477
+ constructs the real probe.
478
+
479
+ - **A Vitest project whose name the test's path infers.** A test under `tmp/probe/` names the
480
+ `probe` project, and a test under `tests/src/<environment>/` names `src:<environment>`. Any other
481
+ path infers no project, and the runtime stage throws `origin: 'claimant'`, `code: 'missing'`, and
482
+ the declared path in `context` rather than reporting an issue:
483
+ `The runtime stage found no configured Vitest project matching the test path`. A path that infers
484
+ a name the root configuration does not define is refused the same way, with
485
+ `The runtime stage found no configured Vitest project named <name>`. A test that never ran is not
486
+ evidence about the candidate, which is why this is a throw.
487
+ - **That project is composed in the root configuration, not declared as a path string.** The
488
+ runtime stage installs its own overlay plugin into each project's configuration so the candidate
489
+ drafts resolve from memory. A project the root configuration names by path carries no such
490
+ plugin, so the stage installs no overlay and runs no test. The check reports an
491
+ `origin: 'workspace'` issue, because the party that must act is the workspace owner editing
492
+ `vite.config.ts`:
493
+ `The runtime stage cannot instrument the string-declared Vitest project <name> because its configuration carries no runtime overlay plugin`.
494
+ The project's `include` pattern is not the mechanism: the stage builds an explicit specification
495
+ for the file it wrote, so a project whose glob matches nothing still serves a claim.
496
+ - **The directory the declared test path names can be created.** The runtime stage writes a real
497
+ file beside the declared test and creates that file's directory first, recursively, so a claim
498
+ naming a directory the workspace does not hold still runs. A fresh clone holds no `tmp/probe/`,
499
+ because `tmp` is ignored by version control, and a claim declaring a test there creates it. A
500
+ directory the host refuses to create — a file already occupies the path, or its parent denies
501
+ writing — reports an `origin: 'workspace'` issue:
502
+ `The runtime stage could not write the generated specification (<reason>)`, where `<reason>` is
503
+ the host's own `mkdir` failure.
504
+ - **A root `tsconfig.json` that resolves at least one input.** The type stage checks the claim's
505
+ test file against the root project, because a test needs the Vitest and Node globals the scoped
506
+ projects remove.
507
+ - **Every project the warm builds must resolve.** The type stage's warm builds incremental state
508
+ for the root `tsconfig.json` and each `configs/src/tsconfig.<name>.json` and
509
+ `configs/app/tsconfig.<name>.json` present, because every inspection awaits that warm. A project
510
+ the compiler refuses raises `origin: 'workspace'`, `code: 'malformed'` naming the project, for
511
+ every claim whichever project it names.
512
+ - **The workspace's `typescript`, `oxlint`, and `vitest` are the same resolved files probe
513
+ resolves.** probe reads each of them from the target workspace's `package.json`, never from its
514
+ own dependencies, and reports the resolved versions on `Verdict.toolchain`. A verdict predicts
515
+ the gate only while probe and the gate read one installed copy of each tool.
516
+
517
+ Declare `@orkestrel/probe` as a development dependency of the workspace it inspects. Its tools are
518
+ optional peers, resolved from that workspace when a direct probe is constructed or a server admits
519
+ a `prove` call.
520
+
521
+ ## Registering the server
522
+
523
+ The package installs a `probe` binary and publishes the `prove` Model Context Protocol tool over a
524
+ stdio transport. Register the resolved JavaScript entry and run it with the harness's own Node:
525
+
526
+ ```json
527
+ {
528
+ "mcpServers": {
529
+ "probe": {
530
+ "command": "node",
531
+ "args": ["node_modules/@orkestrel/probe/dist/bin/main.js"],
532
+ "cwd": "/srv/checkout"
533
+ }
534
+ }
535
+ }
536
+ ```
537
+
538
+ Register that entry rather than a global install, an `npx` invocation, or the `node_modules/.bin`
539
+ shim. The shim is a shell script on POSIX hosts and a batch file on Windows, and spawning the
540
+ JavaScript entry with the current executable is the form that survives both.
541
+
542
+ Workspace prerequisite failures from an admitted `prove` call return as an `isError: true` tool
543
+ result. The server keeps serving discovery and later calls, and a later admitted call retries failed
544
+ construction or workspace arming. A failure thrown before the entry creates and starts its server is
545
+ caught, written to stderr in the form `[origin] code: message`, and exits with status 1. Probe
546
+ construction happens only after server startup, so no public input is known to reach that pre-start
547
+ catch and its runtime behavior remains unproved.
548
+
549
+ These facts decide whether a hand-written client works, and each fails silently when it is wrong:
550
+
551
+ - **The transport is newline-delimited JSON.** One JSON-RPC message per line, terminated by `\n`. A
552
+ client that frames requests with `Content-Length` headers — the way a Language Server Protocol
553
+ client does — gets no reply and no error. That framing is correct for this package's _lint stage_,
554
+ which speaks the Language Server Protocol to Oxlint, and wrong for its _server_. Both live in this
555
+ one package, which is how the mistake gets made.
556
+ - **A current-revision request carries reserved `_meta` keys.**
557
+ `io.modelcontextprotocol/protocolVersion` and `io.modelcontextprotocol/clientCapabilities` are
558
+ both required; `io.modelcontextprotocol/clientInfo` is optional and must be a valid identity when
559
+ present. A request carrying the version alone is refused with
560
+ `-32602 Invalid params: malformed modern request metadata`, which reads like a server defect and
561
+ is not. A version the server does not implement is refused differently, with
562
+ `-32022 Unsupported protocol version` and a `data.supported` list; measured on 2026-08-20 that
563
+ list is `2026-07-28`, `2025-11-25`, and `2025-06-18`.
564
+
565
+ The server answers the handshake era and the current revision together, so a client that sends
566
+ `initialize` without `_meta` is served too. `ProbeServer` snapshots the supplied `ProbeOptions` and
567
+ resolves the default or relative workspace when the server is constructed. It creates and caches a
568
+ real probe only after a structurally valid, contained `prove` call is admitted. Concurrent admitted
569
+ calls share its held construction and the probe it produces. `start()` seizes this process's standard
570
+ input and output: a host that starts the server has given the process to it. `destroy()` gives the
571
+ process back and tears down the probe when a call created it. Teardown entered through a construction
572
+ callback waits for that admitted construction and releases its probe. Destruction before an admitted
573
+ call does not arm the workspace.
574
+
575
+ A handshake-era `tools/call` may carry `_meta.progressToken`, including `0` or an empty string.
576
+ The token does not change the verdict or its receipt. Probe emits no progress reports of its own;
577
+ the call still completes with its result when the claim finishes.
578
+
579
+ **A successful `tools/call` answers with the `Verdict` record and its rendered text together.** The
580
+ result carries the record in `structuredContent` and a single `content` entry of `type: 'text'`
581
+ whose text is what `formatVerdict` rendered: the identity, claim, toolchain, project, and reason
582
+ lines, then every case and control stage, then the closing line. That closing line is where the
583
+ receipt lives in the text block, spelled `receipt <token>` when the claim proved itself and
584
+ `no receipt` when it did not, so a client reading the text block reads the outcome from its last
585
+ line rather than inferring it from the rest. A client reading the record reads `verdict.receipt`
586
+ instead: it carries the token itself, and it is absent when the claim was not proven.
587
+ `structuredContent` is the record `prove` returns in this process, unchanged, so a client reads
588
+ `verdict.id`, `verdict.digest`, `verdict.receipt`, and the per-stage `elapsed` values as data
589
+ rather than off the prose. The `@orkestrel/mcp` client's outcome carries that record alone and
590
+ drops the content blocks, so a caller of that client who wants the rendered form calls
591
+ `formatVerdict` from `@orkestrel/probe` on the record, or reads the text block off the raw wire.
592
+
593
+ **The text block carries `formatVerdict`'s prose rather than the record's serialized JSON, which
594
+ departs from the specification's recommendation.** The tools specification recommends that a tool
595
+ returning structured content also return the serialized JSON in a text content block. The receipt's
596
+ closing line has to stay quotable verbatim, so the text block keeps the rendered form and the record
597
+ travels beside it in `structuredContent`. A client that wants the serialized JSON serializes
598
+ `structuredContent` itself.
599
+
600
+ **The server publishes a key bound above the `@orkestrel/mcp` default, because a verdict's breadth
601
+ is the claimant's.** That package bounds a produced tool-call result by its total enumerable key
602
+ count as well as by its bytes, and applies one bound to inbound metadata and to produced tool
603
+ content alike; its default leaf is sized for metadata. Measured on 2026-08-27 against
604
+ `@orkestrel/mcp` 0.0.25, over the `Verdict` shape this page documents, a verdict costs 38 keys with
605
+ no issues and 11 more for each issue a stage reports, so under the default a verdict whose control
606
+ refuses one declaration travels and the next
607
+ one does not — and it fails by replacing the whole answer with `-32603 Server execution returned an
608
+ invalid tool result` rather than by dropping the record alone. A whole result carrying the record
609
+ beside its rendering costs 44 keys plus 11 for each issue, so the published bound of 4096 keys
610
+ carries a record reporting up to 368 issues. That bound also widens the inbound `_meta` key bound,
611
+ deliberately: bytes and depth still bind there, the 16 KiB metadata byte limit is unchanged, and
612
+ the process on the other end of a stdio transport is the harness that spawned this one.
613
+
614
+ **The reply falls back rather than failing, and the receipt answers at every size.** The server
615
+ admits each answer against those bounds before returning it and takes the widest one they admit.
616
+ Past 368 issues the record is refused and the result carries the rendered text alone. Past the
617
+ 4 MiB content bound the rendered text is refused too, and the result carries what `formatReceipt`
618
+ renders instead: the identity and claim lines, the reason when present, and the same closing
619
+ receipt line. A client therefore reads the outcome off the last line of the text block whichever
620
+ answer arrived.
621
+
622
+ **The `@orkestrel/mcp` stdio client drives claims through this entry.** It spawns the shipped
623
+ `dist/bin/main.js`, negotiates the era itself, lists `prove`, and hands back the record described
624
+ earlier. [`main.test.ts`](../tests/src/bin/main.test.ts) runs that round trip against the built entry
625
+ on the current revision and through the legacy projection. A real Codex 0.153.4 app-server in the
626
+ Scaffold workspace also started this entry and discovered `prove` in 575.8829 ms under its 30-second
627
+ deadline. That reading stopped at discovery: Codex did not call `prove`, and the entry was not read
628
+ from an installed tarball. Treat Codex claim execution and installed-package use as untested. The
629
+ transport facts stated earlier were established against this repository's own hand-written line
630
+ client, and the driven MCP client meets them too.
631
+
632
+ **The advertised schema is wider than the admission rule, at `Draft.path`.** The `prove` tool
633
+ publishes `compileSchema(CLAIM_SHAPE)` and admits a call with `isClaim`, and the two agree on every
634
+ member but `Draft.path`: the schema constrains it to a non-empty string, while the guard also
635
+ refuses an absolute path and one that traverses out of the workspace. No JSON Schema keyword
636
+ expresses that rule, so a claim naming `../../etc/hosts` satisfies the advertised parameters and is
637
+ refused. The refusal names the members it read — `The prove tool refuses case.files.0.path: a draft
638
+ path must stay inside the workspace, which the advertised schema does not constrain` — so a client
639
+ that satisfied the schema is told which path to change rather than that its claim was invalid.
640
+
641
+ ## The claim that earns a receipt
642
+
643
+ This claim earns a receipt in this workspace. Run it verbatim.
644
+
645
+ ```ts
646
+ import type { Claim } from '@orkestrel/probe'
647
+ import { Probe } from '@orkestrel/probe/server'
648
+
649
+ const claim: Claim = {
650
+ project: 'configs/src/tsconfig.core.json',
651
+ case: {
652
+ files: [
653
+ {
654
+ path: 'src/core/factories.ts',
655
+ text: "export function createGreeting(): string {\n\treturn 'hi'\n}\n",
656
+ },
657
+ ],
658
+ test: {
659
+ path: 'tmp/probe/greeting.test.ts',
660
+ text: "import { expect, test } from 'vitest'\nimport { createGreeting } from '../../src/core/factories.js'\ntest('greets', () => expect(createGreeting()).toBe('hi'))\n",
661
+ },
662
+ },
663
+ control: {
664
+ files: [
665
+ {
666
+ path: 'src/core/factories.ts',
667
+ text: "export function createGreeting(): number {\n\treturn 'hi'\n}\n",
668
+ },
669
+ ],
670
+ test: {
671
+ path: 'tmp/probe/greeting.test.ts',
672
+ text: "import { expect, test } from 'vitest'\nimport { createGreeting } from '../../src/core/factories.js'\ntest('greets', () => expect(createGreeting()).toBe('hi'))\n",
673
+ },
674
+ stage: 'type',
675
+ reason: 'a string returned as a number must not compile',
676
+ },
677
+ }
678
+
679
+ const probe = new Probe({ workspace: process.cwd() })
680
+ const verdict = await probe.prove(claim)
681
+ verdict.digest // 'fcb88a2dee987b8673c1fc7107979470'
682
+ verdict.receipt // 'probe:fcb88a2dee987b8673c1fc7107979470:type:typescript@6.0.3:oxlint@1.83.0:vitest@4.1.11:configs/src/tsconfig.core.json@434f59254d58cf2683d453a26bd0d837'
683
+ await probe.destroy()
684
+ ```
685
+
686
+ To inspect only the runtime stage, pass the case from the preceding claim directly to `RuntimeStage`.
687
+ This runs no type or lint stage and does not issue a `Probe` receipt.
688
+
689
+ ```ts
690
+ import { RuntimeStage } from '@orkestrel/probe/server'
691
+
692
+ const runtime = new RuntimeStage(process.cwd())
693
+ try {
694
+ const check = await runtime.inspect(claim.case)
695
+ check.stage // 'runtime'
696
+ check.issues // []
697
+ } finally {
698
+ await runtime.destroy()
699
+ }
700
+ ```
701
+
702
+ The full Probe example depends on these details:
703
+
704
+ - **The candidate file lives under `src/`.** It is checked against `configs/src/tsconfig.core.json`,
705
+ the same scoped project the workspace's own `check:src:core` script runs.
706
+ - **The candidate satisfies the target workspace's own lint rules.** The case runs through the lint
707
+ stage as well as the type stage, so a candidate the workspace's Oxlint configuration refuses earns
708
+ no receipt however cleanly it compiles. This repository's policy plugin admits a module function
709
+ only in a function-kind file, which is why the candidate is `src/core/factories.ts` rather than a
710
+ name of its own. Read the target's rules before you choose a candidate path.
711
+ - **The control differs from the case.** `prove` compares the control against the case byte for
712
+ byte — every candidate draft, paired by position, and the test — and refuses a control that
713
+ repeats all of them, with `origin: 'claimant'` and `code: 'refused'`, before any stage inspects
714
+ the claim. A control byte-identical to its case cannot break, so it never produces the
715
+ `origin: 'claimant'` issue a receipt requires, and the only receipt it could earn is one
716
+ nondeterminism minted for a falsification that never happened. The comparison reads the bytes, so
717
+ varying the control's `stage` or its `reason` alone does not admit it.
718
+ - **The test imports the candidate through a relative specifier.** The runtime stage serves the
719
+ candidate's text at the path the claim declared, so `../../src/core/factories.js` resolves to the
720
+ supplied text rather than to a file on disk.
721
+ - **The test imports `test` and `expect` from `vitest`, and asserts.** A bare `test(...)` fails at
722
+ runtime with `test is not defined`, and at a version-controlled path a body that asserts nothing
723
+ adds the lint issue `Test has no assertions`. Both are charged to the claim.
724
+
725
+ This claim carries no absolute string, so `verdict.digest` is the same in any workspace that runs
726
+ it. Change the control's `reason` and the digest changes with it, because the reason is part of the
727
+ control the digest covers. The tool versions and the project digest in the receipt are this
728
+ workspace's, and `tests/guides.test.ts` re-runs this claim and asserts the token this page carries.
729
+
730
+ ## Reading a receipt
731
+
732
+ A receipt is a `RECEIPT_SEPARATOR`-separated token with this grammar:
733
+
734
+ ```text
735
+ <prefix>:<digest>:<stage>:typescript@<version>:oxlint@<version>:vitest@<version>:<project>@<options>
736
+ ```
737
+
738
+ - `<prefix>` is the value of `RECEIPT_PREFIX`, so a token found away from its verdict names itself.
739
+ - `<digest>` is the claim digest: the case and the control the verdict answered, read against this
740
+ workspace.
741
+ - `<stage>` is the stage the control declared and broke at.
742
+ - A tool field per resolved tool follows, spelled `<name>@<version>`, in the order `typescript`,
743
+ `oxlint`, `vitest`.
744
+ - `<project>@<options>` closes the token: the workspace-relative TypeScript project that judged the
745
+ candidates, and the digest of the compiler options it resolved to.
746
+
747
+ **Parse the project field as the remainder, not as another split.** A workspace-relative project
748
+ path may contain `:` and `@`. Split on `RECEIPT_SEPARATOR`, read the prefix, the digest, the stage,
749
+ and the tool fields as one field each, rejoin everything after the `vitest` field with that
750
+ separator, and read `<options>` as everything after that remainder's closing `@`. That rule stays
751
+ total for a project path containing either character.
752
+
753
+ The call's identity is deliberately absent from the token. It carries no integrity, and it is the
754
+ only value that would stop two honest runs of one claim from producing one comparable string.
755
+
756
+ **Verify a receipt by recomputation, or by re-running the claim.** Recompute it, holding the claim
757
+ and the workspace, by reading the digests the verdict carries. Or re-run `prove` over the same claim
758
+ and compare the two strings byte for byte: two runs of one claim in one workspace produce one token.
759
+
760
+ **probe holds no key.** The token is a function of public inputs, so anyone can type a well-formed
761
+ receipt. It is a statement of the conditions a verdict was reached under, not an authenticator, and
762
+ it is worth exactly what the reader's own recomputation is worth.
763
+
764
+ **probe executes caller-supplied test code with the host's privileges.** The runtime stage writes
765
+ the claim's test to a real file in the target workspace and runs it through the workspace's Vitest.
766
+ That code can read and write the checkout, open sockets, and reach the network. A receipt says
767
+ nothing about what the test did while earning it. Each specification runs in its own Vitest worker,
768
+ so one claim's module state, globals, and environment do not reach the next; nothing about that
769
+ worker contains a filesystem write, a loopback bind, or an outbound request.
770
+
771
+ ## What a receipt does not vouch for
772
+
773
+ `Claim.project` is the one configuration input the caller chooses, which is why the receipt records
774
+ its resolved path and the digest of its compiler options. A receipt minted under a permissive
775
+ project names that project, so a reader comparing it against the gate's own project refuses it on
776
+ sight.
777
+
778
+ These configurations remain outside the token, and no receipt vouches for them:
779
+
780
+ - `.oxlintrc.json`, which the lint stage reads;
781
+ - `vite.config.ts`, which the runtime stage reads;
782
+ - the root `tsconfig.json`, against which the claim's test file is checked.
783
+
784
+ These are the same files the workspace's own `lint:check`, `test`, and `check` scripts read. A
785
+ caller that weakens one has defeated the gate itself, and no receipt vouches for a workspace against
786
+ itself.
787
+
788
+ The project digest is the digest of the `compilerOptions` member of `tsc --showConfig -p <project>`,
789
+ read against the mirrored copy of the project with the mirror as the current directory, so the
790
+ digest and the check read one set of files. `computeDigest` canonicalizes the record: a relative
791
+ path the project declares that escapes the workspace root resolves against the mirror's own
792
+ ancestors, and the mirror carries nothing such a path names. It still **moves with the TypeScript
793
+ version**, because a compiler decides which options it resolves and how it spells them. That is
794
+ contained rather than surprising: the token already names `typescript@<version>`, so any policy
795
+ pinning a digest already pins the version.
796
+
797
+ Further limits belong beside those:
798
+
799
+ - **A control need not be a mutation of its case, and probe applies no relatedness rule.** `Control`
800
+ carries its own `files` and `test`, so a caller can pair a clean case with unrelated broken code
801
+ and satisfy every receipt condition. Any approximation of relatedness strict enough to catch that
802
+ pairing also refuses controls this package deliberately admits, so none is applied. `prove`
803
+ refuses only a control repeating the whole case byte for byte, and that refusal answers
804
+ nondeterminism rather than relatedness. **Judging a control against its case is the reader's
805
+ obligation.** The claim digest binds the case and the control together, so the pairing a
806
+ token was minted over is there to be read.
807
+ - **The overlay is the only thing that serves a candidate's bytes to the runtime stage.** A Vite
808
+ filesystem module cache that answered a covered path from disk would run the file the workspace
809
+ holds rather than the candidate the claim supplied. Measured on 2026-08-24, the string
810
+ `fsModuleCache` appears nowhere in the installed `vite@8.2.2` tree, so there is no such option to
811
+ set and none to defeat. The standing guard is the runtime stage's serve detection rather than a
812
+ version pin: a covered module reachable from the generated specification that the overlay never
813
+ served reports an issue, whatever served it instead. See
814
+ [What the runtime overlay serves](#what-the-runtime-overlay-serves).
815
+ - **Write and delete containment does not bound reads.** TypeScript and Oxlint can inspect files
816
+ outside the workspace through a symlinked candidate path, and a contained `Claim.project` can
817
+ reach outside through `extends`, `files`, `include`, or project references. A receipt does not
818
+ vouch that those reads stayed inside the workspace.
819
+
820
+ That split is deliberate rather than accidental, and § What containment reaches states it as a rule
821
+ rather than as a limit on the token.
822
+
823
+ ## What containment reaches
824
+
825
+ Containment is not one rule, and the difference between its rules decides what a hostile claim can
826
+ do.
827
+
828
+ **Every path a claim carries is contained lexically.** `isDraft` refuses an absolute `Draft.path`
829
+ and one that traverses out of the workspace, and `resolveWorkspaceFile` refuses the same shapes
830
+ again when the stage resolves it. `Claim.project` passes the same rule.
831
+
832
+ **A write or a delete is contained physically as well.** Before probe creates a directory, writes a
833
+ generated specification, writes a boot dependency, or unlinks one, it walks the path's existing
834
+ components and refuses a symbolic link at any of them with
835
+ `Path crosses a symbolic link: <path>`; it then refuses any component whose resolved path leaves
836
+ the workspace. The symbolic-link refusal is `origin: 'workspace'`, `code: 'refused'`, because the
837
+ link belongs to the target tree. A native fault while inspecting an existing component is
838
+ `origin: 'workspace'`, `code: 'malformed'`, and retains that fault on `cause`.
839
+
840
+ The walk and the write that follows it are separate calls, so a concurrent process can move a
841
+ component between them. What that reaches is not uniform, and the difference is worth stating
842
+ exactly. **Exclusive creation and final-component removal are closed.** probe creates every file
843
+ it puts in a target with the `wx` flag, which fails rather than following a symbolic link or
844
+ overwriting a file that appeared after the walk, and an unlink names the final component itself
845
+ rather than what it points at. **An overwrite refuses symbolic-link and gone-file swaps.** A boot
846
+ dependency is overwritten through a descriptor opened `O_WRONLY | O_NOFOLLOW` and truncated
847
+ through that descriptor — a Windows host refuses the numeric `O_TRUNC` without `O_CREAT` as
848
+ `EINVAL`, and the descriptor reaches the file the open bound — which fails on a symbolic link
849
+ standing at that component and on a target that has gone since the walk saw it — while
850
+ **hard-link aliasing remains open**: a regular file swapped for a hard link to a
851
+ same-filesystem file outside the workspace passes that flag set, because `O_NOFOLLOW` refuses
852
+ symbolic links, not hard-linked inodes. Where a host's Node build defines no `O_NOFOLLOW`, that
853
+ flag contributes nothing to the flag set and an overwrite there follows a link the walk did not
854
+ see. **A directory component is open.** A directory swapped for a symbolic link after the walk
855
+ redirects the create, and closing that needs a traversal pinned to file descriptors: Node exposes
856
+ `O_NOFOLLOW` and no descriptor-relative call to apply it through, so this package cannot walk and
857
+ write through one set of descriptors. Read physical containment as covering the claim inputs and
858
+ the target tree as the walk inspected it, plus what the closed set holds at the moment probe
859
+ writes or unlinks a final component.
860
+
861
+ **The type stage writes only into its own mirror.** Every candidate draft it reads is written under
862
+ `TYPE_MIRROR`, at the mirrored spelling of the path the draft declared, so a draft never reaches the
863
+ path it names in the target tree and a draft naming a path the workspace does not hold leaves
864
+ nothing behind. The mirror is refreshed from the workspace before each inspection and released with
865
+ the stage, and the mirror is this stage's own tree: where the workspace holds a file at a path a
866
+ draft declares a directory under, the mirrored copy of that file is removed so the draft can be
867
+ written, and the next refresh restores it.
868
+
869
+ **A read is contained lexically only, and that is the reach to plan for.** The mirror carries
870
+ regular files inside the workspace only: a symbolic link is not carried, so a file reached only
871
+ through one is absent from the mirror and the compiler reports what its absence causes, as a
872
+ claimant issue like any other. A candidate path beneath an in-workspace symbolic link resolves to a
873
+ file outside the workspace, and Oxlint, which reads the workspace directly rather than a mirror of
874
+ it, inspects it there. A `Claim.project` that reaches outside the workspace through `extends`,
875
+ `files`, `include`, or project references reaches nothing there: the type stage reads a project
876
+ against the mirror, so an escaping relative path resolves against the mirror's own ancestors, and
877
+ the mirror carries nothing such a path names.
878
+
879
+ Measured on 2026-08-20: with `link` a symbolic link to a directory outside the workspace,
880
+ `isDraft({ path: 'link/secret.ts', text })` returns `true`,
881
+ `resolveWorkspaceFile(workspace, 'link/secret.ts')` returns the contained spelling whose real path
882
+ is outside the workspace, and the mutating form of the same call throws a `ProbeError` carrying
883
+ `origin: 'workspace'`, `code: 'refused'`, and
884
+ `Path crosses a symbolic link: link/secret.ts`. A contained `tsconfig.json` whose `extends` names an
885
+ absolute path outside the workspace resolved to a different options digest from the same project
886
+ without it, so the outside file was read.
887
+
888
+ Read this as the reason the front of this guide gives a probe a workspace and a caller you already
889
+ trust with a shell, rather than as a hole to work around. A caller that can supply a claim can
890
+ already supply test code the runtime stage runs.
891
+
892
+ ## What the lint stage does not see
893
+
894
+ **A path the workspace's version-control ignore excludes is a path the lint stage reports nothing
895
+ for.** Oxlint's language server honours `.gitignore`, and it does so for text supplied from memory
896
+ exactly as it does for a file on disk. The stage reports a clean check, not a skipped one.
897
+
898
+ This reaches the flagship claim stated earlier: its test lives at `tmp/probe/greeting.test.ts`, and
899
+ `tmp` is ignored in this workspace, so the lint stage inspects the candidate `src/core/factories.ts`
900
+ and reports nothing about the test. Measured on 2026-08-20: the same three-line text carrying an
901
+ unused binding and a `debugger` statement returns 0 issues at `tmp/probe/lint-ignored.test.ts` and 2
902
+ issues at `tests/src/core/lint-tracked.test.ts`.
903
+
904
+ `.gitignore` alone causes this: `tmp` appears there and in no other ignore file this workspace
905
+ carries. Put every candidate draft you want linted at a path version control tracks.
906
+
907
+ ## How the lint stage speaks the protocol
908
+
909
+ The lint stage owns the workspace, the candidate's identity, and the projection from a diagnostic to
910
+ an `Issue`. `@orkestrel/lsp` owns everything between them, and the hookup is fixed:
911
+
912
+ - **The transport is `createStdioClientTransport` from `@orkestrel/lsp/server`.** Its command vector
913
+ is the current executable, the entry `resolveWorkspaceBinary` resolves for `oxlint` in the target
914
+ workspace, and `--lsp`; its directory is that workspace. The child is therefore the workspace's
915
+ own installed Oxlint entry rather than a `node_modules/.bin` shim, for the reason the
916
+ **Prerequisites** section earlier gives.
917
+ - **The client is `createLSPClient` from `@orkestrel/lsp`,** over that transport, with the
918
+ workspace's `file://` URL as its `workspace` option and a 2 s `timeout` option. That option bounds
919
+ the `initialize` and `shutdown` exchanges and the destroy-time settlement, and it does not reach
920
+ the diagnostics an inspection waits for. The transport's `grace` option is 1 s, half that
921
+ deadline, so a child that ignores its ending is signalled and released inside the client's own
922
+ wait for the close.
923
+ - **Each candidate reaches the server through the `open` method.** The stage supplies the URL the
924
+ declared path names, the language identifier `inferDocumentLanguage` selects for that path, the
925
+ candidate's text, and the signal its caller supplied, then closes the document. Nothing is written
926
+ to disk. That signal is what bounds the diagnostics wait, so the stage mints no bound of its own
927
+ for it: a second bound would race the caller's, and which one answered would depend on scheduling.
928
+ `Probe` passes the deadline it already armed for the inspection, so one budget covers the wait and
929
+ reports the overrun.
930
+ - **The published span reaches `Issue.range` unconverted.** The client advertises UTF-16 positions
931
+ and the protocol numbers lines and characters from zero, which is the coordinate basis
932
+ `Issue.range` stores, so this stage copies each coordinate rather than adjusting one. The type
933
+ stage lowers the compiler's one-based line and one-based UTF-16 column by one each, and the
934
+ runtime stage lowers a Vitest frame's line by one because that frame numbers from one.
935
+ `formatIssue` is the only place the one-based line a reader opens is derived.
936
+
937
+ Each limit that split produces is the client's decision rather than this package's:
938
+
939
+ - **The client declares the capabilities and selects the diagnostics path.** It advertises UTF-16
940
+ positions, document synchronization, published diagnostics, and pulled diagnostics, then reads the
941
+ server's `initialize` result: a server declaring `diagnosticProvider` is pulled, and every other
942
+ server is awaited for its published notification. The stage declares nothing and selects nothing,
943
+ so a change in that selection reaches this stage through the package rather than through a switch
944
+ here. Measured on 2026-08-26, Oxlint's language server reports version 1.80.0, declares
945
+ `textDocumentSync` with `openClose`, and declares no `diagnosticProvider`, so its diagnostics
946
+ arrive on the published path.
947
+ - **A server that declares no `openClose` synchronization admits no candidate at all.** The client
948
+ refuses the open before any text reaches that server, and the inspection reports the refusal as a
949
+ stage fault.
950
+ - **A published diagnostic the client refuses reaches no `Issue`.** The client validates every
951
+ diagnostic in a notification and drops the whole notification when one fails, so that inspection
952
+ waits out the caller's bound and reports the stop rather than returning a partial answer.
953
+
954
+ ## What the runtime overlay serves
955
+
956
+ The runtime stage does not execute the test the claim declared. It writes that text to a fresh
957
+ sibling file and runs the sibling, and it installs the claim's `Case.files`, and only those, in the
958
+ overlay a Vite plugin reads. The type stage differs: it records the declared test at its declared
959
+ path alongside every candidate draft, and checks the text there. So the file one stage checks and
960
+ the file the other executes are not the same file, and a test that reads its own location sees the
961
+ generated sibling — which is why the runtime stage rewrites that name out of every message it
962
+ reports.
963
+
964
+ **A query is stripped for the lookup and kept for the transform.** The overlay is keyed by path, and
965
+ a Vite id carries its transform selectors after the first `?`. Resolution cuts the id at that
966
+ character, looks the path up in the overlay, and hands the suffix back on the id it returns; loading
967
+ cuts the same way and serves the candidate's text. So `../../src/value.ts?v=123` imports the
968
+ candidate's module and `../../src/value.ts?raw` imports the same candidate's text as a default
969
+ export, and every selector the importer wrote reaches whichever plugin owns it.
970
+
971
+ **A bare specifier is Vite's to resolve.** The overlay's resolver declines a specifier that is
972
+ neither relative nor absolute rather than guessing where the workspace would place it, so Vite
973
+ resolves it under the workspace's own configuration. The overlay's loader still runs first on the id
974
+ that resolution produced, so a bare import landing on a covered path reads the candidate's bytes.
975
+ This package holds no second copy of that resolver.
976
+
977
+ **A covered module served by anything else is reported rather than passed over.** After the run, the
978
+ stage takes each covered path its own loader never served and asks whether the generated
979
+ specification's module graph reaches that path through importers. One that is reachable was served
980
+ by something other than the overlay, and the party follows how far the id travelled. A loader of
981
+ probe's that never received the id means the workspace's own configuration answered first, reported
982
+ as `The workspace configuration served this module before the runtime overlay` with
983
+ `origin: 'workspace'`. A loader of probe's that received the id and did not match it is this
984
+ package's own resolution missing, reported as `The runtime overlay did not resolve this module` with
985
+ `origin: 'instrument'`. Reachability is what bounds the reading to this run: a resident runner keeps
986
+ module nodes from earlier inspections, and membership alone would report a candidate this claim
987
+ never imported.
988
+
989
+ ## Lifecycle
990
+
991
+ A probe has no `start`. Warming begins at construction and `prove` awaits it, because the harness
992
+ owns the process: a restart is a new process rather than a second lifecycle, and a second client is
993
+ a second process with its own stages. `ProbeServer` defers that real probe construction until an
994
+ admitted call. `ProbeServer.start` is the transport's verb rather
995
+ than the probe's — it decides which process reads the stdio, not when the stages warm.
996
+
997
+ - **Arming.** Construction runs boot controls that mutate an imported dependency and refuse
998
+ service unless the type and runtime stages report the change. The `arm` event fires after those
999
+ controls have reported red and the boot's own files are gone. An attempt that rejects fires
1000
+ `error` instead, carrying the arming refusal as the attempt raises it, so a host waiting on `arm`
1001
+ reads the refusal rather than an event that never arrives. The attempt is still retained for
1002
+ retry, so each attempt surfaces its own `error` and no `prove` reports one refusal twice. The
1003
+ controls run under `tmp/probe/` against the root `tsconfig.json`, which is why the Vitest project,
1004
+ its composition in the root configuration, and a `tmp/probe/` the host lets it create gate the
1005
+ boot rather than a claim.
1006
+ - **Freshness.** Every `prove` revalidates before it answers. The runtime stage re-reads each
1007
+ workspace module and invalidates the ones whose contents moved; the type stage refreshes its
1008
+ mirror of the workspace by content digest, copying a file whose contents moved and removing the
1009
+ copy of a file the workspace deleted. A warm service that skipped this would return a confident
1010
+ wrong answer about freshly edited source.
1011
+ - **Configuration is read once per stage, not per claim.** Freshness covers source, and it does not
1012
+ cover the configuration a stage's own tool was built around. The type stage reads
1013
+ `tsc --showConfig` once per project and keys that reading by resolved project path, so a
1014
+ `tsconfig.json` edited after that reading does not change the compiler options the stage applies
1015
+ or the project digest it reports. A project that declares its own `include` still re-expands on
1016
+ every run, because the scratch project each run reads extends the target's own project file; a
1017
+ project that declares neither `include` nor `files` keeps the selection that reading printed.
1018
+ Oxlint's language server and the resident Vitest hold their own configuration the same way. So a
1019
+ receipt is read against the configuration the stage was built around. Destroy the probe and build
1020
+ another after you edit `tsconfig.json`, `.oxlintrc.json`, or `vite.config.ts`.
1021
+ - **A failed warm is not permanent.** The runtime stage holds its resident Vitest in a slot it
1022
+ clears when that warm rejects, so the fault reaches the caller as the target tree's own —
1023
+ `origin: 'workspace'`, `code: 'malformed'`, naming `vite.config.ts` in `context` — rather than
1024
+ being masked by an aging resident runner. The next `inspect` finds the slot empty and warms fresh,
1025
+ reading the configuration again, so a workspace repaired after the failed call serves the call
1026
+ that follows it.
1027
+ One call never loops through a second warm of its own, and no failure leaves the stage permanently
1028
+ refusing. This is a recovery path rather than a reload: a warm that succeeded is kept, so the
1029
+ preceding entry's rule about editing `vite.config.ts` stands.
1030
+ - **Admission.** One queue per stage admits inspections in arrival order, one at a time. The
1031
+ `deadline` covers active work rather than queue wait. Caller-named project resolution shares that
1032
+ order with type inspections, so a resolve never runs partway through one inspection's own
1033
+ candidate checks.
1034
+ - **Expiry.** `ProbeOptions.deadline` is the coordinator's budget for one active stage inspection,
1035
+ and it lives outside the worker because a Vitest `testTimeout` cannot fire while a synchronous
1036
+ loop blocks that worker. An expiry at any stage abandons that stage, replaces it before the next
1037
+ queued inspection begins, and emits `expire` with the claim that expired. A failed boot is
1038
+ replaced the same way: the next claim runs the controls again rather than inheriting a refusal.
1039
+ - **The budget covers the warm.** Every stage's inspection awaits that stage's warm, and the type
1040
+ stage's warm builds each declared project's incremental state before the first inspection answers.
1041
+ So a `deadline` under that warm expires arming rather than any claim, and the probe never arms.
1042
+ Size `deadline` above the § Cost reading for the target tree, and leave room for a contended host.
1043
+ No stage holds the host's loop: the compiler, the language server, and the test runner each work
1044
+ in a child process or a worker, so the deadline fires on time and terminates the work it bounds.
1045
+ - **Revisions.** Each runtime inspection writes its specification at a fresh path and never reuses
1046
+ one, because a resident runner asked to re-run a path it has already seen reports a false pass.
1047
+ One inspection in every 64 also replaces the resident runner, and that inspection costs more than
1048
+ the other 63 — budget `deadline` against that one rather than the common one. That fresh path
1049
+ never reaches a caller: a test that reads its own filename, through `import.meta.url` or through a
1050
+ frame in a failure it raised, reports the path the claim declared, because the stage rewrites the
1051
+ exact basename it generated back to the declared test's basename in every message it reports.
1052
+ - **Teardown.** `destroy()` releases every resident process and is idempotent. It releases the
1053
+ emitter last, and releases it on a teardown that failed too, so a listener registered through
1054
+ `ProbeOptions.on` or through `probe.emitter` receives nothing after teardown settles and
1055
+ `probe.emitter.destroyed` reads true. A refusal a later `prove` raises still reaches the caller
1056
+ that asked for it, and reaches no listener. `ProbeServer.destroy`
1057
+ adds the process itself: it removes the listeners `start` attached — the `data`, `close`, and
1058
+ `error` forwarders on standard input, and the `SIGINT` and `SIGTERM` handlers on the process —
1059
+ and pauses the stream unless `start` found it already flowing, so the
1060
+ event loop drains and the process exits 0 with no explicit exit call. A stream nobody has read yet
1061
+ is neither flowing nor paused, and this server is what sets it flowing, so it is paused. A host
1062
+ already reading its own standard input keeps reading it after the server it embedded is destroyed.
1063
+ - **The server removes only what it added.** Every listener `ProbeServer` attaches is held as a
1064
+ field and removed by reference, so a listener a host registers while the server is serving is
1065
+ still attached and still fires afterwards. Nothing is chosen by being absent from a capture,
1066
+ because a capture cannot tell a listener the server added from one the host added later. The
1067
+ transport is what makes that reachable: it reads a stream the server owns rather than this
1068
+ process's standard input, so its own listeners never land on `process.stdin` and the only
1069
+ listeners the server puts there are the `data`, `close`, and `error` forwarders into that stream.
1070
+ The release-time
1071
+ reader count is load-bearing for the same reason — a host that starts reading standard input
1072
+ while the server is serving keeps its reader and keeps the flow, even though `start` found the
1073
+ stream stopped and would otherwise pause it.
1074
+ - **Stage teardown is bounded, and each stage is bounded by something different.** Every stage
1075
+ abandons the inspections it holds rather than waiting behind one, and what it then waits for
1076
+ differs per stage. The lint stage holds a bound of its own and sets it on the `@orkestrel/lsp`
1077
+ client it drives: 2 s for each lifecycle exchange the Language Server Protocol leaves to the
1078
+ server — the `initialize` reply that warming waits for and the `shutdown` reply that ending waits
1079
+ for. It does not reach the diagnostics an inspection waits for, which the caller's own signal
1080
+ bounds instead, so a tight teardown bound no longer preempts a claim's budget. The transport's
1081
+ cooperative window is half that 2 s, so a server that answers `shutdown` and then ignores `exit`
1082
+ is signalled and released inside the client's own wait for the close, rather than deadlocking
1083
+ `destroy()`. A server that accepts the connection and answers nothing is released the same way.
1084
+ The type stage holds no bound and needs none for its own tools: it terminates the compiler it
1085
+ spawned — by process tree on Windows, where no cooperative signal reaches a child — waits for the
1086
+ warm it started, and then deletes its mirror. The runtime stage holds no bound either, so a
1087
+ `vitest.close()` that never settles is bounded by the coordinator instead: `Probe.destroy` races
1088
+ each stage's teardown against `ProbeOptions.deadline` and proceeds when the budget expires. What
1089
+ an abandoned tool still holds it holds until this process ends, so that bound buys the signal path
1090
+ rather than the resource — `destroy()` settles for a caller that set a budget it can wait for,
1091
+ instead of hanging behind a stage that will not close.
1092
+ - **Termination.** `ProbeServer.start` answers `SIGINT` and `SIGTERM` by destroying the server, and
1093
+ they are the whole set: no evidence names a harness that ends a stdio child any other way, and
1094
+ a configurable set would be a supported way to spell the leak this closes. Another signal arriving
1095
+ during a teardown already running reaches the default disposition and ends the process at once,
1096
+ because teardown releases its handlers before the probe. Measured on 2026-08-20 on the host § Cost
1097
+ names, signal to child exit is 2.2 s to 2.3 s during boot and 50 ms to 59 ms against an armed
1098
+ probe, over 3 runs each. The boot-time figure is the long one because teardown awaits the boot in
1099
+ flight; budget a harness's grace window against it rather than against the warm case.
1100
+ - **The listener race.** Every `createVitest` call installs `SIGINT` and `SIGTERM` handlers that end
1101
+ this process about a millisecond after the signal, which is three orders of magnitude inside the
1102
+ teardown the preceding **Termination** entry measures. The runtime stage removes the handlers its
1103
+ own warm installed, as the call
1104
+ returns and before anything is awaited, so no window exists for a signal to arrive in. Without
1105
+ that, a graceful teardown reads as fixed, passes a manual test, and still leaves its files in the
1106
+ consumer's tree.
1107
+ - **What a killed host leaves.** A host killed without `destroy` — `SIGKILL`, a power loss, a
1108
+ harness that never signals — can leave a generated specification or a boot dependency behind.
1109
+ Every file this package writes into a target carries `probe-<pid>-<uuid>` between its stem and its
1110
+ extension, and the runtime stage deletes such a file at its next warm when the process id leads a
1111
+ process that is gone **and** the file is one this package can attribute. Attribution is what stops
1112
+ the sweep reaching your tree: a generated specification carries your own test text, so probe
1113
+ closes the file with the marker `// @orkestrel/probe generated specification <pid>-<uuid>`, and
1114
+ the sweep requires that marker to name the same revision the file name does. The boot
1115
+ dependencies carry the same marker, so nothing is attributed by its path and nothing under
1116
+ `tmp/probe/` is deleted for sitting there. A file of yours that happens to carry the same name
1117
+ shape is left where it is, wherever it sits, and so is a live neighbour's specification.
1118
+ - **What the type stage leaves.** Its mirror is one directory under `TYPE_MIRROR`, named for the
1119
+ writing host's process id and a fresh UUID, carrying that same marker at `.probe/mirror.txt`.
1120
+ `destroy()` deletes it, and the next stage constructed against that workspace sweeps a mirror
1121
+ whose process id names a host that is gone and whose marker names its own directory. A directory
1122
+ failing any of those reads stays where it is. Nothing the stage writes reaches a path outside
1123
+ `tmp/`, so a target's version-controlled tree never carries a draft.
1124
+
1125
+ ## Cost
1126
+
1127
+ The following measurements decide whether a harness's timeout is right. They were taken on
1128
+ 2026-09-06, over this repository as the target workspace, on
1129
+ Linux 6.18.44 x64 with 4 processors, Node 22.22.2, TypeScript 6.0.3, Oxlint 1.81.0, and Vitest
1130
+ 4.1.11, with other work running beside them. Read them as the shape of the cost on comparable
1131
+ hardware rather than as a figure another host reproduces.
1132
+
1133
+ | What | Measured |
1134
+ | ------------------------------------------------------------------------------ | ---------------- |
1135
+ | Boot: spawning `dist/bin/main.js` to the answered `initialize` | 497 ms to 561 ms |
1136
+ | Admitted valid claim: spawning `dist/bin/main.js` to the answered `tools/call` | 16.2 s to 16.8 s |
1137
+ | Warm `prove` over the flagship claim, client round trip | 4.2 s to 5.7 s |
1138
+ | Type stage: construction to the `arm` event over this repository | 12.2 s |
1139
+ | Type stage: the declared projects warmed together, cold | 4.6 s |
1140
+ | Type stage: the declared projects warmed together again | 2.5 s |
1141
+ | Type stage: warm inspection, the root project and selected scoped project | 2.0 s |
1142
+ | Type stage: `tsc --showConfig` for a project | 90 ms to 115 ms |
1143
+ | Type stage: warm inspection over the fixture target workspace | 0.9 s |
1144
+
1145
+ The type stage runs the workspace's own `tsc` per selected project, so a target's own `check` script
1146
+ is the shape of its cost. Warming builds each declared project's incremental state before the first
1147
+ inspection answers, and every inspection awaits that warm, so `ProbeOptions.deadline` must clear it:
1148
+ over this repository a budget under about 8 s expires arming rather than a claim. The default
1149
+ `PROBE_DEADLINE` of 30,000 ms clears it with room for a contended host.
1150
+
1151
+ The measured admitted valid claim is dominated by deferred arming, which runs its real controls
1152
+ through the stage sequence before the call answers. The call also carries `prove`, so it lands about
1153
+ a warm call after the `arm` event. A client whose timeout is tighter than arming reports a hang that
1154
+ is a wait. Handshake and discovery requests require no workspace toolchain; only an admitted
1155
+ `tools/call` waits on arming.
1156
+
1157
+ `prove` runs the case through every stage and then the control through every stage, in sequence, so
1158
+ one call pays the runtime stage's floor twice. One runtime inspection in every 64 also replaces
1159
+ the resident Vitest runner and costs more than the other 63, so budget a client timeout against that
1160
+ inspection rather than the common one.
1161
+
1162
+ ## Tests
1163
+
1164
+ - [`guides.test.ts`](../tests/guides.test.ts) — this guide's parity directions, and the equality
1165
+ gate: every `Summary` cell against its declaration's description paragraph, the titled
1166
+ `The claim that earns a receipt` fence against the `@example` block of that title (pinned so the
1167
+ titled pair cannot be retired silently), and the README pitch against this guide's tagline. It
1168
+ also runs the flagship fences and asserts what they claim: the claim literal shared with the
1169
+ `Claim` contract, the constants at the values they publish, the failure table against the tuples
1170
+ the package declares, what `verdict.digest` covers, and the flagship claim run for its receipt.
1171
+ - [`helpers.test.ts`](../tests/src/core/helpers.test.ts) — the formatters, the receipt token's
1172
+ conditions, and the generated specification's marker.
1173
+ - [`validators.test.ts`](../tests/src/core/validators.test.ts) — every guard against hostile shapes,
1174
+ including the draft path the advertised schema admits and the guard refuses.
1175
+ - [`errors.test.ts`](../tests/src/core/errors.test.ts) — the failure guard against lookalikes and a
1176
+ duplicate copy of the package, and every failure path a test can drive without a resident tool,
1177
+ driven for real and read for the ownership and the condition it raised.
1178
+ - [`Probe.test.ts`](../tests/src/server/Probe.test.ts) — arming, admission, deadline expiry and
1179
+ stage replacement, and the receipt decision end to end.
1180
+ - [`helpers.test.ts`](../tests/src/server/helpers.test.ts) — the server leaves, including every
1181
+ documented example on this page's helper table and the members a refused claim names.
1182
+ - [`TypeStage.test.ts`](../tests/src/server/stages/TypeStage.test.ts),
1183
+ [`LintStage.test.ts`](../tests/src/server/stages/LintStage.test.ts), and
1184
+ [`RuntimeStage.test.ts`](../tests/src/server/stages/RuntimeStage.test.ts) — the stages against
1185
+ their real tools.
1186
+ - [`ProbeServer.test.ts`](../tests/src/server/ProbeServer.test.ts) — what `start` seizes and what
1187
+ `destroy` gives back, standard input's flow included, what a host attaches while the server is
1188
+ serving, and the key bound the installed package applies to a record-bearing result.
1189
+ - [`Overlay.test.ts`](../tests/src/server/Overlay.test.ts) — the candidate set's identity,
1190
+ containment, and release.
1191
+ - [`parsers.test.ts`](../tests/src/server/parsers.test.ts) — the printed project configuration and
1192
+ the revision identity a sweep reads, against the shapes each refuses.
1193
+ - [`main.test.ts`](../tests/src/bin/main.test.ts) — the shipped entry driven by this repository's
1194
+ own line client and by the `@orkestrel/mcp` stdio client, the record and the rendered text its
1195
+ reply carries on the legacy and the modern era, and the signals delivered to it during boot and
1196
+ in service.
1197
+ - [`distribution.test.ts`](../tests/distribution.test.ts) — the packed package installed outside the
1198
+ repository and driven through its public exports.
1199
+
1200
+ ## See also
1201
+
1202
+ - [`README.md`](README.md) — the guides index.
1203
+ - [`mcp.md`](mcp.md) — the dependency mirror for `@orkestrel/mcp`, whose server and stdio transport
1204
+ carry the `prove` tool.
1205
+ - [`lsp.md`](lsp.md) — the dependency mirror for `@orkestrel/lsp`, whose client and stdio client
1206
+ transport carry the lint stage's conversation with the Oxlint language server.
1207
+ - [`tool.md`](tool.md) — the dependency mirror for `@orkestrel/tool`, whose registry holds it.
1208
+ - [`contract.md`](contract.md) — the dependency mirror for `@orkestrel/contract`, whose shapes
1209
+ compile both the published tool schema and the guards.
1210
+ - [`AGENTS.md`](../AGENTS.md) — the repository's coding and documentation contract.