@orkestrel/scaffold 0.0.66 → 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 +8 -8
  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 +44 -22
  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 +43 -23
  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 +9 -9
@@ -0,0 +1,595 @@
1
+ # Workspace
2
+
3
+ > The virtual file workspace for the `@orkestrel` line: a path-keyed map of immutable files with
4
+ > an editing surface over it, a registry that holds those maps by id under one active selection,
5
+ > and a snapshot store seam that persists them.
6
+
7
+ Every edit — `write`, `prepend`, `append`, `replace`, `move` — mints a new `FileInterface` value
8
+ and puts it back under its path, so a file is a value a caller can hold and compare, never a handle
9
+ that changes underneath it. `Workspace` is the class behind the editing surface and
10
+ `WorkspaceManager` the class behind the registry, which gains lenient `open` and `save` whenever a
11
+ store is supplied. `WorkspaceStoreInterface` is the durability seam: `get`, `set`, and `delete` over
12
+ a `WorkspaceSnapshot`. Everything else in this module is the immutable data those nouns exchange,
13
+ plus the pure functions that derive it. Source: [`src/core`](../src/core). Published through
14
+ `@orkestrel/workspace`.
15
+
16
+ A workspace is not a filesystem. There is no disk, no `node:fs`, no watcher, no synchronization
17
+ lifecycle, and no dirty-state tracking. A path is a key, not a location: `src/main.ts` and
18
+ `notes.md` sit in the same flat map with no directories between them, and nothing outside the
19
+ process can change what the map holds. Durability is a separate seam — `snapshot()` produces a
20
+ plain JSON-serializable value and a store persists it. A store that one day wrote those snapshots
21
+ to disk would be one more implementation of that interface, not a change of identity here.
22
+
23
+ Anyone can drive it. An agent loop, a tool handler, and plain application code are all callers.
24
+
25
+ ## Surface
26
+
27
+ ### Contracts
28
+
29
+ The data shapes, from [`types.ts`](../src/core/types.ts). Every property is readonly, and an
30
+ absent optional field is absent. `WorkspaceInterface`, `WorkspaceManagerInterface`, and
31
+ `WorkspaceStoreInterface` are the behavioral contracts: each one's call-signature members are
32
+ documented under [`## Methods`](#methods), and its readonly data members stay here — `id`,
33
+ `emitter`, and `count` on `WorkspaceInterface`, `count` and `active` on
34
+ `WorkspaceManagerInterface`, and none on `WorkspaceStoreInterface`.
35
+
36
+ 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 `\|`.
37
+
38
+ | Name | Kind | Shape | Summary |
39
+ | --------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
40
+ | `BinaryMIME` | type | `'image/png' \| 'image/jpeg' \| 'image/gif' \| 'image/webp'` | Names the MIME labels a binary `FileContent` arm supports. |
41
+ | `FileContent` | type | `TextContent \| BinaryContent` | Holds a file's immutable content: either text with a language tag or a base64 string with a MIME. The union carries no discriminant field, so a caller narrows it with a guard. |
42
+ | `TextContent` | interface | `{ text, language }` | Holds the text arm of a file's immutable content: a body and its language tag. |
43
+ | `BinaryContent` | interface | `{ base64, mime }` | Holds the binary arm of a file's immutable content: a base64 payload and its MIME. |
44
+ | `FileState` | type | `'created' \| 'modified'` | Names the edit state of an immutable file value: `created` for the first write to a path and `modified` for every later edit of that path. |
45
+ | `FileInput` | interface | `{ path, content, state? }` | Carries the caller-supplied values used to create an immutable file. The byte size and the line count are derived rather than supplied. |
46
+ | `FileInterface` | interface | `{ path, content, state, size, lines }` | Represents an immutable path-addressed file with derived byte and line counts. |
47
+ | `Position` | interface | `{ line, column }` | Locates a 1-based caret inside text. |
48
+ | `Range` | interface | `{ start, end }` | Represents a half-open text span whose start is inclusive and end is exclusive. |
49
+ | `ReadResult` | interface | `{ content, range }` | Carries the content and clamped span returned by a ranged read. |
50
+ | `SearchOptions` | interface | `{ regex?, sensitive?, limit? }` | Configures search and replacement behavior. |
51
+ | `SearchMatch` | interface | `{ path, line, column, length, content }` | Reports one 1-based search hit and the full line that contains it. |
52
+ | `ReplaceResult` | interface | `{ occurrences, files }` | Carries the tallies a replacement produced: the occurrences replaced and the files changed. |
53
+ | `WorkspaceEventMap` | type | `{ write, remove, move, clear }` | Names the events emitted after workspace mutations complete. |
54
+ | `WorkspaceOptions` | interface | `{ id?, on?, error?, seed? }` | Configures a workspace at construction. |
55
+ | `WorkspaceSnapshot` | interface | `{ id, files }` | Represents a workspace's stored state in JSON-serializable form. |
56
+ | `WorkspaceStoreInterface` | interface | `{} plus get, set, delete` | Persists workspace snapshots through an asynchronous point-access contract. |
57
+ | `WorkspaceSnapshotRow` | interface | `{ id, snapshot }` | Represents the database row used to persist one opaque workspace snapshot. |
58
+ | `WorkspaceErrorCode` | type | `'MISSING' \| 'MODALITY' \| 'PATTERN' \| 'RANGE'` | Names the machine-readable failure codes raised by the workspace edit surface. |
59
+ | `WorkspaceInterface` | interface | `{ id, emitter, count } plus file, files, read, has, search, replace, write, prepend, append, move, remove, clear, snapshot, destroy` | Represents a mutable path-keyed editing surface over immutable file values. |
60
+ | `WorkspaceManagerOptions` | interface | `{ on?, error?, store? }` | Configures a workspace registry at construction. |
61
+ | `WorkspaceManagerInterface` | interface | `{ count, active } plus workspace, workspaces, add, switch, open, save, remove, clear` | Represents an insertion-ordered workspace registry with an active selection and optional durability. |
62
+
63
+ ### Constants
64
+
65
+ A `Shape` cell holds the constant's declared type.
66
+
67
+ | Name | Kind | Shape | Summary |
68
+ | --------------------- | ----- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
69
+ | `EXTENSION_LANGUAGES` | const | `Readonly<Record<string, string>>` | Maps file extensions to language tags for text content. The table is frozen, and an extension it does not list falls back to `text` in `inferLanguage`. |
70
+
71
+ ### Errors
72
+
73
+ From [`errors.ts`](../src/core/errors.ts). A refusal is an exception; everything else this
74
+ package can answer, it answers with a value.
75
+
76
+ | Name | Kind | Signature | Summary |
77
+ | ------------------ | -------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
78
+ | `WorkspaceError` | class | `new (code, message, context?)` | Reports an invalid workspace edit or search operation, carrying a `WorkspaceErrorCode` and, when the operation had one, the context it ran under. |
79
+ | `isWorkspaceError` | function | `(value: unknown) => value is WorkspaceError` | Narrows a caught value to a `WorkspaceError`. |
80
+
81
+ ### Helpers
82
+
83
+ The pure leaves, from [`helpers.ts`](../src/core/helpers.ts). Each one is exported and tested
84
+ on its own, and the classes compose them rather than hiding them.
85
+
86
+ | Name | Kind | Signature | Summary |
87
+ | -------------------- | -------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
88
+ | `inferLanguage` | function | `(path: string) => string` | Infers a language tag from the final file extension. |
89
+ | `isText` | function | `(content: FileContent) => content is TextContent` | Determines whether content is the text arm. |
90
+ | `isBinary` | function | `(content: FileContent) => content is BinaryContent` | Determines whether content is the binary arm. |
91
+ | `computeSize` | function | `(content: FileContent) => number` | Computes the byte size of file content. |
92
+ | `countLines` | function | `(content: FileContent) => number` | Counts the lines in file content. |
93
+ | `computeDecodedSize` | function | `(base64: string) => number` | Computes the decoded byte length of a base64 string, arithmetically rather than by decoding it. |
94
+ | `isValidRange` | function | `(range: Range) => boolean` | Determines whether a 1-based range is structurally valid. |
95
+ | `clampPosition` | function | `(text: string, position: Position) => Position` | Clamps a position to text bounds. |
96
+ | `clampRange` | function | `(text: string, range: Range) => Range` | Clamps both positions in a range to text bounds. |
97
+ | `offsetAt` | function | `(text: string, position: Position) => number` | Converts a 1-based position to a zero-based string offset. |
98
+ | `sliceRange` | function | `(text: string, range: Range) => string` | Slices a clamped half-open text range. |
99
+ | `spliceRange` | function | `(text: string, range: Range, replacement: string) => string` | Replaces a clamped half-open text range. |
100
+ | `rangeOf` | function | `(fromLine, fromColumn, toLine, toColumn) => Range` | Assembles a nested range from four 1-based coordinates. |
101
+ | `escapeRegExp` | function | `(value: string) => string` | Escapes regular-expression metacharacters for literal matching. |
102
+
103
+ ### Validators
104
+
105
+ The total guards, from [`validators.ts`](../src/core/validators.ts). Each narrows an `unknown`
106
+ value arriving from outside the process without throwing on a hostile property access.
107
+
108
+ In a guard table a `Shape` cell holds the type the guard narrows to.
109
+
110
+ | Name | Kind | Shape | Summary |
111
+ | --------------------- | -------- | ------------------- | ----------------------------------------------------- |
112
+ | `isFile` | function | `FileInterface` | Narrows an unknown value to an immutable file record. |
113
+ | `isWorkspaceSnapshot` | function | `WorkspaceSnapshot` | Narrows an unknown value to a workspace snapshot. |
114
+
115
+ ### Factories
116
+
117
+ From [`factories.ts`](../src/core/factories.ts) — the constructor-free way to reach every
118
+ class. Each returns the interface, not the class.
119
+
120
+ | Name | Kind | Signature | Summary |
121
+ | ------------------------------ | -------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
122
+ | `createFile` | function | `(input: FileInput) => FileInterface` | Creates an immutable file with derived size and line counts. |
123
+ | `createTextContent` | function | `(text: string, language: string) => TextContent` | Creates the text arm of `FileContent`, returned as `TextContent` rather than as the whole union. |
124
+ | `createBinaryContent` | function | `(base64: string, mime: BinaryMIME) => BinaryContent` | Creates the binary arm of `FileContent`, returned as `BinaryContent` rather than as the whole union. |
125
+ | `createWorkspace` | function | `(options?: WorkspaceOptions) => WorkspaceInterface` | Creates a workspace with the same identity, emitter, and seed options the constructor takes. |
126
+ | `createMemoryWorkspaceStore` | function | `() => WorkspaceStoreInterface` | Creates an in-memory workspace snapshot store. |
127
+ | `createDatabaseWorkspaceStore` | function | `(driver?: DriverInterface) => WorkspaceStoreInterface` | Creates a database-backed workspace snapshot store, over an in-memory driver when the caller supplies none. |
128
+ | `createWorkspaceManager` | function | `(options?: WorkspaceManagerOptions) => WorkspaceManagerInterface` | Creates an empty workspace registry. |
129
+
130
+ ### Classes
131
+
132
+ The implementing classes, from [`Workspace.ts`](../src/core/workspaces/Workspace.ts),
133
+ [`WorkspaceManager.ts`](../src/core/workspaces/WorkspaceManager.ts),
134
+ [`MemoryWorkspaceStore.ts`](../src/core/workspaces/stores/MemoryWorkspaceStore.ts), and
135
+ [`DatabaseWorkspaceStore.ts`](../src/core/workspaces/stores/DatabaseWorkspaceStore.ts) — each
136
+ documented in full under its own heading following this table.
137
+
138
+ | Name | Kind | Summary |
139
+ | ------------------------ | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
140
+ | `Workspace` | class | Implements `WorkspaceInterface` over one insertion-ordered path map the instance owns, projecting fresh arrays on every read. Whole-file edits create text files, ranged edits operate only on existing text files, and binary files remain available through construction-time hydration. Mutations emit after the file map has changed. |
141
+ | `WorkspaceManager` | class | Implements `WorkspaceManagerInterface` over an insertion-ordered id map and one active id the instance owns, resolving `active` through that map on every read and holding no emitter. A supplied store adds lenient snapshot `open` and `save` operations. Event defaults flow into workspaces created through the registry, while observability remains owned by each workspace. |
142
+ | `MemoryWorkspaceStore` | class | Holds workspace snapshots in the current process. |
143
+ | `DatabaseWorkspaceStore` | class | Persists workspace snapshots in a database table. Snapshots occupy one opaque column and are narrowed when read back from the storage boundary. |
144
+
145
+ ### `Workspace`
146
+
147
+ The implementing class of `WorkspaceInterface`, from
148
+ [`Workspace.ts`](../src/core/workspaces/Workspace.ts). One insertion-ordered path map is its
149
+ whole state, and `files()` and `snapshot()` project fresh arrays out of it rather than exposing a
150
+ view. Its constructor takes one optional `WorkspaceOptions` value. The `seed` iterable seats
151
+ pre-built files by each value's `path`, silently and with the last duplicate path winning. That
152
+ seed is the only way a binary file enters a workspace, because the edit surface itself mints text
153
+ and nothing else. See [`## Methods`](#methods) for its public call surface.
154
+
155
+ ### `WorkspaceManager`
156
+
157
+ The implementing class of `WorkspaceManagerInterface`, from
158
+ [`WorkspaceManager.ts`](../src/core/workspaces/WorkspaceManager.ts). It holds an
159
+ insertion-ordered id map plus one active id, and resolves `active` through that map on every read
160
+ so the pointer can never go stale. It owns no emitter: observation belongs to each workspace, and
161
+ the manager only forwards listener defaults into the workspaces it creates. See
162
+ [`## Methods`](#methods) for its public call surface.
163
+
164
+ ### `MemoryWorkspaceStore`
165
+
166
+ The process-local implementation of `WorkspaceStoreInterface`, from
167
+ [`MemoryWorkspaceStore.ts`](../src/core/workspaces/stores/MemoryWorkspaceStore.ts). A plain map
168
+ behind the async contract: snapshots live as long as the process does. See
169
+ [`## Methods`](#methods) for the contract it satisfies.
170
+
171
+ ### `DatabaseWorkspaceStore`
172
+
173
+ The durable implementation of `WorkspaceStoreInterface`, from
174
+ [`DatabaseWorkspaceStore.ts`](../src/core/workspaces/stores/DatabaseWorkspaceStore.ts). It
175
+ writes each snapshot as one opaque column of a `WorkspaceSnapshotRow` through `@orkestrel/database`
176
+ and narrows the column back with `isWorkspaceSnapshot` on the way out, so a row holding anything
177
+ else reads as absent rather than as a broken workspace. How durable it actually is belongs to the
178
+ driver. See [`## Methods`](#methods) for the contract it satisfies.
179
+
180
+ ## Methods
181
+
182
+ The public call-signature members of each behavioral interface, one table per interface. A
183
+ `Summary` cell carries its member's first overload; where a member is overloaded — `read`, `has`,
184
+ `write`, `prepend`, `append`, `move`, and `remove` on `WorkspaceInterface`, and `remove` on
185
+ `WorkspaceManagerInterface` — the `Returns` cell spans the whole set and the sections that follow
186
+ work each form.
187
+
188
+ #### `WorkspaceInterface`
189
+
190
+ | Method | Returns | Summary |
191
+ | ---------- | ------------------------------- | ---------------------------------------------------------------------------------------- |
192
+ | `file` | `FileInterface \| undefined` | Finds the file value stored at one path. |
193
+ | `files` | `readonly FileInterface[]` | Lists every file in insertion order. |
194
+ | `read` | text, `ReadResult`, or a record | Reads a text file whole. |
195
+ | `has` | `boolean` | Checks whether one path is present. |
196
+ | `search` | `readonly SearchMatch[]` | Scans text files for a query, skipping binary content. |
197
+ | `replace` | `ReplaceResult` | Rewrites every match across the text files, skipping binary content. |
198
+ | `write` | `void` | Writes whole text to a path, creating it when absent and retyping a binary path as text. |
199
+ | `prepend` | `void` | Puts text before a path's existing content, treating an absent path as empty text. |
200
+ | `append` | `void` | Puts text after a path's existing content, treating an absent path as empty text. |
201
+ | `move` | `boolean` | Re-keys one file to a new path, keeping the source's insertion slot. |
202
+ | `remove` | `boolean` | Drops one path, leaving an absent path untouched. |
203
+ | `clear` | `void` | Empties the workspace and emits one `clear`, never a burst of per-path removals. |
204
+ | `snapshot` | `WorkspaceSnapshot` | Projects the identity and a flat file list into a serializable value. |
205
+ | `destroy` | `void` | Releases the owned emitter, leaving the editing surface functional and unobserved. |
206
+
207
+ #### `WorkspaceManagerInterface`
208
+
209
+ | Method | Returns | Summary |
210
+ | ------------ | ------------------------------------------ | -------------------------------------------------------------------------------------------- |
211
+ | `workspace` | `WorkspaceInterface \| undefined` | Finds one registered workspace by id. |
212
+ | `workspaces` | `readonly WorkspaceInterface[]` | Lists registered workspaces in insertion order. |
213
+ | `add` | `WorkspaceInterface` | Creates and registers a workspace, activating it when no selection is active yet. |
214
+ | `switch` | `WorkspaceInterface \| undefined` | Re-points the active selection at a registered workspace. |
215
+ | `open` | `Promise<WorkspaceInterface \| undefined>` | Activates a registered workspace, or hydrates one from a stored snapshot on a registry miss. |
216
+ | `save` | `Promise<boolean>` | Persists a registered workspace's snapshot under its own id. |
217
+ | `remove` | `boolean` | Drops one registered workspace and destroys it. |
218
+ | `clear` | `void` | Empties the registry, destroying each workspace and clearing the selection. |
219
+
220
+ #### `WorkspaceStoreInterface`
221
+
222
+ | Method | Returns | Summary |
223
+ | -------- | ----------------------------------------- | -------------------------------------------------------- |
224
+ | `get` | `Promise<WorkspaceSnapshot \| undefined>` | Resolves a snapshot by workspace id. |
225
+ | `set` | `Promise<void>` | Inserts or replaces a snapshot under its own identifier. |
226
+ | `delete` | `Promise<void>` | Deletes a snapshot when present. |
227
+
228
+ ## Files and content
229
+
230
+ A file is a frozen value: a `path`, its `content`, a `state`, and the derived `size` and `lines`.
231
+ Nothing mutates it. An edit replaces the value stored at a path, so a reference taken before an
232
+ edit still describes exactly what was there.
233
+
234
+ `FileContent` is a tagless union — text carries `{ text, language }`, binary carries
235
+ `{ base64, mime }` — and callers narrow it with a guard instead of reading a discriminant that could
236
+ disagree with the payload:
237
+
238
+ ```ts
239
+ import {
240
+ computeSize,
241
+ countLines,
242
+ createBinaryContent,
243
+ createFile,
244
+ createTextContent,
245
+ inferLanguage,
246
+ isBinary,
247
+ isText,
248
+ } from '@orkestrel/workspace'
249
+
250
+ const note = createFile({
251
+ path: 'notes.md',
252
+ content: createTextContent('# Title\nBody', inferLanguage('notes.md')), // 'markdown'
253
+ })
254
+
255
+ note.size // 12 — UTF-8 bytes, through computeSize
256
+ note.lines // 2 — through countLines
257
+ note.state // 'created'
258
+ isText(note.content) // true
259
+
260
+ const icon = createFile({ path: 'icon.png', content: createBinaryContent('AAAA', 'image/png') })
261
+ isBinary(icon.content) // true
262
+ icon.size // 3 — decoded base64 bytes, through computeDecodedSize
263
+ ```
264
+
265
+ Language is inferred once, from the final path extension through `EXTENSION_LANGUAGES`, and an
266
+ unlisted extension resolves to `text`. Re-writing an existing text file keeps the language it
267
+ already had, so a caller that set one deliberately does not lose it to a rename-shaped write.
268
+
269
+ `FileState` names the first write to a path `created` and every later edit of that path
270
+ `modified`. Hydration preserves whichever stored state the file already carries verbatim; it does
271
+ not synthesize provenance. There is no dirty tracking, and `isFile` is
272
+ the total guard for a value arriving from outside this process.
273
+
274
+ ## Editing
275
+
276
+ Editing returns its documented value when the request has meaning and throws only when a text
277
+ operation cannot be applied. Missing lookups and no-op moves or removals answer with values rather
278
+ than exceptions; `MISSING`, `MODALITY`, and `RANGE` identify the edit refusals that follow.
279
+
280
+ A write takes whole text, a clamped range, or a record batch. Prepend and append are the opposite
281
+ ends of the same map.
282
+
283
+ ```ts
284
+ import { createWorkspace, rangeOf } from '@orkestrel/workspace'
285
+
286
+ const workspace = createWorkspace({ id: 'project' })
287
+
288
+ workspace.write('src/main.ts', 'const answer = 41') // created
289
+ workspace.write('src/main.ts', '42', rangeOf(1, 16, 1, 18)) // modified — splices '41' → '42'
290
+ workspace.write({ 'README.md': '# Project', 'src/util.ts': 'export {}' }) // one file per entry
291
+
292
+ workspace.prepend('src/main.ts', '// generated\n')
293
+ workspace.append('src/main.ts', '\n')
294
+ workspace.prepend({ 'README.md': '<!-- header -->\n' })
295
+ ```
296
+
297
+ A whole-file write always succeeds: it creates the path or replaces what is there, and writing a
298
+ string over a binary path deliberately retypes it as text. Prepend and append treat an absent path
299
+ as empty text and create it. Aimed at binary content, though, they throw `MODALITY` — there is no
300
+ sensible text to concatenate onto base64 data.
301
+
302
+ A ranged write is stricter, because it addresses text that must already exist. It refuses an
303
+ absent path with `Cannot splice a range of a missing file: <path>` under `MISSING`, and a binary
304
+ path with `Cannot splice a range of a binary file: <path>` under `MODALITY`. A structurally
305
+ impossible range — inverted, or with a coordinate below one — throws `RANGE`. A range that is merely too
306
+ large is not an error: both endpoints clamp to the text's bounds, so `rangeOf(1, 2, 9, 9)` over
307
+ `'abc'` addresses everything from the second column onward. The pure functions behind that
308
+ behavior are exported and usable on their own: `isValidRange`, `clampPosition`, `clampRange`,
309
+ `offsetAt`, `sliceRange`, and `spliceRange`.
310
+
311
+ Ranges are half-open and 1-based. `rangeOf(1, 1, 1, 6)` covers the first five columns of line one,
312
+ which is the convention every editor position in this package follows.
313
+
314
+ ## Reading and searching
315
+
316
+ Reads are shaped by what the caller asked for, and binary content is quietly absent from the shapes
317
+ that promise text:
318
+
319
+ ```ts
320
+ import { createWorkspace, escapeRegExp, rangeOf } from '@orkestrel/workspace'
321
+
322
+ const workspace = createWorkspace()
323
+ workspace.write({ 'a.ts': 'const x = 1\nconst y = 2', 'b.ts': 'const z = 3' })
324
+
325
+ workspace.file('a.ts') // the frozen FileInterface value, or undefined
326
+ workspace.files() // every file, in insertion order
327
+ workspace.count // 2
328
+
329
+ workspace.read('a.ts') // 'const x = 1\nconst y = 2'
330
+ workspace.read('a.ts', rangeOf(1, 1, 1, 6)) // { content: 'const', range: … }
331
+ workspace.read(['a.ts', 'missing.ts']) // { 'a.ts': … } — absent paths are omitted
332
+ workspace.has('a.ts') // true
333
+ workspace.has(['a.ts', 'b.ts']) // true — every path present
334
+ workspace.has(['missing.ts', 'b.ts']) // false — a batch answers true only when all are present
335
+
336
+ workspace.search('const') // three matches, a.ts before b.ts, line order within each
337
+ workspace.search('[a-z]\\d', { regex: true }) // pattern source instead of literal text
338
+ workspace.search('CONST', { sensitive: false, limit: 2 })
339
+ workspace.replace('const', 'let') // { occurrences: 3, files: 2 }
340
+ escapeRegExp('a.b') // 'a\\.b' — what a literal search does for you
341
+ ```
342
+
343
+ A plain read of a binary path returns `undefined`, exactly as an absent path does; a batch read
344
+ omits both. A ranged read of binary content is the one that throws `MODALITY`, because the caller
345
+ named coordinates that cannot exist. Search and replace skip binary content entirely, so base64
346
+ that happens to spell a query is never a hit.
347
+
348
+ A query is literal by default — `escapeRegExp` neutralizes its metacharacters — and `regex: true`
349
+ passes it through as pattern source instead. `limit` caps hits for `search` and replacements for
350
+ `replace`, counted across files in insertion order. A pattern that will not compile throws
351
+ `PATTERN` rather than silently matching nothing. A zero-width pattern such as `a*` terminates: the
352
+ scan advances past an empty match instead of re-matching the same column.
353
+
354
+ `replace` returns `{ occurrences, files }`: how many occurrences changed and how many files they
355
+ were spread across. It rewrites each changed file once, so a file with four replacements emits one
356
+ `write` event, and a file with no match is never touched.
357
+
358
+ ## Moving, removing, and snapshots
359
+
360
+ ```ts
361
+ import { createWorkspace } from '@orkestrel/workspace'
362
+
363
+ const workspace = createWorkspace({ id: 'project' })
364
+ workspace.write({ 'old.ts': 'body', 'draft.md': 'notes' })
365
+
366
+ workspace.move('old.ts', 'src/new.ts') // true
367
+ workspace.move({ 'draft.md': 'docs/draft.md' }) // true — a mapping batch
368
+ workspace.move('ghost.ts', 'x.ts') // false — nothing to re-key
369
+
370
+ workspace.snapshot() // { id: 'project', files: [ … ] } — plain, serializable
371
+
372
+ workspace.remove('src/new.ts') // true
373
+ workspace.remove(['docs/draft.md', 'ghost.ts']) // false — 'ghost.ts' was never there
374
+ workspace.clear() // empties the workspace and emits clear
375
+ ```
376
+
377
+ A move re-keys a file to a new path and marks the result `modified`; the moved value carries the
378
+ new path, because a file's `path` is part of its value. Rebuilding the map keeps the moved value in
379
+ the source's insertion slot. An occupied target is removed while the source content remains in
380
+ that source slot, so the file count drops by one. A missing source is not a failure either:
381
+ `move` reports `false` and changes nothing. Moving a path to itself is the same exact no-op: it
382
+ returns `false`, preserves the value and order, and emits nothing.
383
+
384
+ `remove` mirrors that leniency, answering with a value rather than throwing over an absent path.
385
+ Each batch form — `has(paths)`, `move(mapping)`, and `remove(paths)` — applies to every entry it
386
+ can and reports `true` only when all of them succeeded, so one absent path turns the batch's answer
387
+ `false` while the present paths still move or drop. An empty batch has no entry that can fail, so
388
+ `has([])`, `move({})`, `remove([])`, and the registry's `remove([])` each report `true` and change
389
+ nothing. `clear()` owns emptying the workspace and sends one canonical `clear` event, never a burst
390
+ of per-path removals.
391
+
392
+ `snapshot()` is the boundary between the live surface and everything durable: an id and a flat file
393
+ list, holding the same frozen values the map holds. Feed those files back through a workspace's
394
+ construction seed and the rebuilt workspace snapshots equal.
395
+
396
+ ## Events
397
+
398
+ Each workspace owns an `EmitterInterface` from `@orkestrel/emitter`, reachable as `emitter` and
399
+ configurable at construction through `on` and `error`. Every event fires after the map has already
400
+ changed, so a listener always observes the settled state.
401
+
402
+ | Event | Payload | Timing |
403
+ | -------- | ------------------ | -------------------------------------------------------------- |
404
+ | `write` | the resulting file | after a write, splice, prepend, append, or changed replacement |
405
+ | `remove` | the removed path | after a path that was present is dropped |
406
+ | `move` | `from, to` | after the map is re-keyed |
407
+ | `clear` | none | after `clear()` |
408
+
409
+ A listener that throws is isolated by the emitter and routed to the workspace's `error` handler:
410
+ the edit still lands and the call that triggered it returns normally. Nothing is emitted for a
411
+ no-op — removing an absent path, or replacing a query that matched nothing — and construction-time
412
+ seeding is silent, because seeding is hydration rather than editing.
413
+
414
+ ## Lifecycle
415
+
416
+ `clear()` resets file state while leaving observation live. `destroy()` is the teardown boundary:
417
+ it releases the owned emitter last, is idempotent, and leaves file operations functional. Writes
418
+ after destruction still land, but the destroyed emitter delivers no further events.
419
+
420
+ ```ts
421
+ import { createWorkspace } from '@orkestrel/workspace'
422
+
423
+ const workspace = createWorkspace()
424
+ workspace.destroy()
425
+ workspace.write('silent.txt', 'still stored') // succeeds without delivering an event
426
+ ```
427
+
428
+ ## The registry
429
+
430
+ A `WorkspaceManager` is a working set with one selection, not a global. Build one per caller, add
431
+ the workspaces that caller needs to reach, and let `active` say which one is current:
432
+
433
+ ```ts
434
+ import { createWorkspaceManager } from '@orkestrel/workspace'
435
+
436
+ const edited: string[] = []
437
+ const manager = createWorkspaceManager({ on: { write: (file) => edited.push(file.path) } })
438
+
439
+ const scratch = manager.add({ id: 'scratch' }) // the first add becomes active
440
+ const review = manager.add({ id: 'review' }) // a later add does not steal the selection
441
+
442
+ manager.count // 2
443
+ manager.active === scratch // true
444
+ manager.workspace('review') === review // the exact registered instance, or undefined
445
+ manager.workspaces() // a fresh readonly array, in insertion order
446
+
447
+ manager.switch('review') // returns the workspace and re-points active
448
+ manager.switch('ghost') // undefined — active is left alone
449
+
450
+ manager.remove('review') // true — and active clears, because the active one went
451
+ manager.remove(['scratch', 'ghost']) // false — 'ghost' was never registered, but 'scratch' still goes
452
+ manager.clear() // empty registry, no selection
453
+ ```
454
+
455
+ Only the first `add` auto-activates; after that the selection moves solely by `switch`, `open`, or
456
+ the removal of whatever it pointed at. Adding an id that already exists replaces the registered
457
+ workspace, and because `active` is resolved through the map on every read, a replaced active id
458
+ resolves to the replacement rather than to a detached instance.
459
+
460
+ Listener defaults given to the manager flow into every workspace it creates, and per-add `on` or
461
+ `error` overrides them outright rather than merging. The same `WorkspaceOptions` shape reaches
462
+ `add`; its `seed` seats pre-built files into the new workspace silently, which is the hydration
463
+ seam `open` uses. `remove` and `clear` destroy each workspace as it leaves the registry. Registered
464
+ workspaces share nothing else: each owns its own files, its own emitter, and its own id.
465
+
466
+ ## Durability
467
+
468
+ A manager without a store is complete and entirely in memory. Supplying one adds `save` and
469
+ `open`, and each is lenient:
470
+
471
+ ```ts
472
+ import {
473
+ createDatabaseWorkspaceStore,
474
+ createMemoryWorkspaceStore,
475
+ createWorkspaceManager,
476
+ isWorkspaceSnapshot,
477
+ } from '@orkestrel/workspace'
478
+
479
+ const store = createMemoryWorkspaceStore()
480
+ const manager = createWorkspaceManager({ store })
481
+
482
+ const project = manager.add({ id: 'project' })
483
+ project.write('src/main.ts', 'const answer = 42')
484
+
485
+ await manager.save('project') // true — the snapshot is now in the store
486
+ await manager.save('ghost') // false — unknown id, nothing written
487
+
488
+ const reader = createWorkspaceManager({ store })
489
+ const opened = await reader.open('project') // hydrated from the snapshot, registered, activated
490
+ opened?.read('src/main.ts') // 'const answer = 42'
491
+ await reader.open('never-saved') // undefined — a miss stays a miss
492
+
493
+ const durable = createDatabaseWorkspaceStore()
494
+ await durable.set(project.snapshot())
495
+ await durable.get('project') // the snapshot, or undefined
496
+ await durable.delete('project')
497
+ isWorkspaceSnapshot(await durable.get('project')) // false — it is gone
498
+ ```
499
+
500
+ `open` consults the registry first: a registered id is activated and returned, without
501
+ touching the store at all. Only a miss reaches the store, and a snapshot that comes back is
502
+ hydrated into a new workspace through the seed, registered, and made active even when the registry
503
+ was not empty. A miss with no store, or a miss the store cannot satisfy, returns `undefined`.
504
+ `save` writes the snapshot under its own id, so saving twice upserts rather than accumulating.
505
+ Removing a workspace from the registry does not delete its stored snapshot; dropping durable state
506
+ is the store's `delete`.
507
+
508
+ `MemoryWorkspaceStore` keeps snapshots in a map for the lifetime of the process — a real store with
509
+ a short memory, useful wherever durability is not the point. `DatabaseWorkspaceStore` writes one
510
+ opaque column through `@orkestrel/database` and narrows it back with `isWorkspaceSnapshot` on read,
511
+ so a row that is not a snapshot reads as absent instead of propagating. Neither store clones what
512
+ it is given, and neither hydrates a live workspace: turning a snapshot back into a `Workspace` is
513
+ the manager's job.
514
+
515
+ Because the seam is `get`, `set`, and `delete` over a plain serializable value, a caller's own
516
+ implementation is a peer of `MemoryWorkspaceStore` and `DatabaseWorkspaceStore`. A store that wrote
517
+ snapshots to disk, to object storage, or to a remote service would satisfy the same contract and
518
+ change nothing about what a workspace is.
519
+
520
+ ## Failures
521
+
522
+ A code describes every refusal, and `WorkspaceError` carries the code plus the context the
523
+ operation had:
524
+
525
+ | Code | Raised by |
526
+ | ---------- | -------------------------------------------------------------------- |
527
+ | `MISSING` | a ranged write aimed at a path the workspace does not hold |
528
+ | `MODALITY` | a text-only operation aimed at binary content |
529
+ | `RANGE` | a ranged write whose range is inverted or has a coordinate below one |
530
+ | `PATTERN` | a search or replacement whose pattern source will not compile |
531
+
532
+ ```ts
533
+ import { createWorkspace, isWorkspaceError } from '@orkestrel/workspace'
534
+
535
+ const workspace = createWorkspace()
536
+
537
+ try {
538
+ workspace.search('(', { regex: true })
539
+ } catch (error) {
540
+ if (isWorkspaceError(error)) error.code // 'PATTERN'
541
+ }
542
+ ```
543
+
544
+ Everything else answers with a value. An absent path reads as `undefined`, a fruitless `remove` or
545
+ `move` reports `false`, an unknown `switch` or `open` returns `undefined`, and a `save` without a
546
+ store returns `false`. A caller drives this package without a try block until it addresses content
547
+ in a way that cannot mean anything.
548
+
549
+ ## Callers
550
+
551
+ The surface is deliberately narrow enough that no caller is special. `@orkestrel/agent` is one
552
+ such caller: an agent loop can keep a manager, treat `active` as the workspace under discussion,
553
+ and render its files into a prompt. That projection is the agent's product decision and lives
554
+ there, not here. `@orkestrel/toolbox` is another caller: a tool handler can expose the same
555
+ operations as callable tools. Plain code can skip both and build a workspace, edit it, and snapshot
556
+ it directly.
557
+
558
+ What a workspace holds is equally open. Files a program generated, documents fetched from
559
+ elsewhere, a scratch space that never becomes anything — the map does not care, because nothing
560
+ here reaches outside the process to check.
561
+
562
+ ## Tests
563
+
564
+ - [`guides.test.ts`](../tests/guides.test.ts) — the `## Surface` ↔ `src/core` bijection over values
565
+ and types, each interface ↔ class method bijection, and the equality gate: every `Summary` cell
566
+ against its declaration's description paragraph, the titled `Files and content` fence against the
567
+ `@example` block of that title (pinned so the titled pair cannot be retired silently), and the
568
+ README pitch against this guide's tagline. It also runs the flagship fences and asserts the
569
+ values their comments claim.
570
+ - [`helpers.test.ts`](../tests/src/core/helpers.test.ts) — content narrowing, sizing, line
571
+ counting, range validity and clamping, offsets, splicing, and escaping.
572
+ - [`factories.test.ts`](../tests/src/core/factories.test.ts) — derived metadata, frozen values,
573
+ and each factory's working instance.
574
+ - [`Workspace.test.ts`](../tests/src/core/workspaces/Workspace.test.ts) — the edit surface end
575
+ to end: state transitions, clamping, the modality matrix over a real binary file, search and
576
+ replace semantics, insertion order, events and listener isolation, and the construction seed.
577
+ - [`WorkspaceManager.test.ts`](../tests/src/core/workspaces/WorkspaceManager.test.ts) —
578
+ registration, the active pointer, listener defaults and overrides, and the store round trip
579
+ through `open` and `save`.
580
+ - [`MemoryWorkspaceStore.test.ts`](../tests/src/core/workspaces/stores/MemoryWorkspaceStore.test.ts)
581
+ and
582
+ [`DatabaseWorkspaceStore.test.ts`](../tests/src/core/workspaces/stores/DatabaseWorkspaceStore.test.ts)
583
+ — one shared store contract battery run against both implementations, plus each one's specific
584
+ path.
585
+
586
+ ## See also
587
+
588
+ - [`README.md`](README.md) — the guides index.
589
+ - [`emitter.md`](emitter.md) — the dependency mirror for `@orkestrel/emitter`, whose isolation
590
+ guarantees back every workspace event.
591
+ - [`database.md`](database.md) — the dependency mirror for `@orkestrel/database`, the table behind
592
+ `DatabaseWorkspaceStore`.
593
+ - [`contract.md`](contract.md) — the dependency mirror for `@orkestrel/contract`, whose total
594
+ guards back the overload narrowing and the storage-boundary guards.
595
+ - [`AGENTS.md`](../AGENTS.md) — the repository's coding and documentation contract.