@orkestrel/scaffold 0.0.100 → 0.0.102

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 (60) hide show
  1. package/dist/agents/skills/orkestrel-dispatch/scripts/brief.js +6 -14
  2. package/dist/agents/skills/orkestrel-dispatch/scripts/cite.js +41 -28
  3. package/dist/agents/skills/orkestrel-dispatch/scripts/helpers.js +5 -12
  4. package/dist/agents/skills/orkestrel-dispatch/scripts/launch.js +25 -13
  5. package/dist/agents/skills/orkestrel-dispatch/scripts/login.js +18 -25
  6. package/dist/agents/skills/orkestrel-dispatch/scripts/result.js +11 -9
  7. package/dist/agents/skills/orkestrel-harden/scripts/discovery.js +233 -232
  8. package/dist/agents/skills/orkestrel-publish/scripts/compare.js +95 -89
  9. package/dist/agents/skills/orkestrel-publish/scripts/pins.js +5 -7
  10. package/dist/agents/skills/orkestrel-publish/scripts/swap.js +1273 -0
  11. package/dist/agents/skills/orkestrel-publish/scripts/wave.js +99 -89
  12. package/dist/agents/skills/orkestrel-publish/scripts/window.js +54 -52
  13. package/dist/agents/skills/orkestrel-scout/scripts/map.js +160 -161
  14. package/dist/bin/main.js +483 -444
  15. package/dist/bin/main.js.map +1 -1
  16. package/dist/host/AGENTS.md +1 -0
  17. package/dist/host/agents/skills/orkestrel-dispatch/SKILL.md +10 -10
  18. package/dist/host/agents/skills/orkestrel-dispatch/scripts/brief.ts +8 -11
  19. package/dist/host/agents/skills/orkestrel-dispatch/scripts/cite.ts +58 -28
  20. package/dist/host/agents/skills/orkestrel-dispatch/scripts/helpers.ts +5 -10
  21. package/dist/host/agents/skills/orkestrel-dispatch/scripts/launch.ts +31 -12
  22. package/dist/host/agents/skills/orkestrel-dispatch/scripts/login.ts +28 -23
  23. package/dist/host/agents/skills/orkestrel-dispatch/scripts/result.ts +12 -10
  24. package/dist/host/agents/skills/orkestrel-harden/scripts/discovery.ts +318 -237
  25. package/dist/host/agents/skills/orkestrel-journey/references/statechart.md +2 -5
  26. package/dist/host/agents/skills/orkestrel-publish/SKILL.md +7 -6
  27. package/dist/host/agents/skills/orkestrel-publish/references/release.md +12 -10
  28. package/dist/host/agents/skills/orkestrel-publish/references/wave.md +12 -5
  29. package/dist/host/agents/skills/orkestrel-publish/scripts/compare.ts +124 -82
  30. package/dist/host/agents/skills/orkestrel-publish/scripts/pins.ts +5 -7
  31. package/dist/host/agents/skills/orkestrel-publish/scripts/swap.ts +1655 -0
  32. package/dist/host/agents/skills/orkestrel-publish/scripts/wave.ts +128 -97
  33. package/dist/host/agents/skills/orkestrel-publish/scripts/window.ts +70 -55
  34. package/dist/host/agents/skills/orkestrel-scout/scripts/map.ts +215 -155
  35. package/dist/host/claude/rules/architecture.md +2 -2
  36. package/dist/host/claude/rules/tests.md +8 -2
  37. package/dist/host/claude/rules/typescript.md +130 -0
  38. package/dist/host/claude/rules/workspace.md +36 -12
  39. package/dist/host/configs/helpers.ts +214 -133
  40. package/dist/host/configs/policy.ts +110 -105
  41. package/dist/host/dotfiles/gitignore +1 -0
  42. package/dist/host/dotfiles/oxlintrc.json +10 -0
  43. package/dist/host/guides/scaffold.md +244 -170
  44. package/dist/host/manifest.json +37 -31
  45. package/dist/host/tests/config.test.ts +1305 -619
  46. package/dist/host/tests/policy.test.ts +19 -0
  47. package/dist/host/tests/setupPolicy.ts +1710 -995
  48. package/dist/src/core/index.cjs +1595 -1686
  49. package/dist/src/core/index.cjs.map +1 -1
  50. package/dist/src/core/index.d.cts +202 -18
  51. package/dist/src/core/index.d.ts +202 -18
  52. package/dist/src/core/index.js +1590 -1687
  53. package/dist/src/core/index.js.map +1 -1
  54. package/dist/src/server/index.cjs +448 -392
  55. package/dist/src/server/index.cjs.map +1 -1
  56. package/dist/src/server/index.d.cts +1182 -1052
  57. package/dist/src/server/index.d.ts +1182 -1052
  58. package/dist/src/server/index.js +447 -395
  59. package/dist/src/server/index.js.map +1 -1
  60. package/package.json +2 -2
@@ -53,6 +53,31 @@ interface AnchorRow {
53
53
  readonly heading: string
54
54
  }
55
55
 
56
+ interface TomlState {
57
+ readonly table: string
58
+ readonly fence: string | undefined
59
+ readonly rows: readonly LineRow[]
60
+ }
61
+
62
+ interface YamlLine {
63
+ readonly line: number
64
+ readonly indent: number
65
+ readonly content: string
66
+ readonly key: string | undefined
67
+ readonly value: string
68
+ }
69
+
70
+ interface YamlHead {
71
+ readonly position: number
72
+ readonly line: YamlLine
73
+ readonly key: string
74
+ }
75
+
76
+ interface YamlState {
77
+ readonly stack: ReadonlyArray<{ readonly indent: number; readonly key: string }>
78
+ readonly heads: readonly YamlHead[]
79
+ }
80
+
56
81
  interface Report {
57
82
  readonly tree?: readonly TreeRow[]
58
83
  readonly headings?: readonly LineRow[]
@@ -93,35 +118,42 @@ function mapTree(paths: readonly string[]): readonly TreeRow[] {
93
118
  .sort((left, right) => left.path.localeCompare(right.path))
94
119
  }
95
120
 
121
+ function scanFences(lines: readonly string[]): readonly boolean[] {
122
+ const fenced: boolean[] = []
123
+ for (const text of lines) fenced.push((fenced.at(-1) ?? false) !== text.startsWith('```'))
124
+ return fenced
125
+ }
126
+
96
127
  function mapHeadings(paths: readonly string[]): readonly LineRow[] {
97
- const rows: LineRow[] = []
98
- for (const path of expandPaths(paths).filter((candidate) => candidate.endsWith('.md'))) {
99
- let fenced = false
100
- readLines(path).forEach((text, index) => {
101
- if (text.startsWith('```')) fenced = !fenced
102
- if (fenced) return
103
- const match = text.match(HEADING)
104
- if (match?.[1] !== undefined && match[2] !== undefined) {
105
- rows.push({ path, line: index + 1, text: `${match[1]} ${match[2]}` })
106
- }
128
+ return expandPaths(paths)
129
+ .filter((candidate) => candidate.endsWith('.md'))
130
+ .flatMap((path) => {
131
+ const lines = readLines(path)
132
+ const fenced = scanFences(lines)
133
+ return lines.flatMap((text, index) => {
134
+ if (fenced[index] === true) return []
135
+ const match = text.match(HEADING)
136
+ return match?.[1] !== undefined && match[2] !== undefined
137
+ ? [{ path, line: index + 1, text: `${match[1]} ${match[2]}` }]
138
+ : []
139
+ })
107
140
  })
108
- }
109
- return rows
110
141
  }
111
142
 
112
143
  function mapExports(paths: readonly string[]): readonly LineRow[] {
113
- const rows: LineRow[] = []
114
- for (const path of expandPaths(paths).filter((candidate) => /\.[cm]?ts$/u.test(candidate))) {
115
- readLines(path).forEach((text, index) => {
116
- const match = text.match(EXPORT)
117
- if (match?.[1] !== undefined && match[2] !== undefined) {
118
- rows.push({ path, line: index + 1, text: `${match[1]} ${match[2]}` })
119
- } else if (/^export\s+\*\s+from\s+/u.test(text)) {
120
- rows.push({ path, line: index + 1, text: text.trim() })
121
- }
122
- })
123
- }
124
- return rows
144
+ return expandPaths(paths)
145
+ .filter((candidate) => /\.[cm]?ts$/u.test(candidate))
146
+ .flatMap((path) =>
147
+ readLines(path).flatMap((text, index) => {
148
+ const match = text.match(EXPORT)
149
+ if (match?.[1] !== undefined && match[2] !== undefined) {
150
+ return [{ path, line: index + 1, text: `${match[1]} ${match[2]}` }]
151
+ }
152
+ return /^export\s+\*\s+from\s+/u.test(text)
153
+ ? [{ path, line: index + 1, text: text.trim() }]
154
+ : []
155
+ }),
156
+ )
125
157
  }
126
158
 
127
159
  function buildMatcher(term: string, ignoreCase: boolean): (text: string) => boolean {
@@ -137,102 +169,129 @@ function mapCensus(
137
169
  const files = expandPaths(roots).filter(isText)
138
170
  return terms.map((term) => {
139
171
  const matches = buildMatcher(term, ignoreCase)
140
- const rows: LineRow[] = []
141
- for (const path of files) {
142
- readLines(path).forEach((text, index) => {
143
- if (matches(text)) rows.push({ path, line: index + 1, text: text.trim().slice(0, 160) })
144
- })
172
+ return {
173
+ term,
174
+ rows: files.flatMap((path) =>
175
+ readLines(path).flatMap((text, index) =>
176
+ matches(text) ? [{ path, line: index + 1, text: text.trim().slice(0, 160) }] : [],
177
+ ),
178
+ ),
145
179
  }
146
- return { term, rows }
147
180
  })
148
181
  }
149
182
 
150
183
  function mapRefs(target: string, roots: readonly string[]): readonly LineRow[] {
151
184
  const needle = normalizeSlashes(target)
152
185
  const name = basename(needle)
153
- const rows: LineRow[] = []
154
- for (const path of expandPaths(roots).filter(isText)) {
155
- if (path === needle) continue
156
- readLines(path).forEach((text, index) => {
157
- if (text.includes(needle) || text.includes(name)) {
158
- rows.push({ path, line: index + 1, text: text.trim().slice(0, 160) })
159
- }
160
- })
161
- }
162
- return rows
186
+ return expandPaths(roots)
187
+ .filter(isText)
188
+ .filter((path) => path !== needle)
189
+ .flatMap((path) =>
190
+ readLines(path).flatMap((text, index) =>
191
+ text.includes(needle) || text.includes(name)
192
+ ? [{ path, line: index + 1, text: text.trim().slice(0, 160) }]
193
+ : [],
194
+ ),
195
+ )
163
196
  }
164
197
 
165
- function readYamlKeys(path: string, lines: readonly string[], offset: number): readonly LineRow[] {
166
- const rows: LineRow[] = []
167
- const stack: Array<{ readonly indent: number; readonly key: string }> = []
168
- lines.forEach((text, index) => {
198
+ function classifyYamlLines(lines: readonly string[], offset: number): readonly YamlLine[] {
199
+ return lines.flatMap((text, index): readonly YamlLine[] => {
169
200
  const content = text.trimStart()
170
- if (content === '' || content.startsWith('#')) return
171
- const indent = text.length - content.length
201
+ if (content === '' || content.startsWith('#')) return []
172
202
  const match = content.match(/^([A-Za-z_][\w-]*)\s*:(?:\s+(.*))?$/u)
173
- if (match?.[1] === undefined) {
174
- // A list item, a block scalar line, or a wrapped value extends the key before it.
175
- const last = rows.at(-1)
176
- if (last !== undefined && indent >= (stack.at(-1)?.indent ?? 0)) {
177
- rows[rows.length - 1] = { ...last, text: `${last.text} ${content.trim()}`.trimEnd() }
178
- }
179
- return
180
- }
181
- while (stack.length > 0 && (stack.at(-1)?.indent ?? 0) >= indent) stack.pop()
182
- const key = [...stack.map((entry) => entry.key), match[1]].join('.')
183
- stack.push({ indent, key: match[1] })
184
- rows.push({ path, line: index + offset, text: `${key}: ${(match[2] ?? '').trim()}`.trimEnd() })
203
+ return [
204
+ {
205
+ line: index + offset,
206
+ indent: text.length - content.length,
207
+ content,
208
+ key: match?.[1],
209
+ value: (match?.[2] ?? '').trim(),
210
+ },
211
+ ]
185
212
  })
186
- return rows
213
+ }
214
+
215
+ function stepYamlState(state: YamlState, line: YamlLine, position: number): YamlState {
216
+ if (line.key === undefined) return state
217
+ // Indents strictly increase along the stack, so the entries at or past this indent are the tail.
218
+ const stack = state.stack.filter((entry) => entry.indent < line.indent)
219
+ const key = [...stack.map((entry) => entry.key), line.key].join('.')
220
+ return {
221
+ stack: [...stack, { indent: line.indent, key: line.key }],
222
+ heads: [...state.heads, { position, line, key }],
223
+ }
224
+ }
225
+
226
+ function readYamlKeys(path: string, lines: readonly string[], offset: number): readonly LineRow[] {
227
+ const classified = classifyYamlLines(lines, offset)
228
+ const { heads } = classified.reduce<YamlState>(
229
+ (state, line, position) => stepYamlState(state, line, position),
230
+ { stack: [], heads: [] },
231
+ )
232
+ // A list item, a block scalar line, or a wrapped value extends the key line before it when it is
233
+ // indented at least as far; between two key lines every classified line is a non-key line.
234
+ return heads.map((head, rank) => ({
235
+ path,
236
+ line: head.line.line,
237
+ text: [
238
+ `${head.key}: ${head.line.value}`.trimEnd(),
239
+ ...classified
240
+ .slice(head.position + 1, heads[rank + 1]?.position ?? classified.length)
241
+ .filter((line) => line.indent >= head.line.indent)
242
+ .map((line) => line.content.trim()),
243
+ ]
244
+ .join(' ')
245
+ .trimEnd(),
246
+ }))
247
+ }
248
+
249
+ function stepTomlState(state: TomlState, text: string, path: string, line: number): TomlState {
250
+ if (state.fence !== undefined) {
251
+ // The body of a multi-line string carries no key.
252
+ return text.includes(state.fence) ? { ...state, fence: undefined } : state
253
+ }
254
+ const header = text.match(/^\s*\[\[?([^\]]+)\]\]?\s*$/u)
255
+ if (header?.[1] !== undefined) return { ...state, table: header[1].trim() }
256
+ const match = text.match(/^\s*([A-Za-z0-9_.-]+|"[^"]+")\s*=\s*(.*)$/u)
257
+ if (match?.[1] === undefined) return state
258
+ const value = (match[2] ?? '').trim()
259
+ const opening = value.match(/^("""|''')/u)?.[1]
260
+ return {
261
+ table: state.table,
262
+ fence: opening !== undefined && value.indexOf(opening, 3) === -1 ? opening : undefined,
263
+ rows: [
264
+ ...state.rows,
265
+ {
266
+ path,
267
+ line,
268
+ text: `${state.table === '' ? '' : `${state.table}.`}${match[1]}: ${value}`.trimEnd(),
269
+ },
270
+ ],
271
+ }
187
272
  }
188
273
 
189
274
  function readTomlKeys(path: string, lines: readonly string[], offset: number): readonly LineRow[] {
190
- const rows: LineRow[] = []
191
- let table = ''
192
- let fence: string | undefined
193
- lines.forEach((text, index) => {
194
- if (fence !== undefined) {
195
- // The body of a multi-line string carries no key.
196
- if (text.includes(fence)) fence = undefined
197
- return
198
- }
199
- const header = text.match(/^\s*\[\[?([^\]]+)\]\]?\s*$/u)
200
- if (header?.[1] !== undefined) {
201
- table = header[1].trim()
202
- return
203
- }
204
- const match = text.match(/^\s*([A-Za-z0-9_.-]+|"[^"]+")\s*=\s*(.*)$/u)
205
- if (match?.[1] === undefined) return
206
- const value = (match[2] ?? '').trim()
207
- const opening = value.match(/^("""|''')/u)?.[1]
208
- if (opening !== undefined && value.indexOf(opening, 3) === -1) fence = opening
209
- rows.push({
210
- path,
211
- line: index + offset,
212
- text: `${table === '' ? '' : `${table}.`}${match[1]}: ${value}`.trimEnd(),
213
- })
214
- })
215
- return rows
275
+ return lines.reduce<TomlState>(
276
+ (state, text, index) => stepTomlState(state, text, path, index + offset),
277
+ { table: '', fence: undefined, rows: [] },
278
+ ).rows
216
279
  }
217
280
 
218
281
  function mapFrontmatter(paths: readonly string[]): readonly LineRow[] {
219
- const rows: LineRow[] = []
220
- for (const path of expandPaths(paths).filter((candidate) =>
221
- /\.(md|mdc|toml|ya?ml)$/u.test(candidate),
222
- )) {
223
- const lines = readLines(path)
224
- const markdown = /\.mdc?$/u.test(path)
225
- const end = markdown ? lines.indexOf('---', 1) : lines.length
226
- if (markdown && (lines[0] !== '---' || end === -1)) continue
227
- const body = lines.slice(markdown ? 1 : 0, end)
228
- const offset = markdown ? 2 : 1
229
- rows.push(
230
- ...(path.endsWith('.toml')
282
+ return expandPaths(paths)
283
+ .filter((candidate) => /\.(md|mdc|toml|ya?ml)$/u.test(candidate))
284
+ .flatMap((path) => {
285
+ const lines = readLines(path)
286
+ const markdown = /\.mdc?$/u.test(path)
287
+ const end = markdown ? lines.indexOf('---', 1) : lines.length
288
+ if (markdown && (lines[0] !== '---' || end === -1)) return []
289
+ const body = lines.slice(markdown ? 1 : 0, end)
290
+ const offset = markdown ? 2 : 1
291
+ return path.endsWith('.toml')
231
292
  ? readTomlKeys(path, body, offset)
232
- : readYamlKeys(path, body, offset)),
233
- )
234
- }
235
- return rows
293
+ : readYamlKeys(path, body, offset)
294
+ })
236
295
  }
237
296
 
238
297
  function readHeadingTexts(path: string): ReadonlySet<string> {
@@ -240,63 +299,64 @@ function readHeadingTexts(path: string): ReadonlySet<string> {
240
299
  }
241
300
 
242
301
  function mapAnchors(paths: readonly string[]): readonly AnchorRow[] {
243
- const headings = new Map<string, ReadonlySet<string>>()
244
- const rows: AnchorRow[] = []
245
- for (const path of expandPaths(paths).filter((candidate) => candidate.endsWith('.md'))) {
246
- readLines(path).forEach((text, index) => {
247
- for (const match of text.matchAll(ANCHOR)) {
248
- const target = match[1] === undefined ? path : normalizeSlashes(match[1])
249
- const heading = (match[2] ?? '').trim()
250
- if (heading === '') continue
251
- if (!existsSync(target)) {
252
- rows.push({ path, line: index + 1, target, heading })
253
- continue
254
- }
255
- const known = headings.get(target) ?? readHeadingTexts(target)
256
- headings.set(target, known)
257
- if (!known.has(heading.toLowerCase())) rows.push({ path, line: index + 1, target, heading })
258
- }
259
- })
260
- }
261
- return rows
302
+ const citations = expandPaths(paths)
303
+ .filter((candidate) => candidate.endsWith('.md'))
304
+ .flatMap((path) =>
305
+ readLines(path).flatMap((text, index) =>
306
+ [...text.matchAll(ANCHOR)].flatMap((match) => {
307
+ const target = match[1] === undefined ? path : normalizeSlashes(match[1])
308
+ const heading = (match[2] ?? '').trim()
309
+ return heading === '' ? [] : [{ path, line: index + 1, target, heading }]
310
+ }),
311
+ ),
312
+ )
313
+ // Each existing target is read once, in the order its first citation names it.
314
+ const headings = new Map(
315
+ [...new Set(citations.map(({ target }) => target).filter((target) => existsSync(target)))].map(
316
+ (target) => [target, readHeadingTexts(target)],
317
+ ),
318
+ )
319
+ return citations.filter(
320
+ ({ target, heading }) => headings.get(target)?.has(heading.toLowerCase()) !== true,
321
+ )
262
322
  }
263
323
 
264
324
  function renderReport(report: Report): string {
265
- const lines: string[] = []
266
- if (report.tree !== undefined) {
267
- lines.push('## Tree')
268
- for (const row of report.tree) lines.push(`${row.path}\t${row.lines} lines\t${row.bytes} bytes`)
269
- lines.push(
270
- `total\t${report.tree.length} files\t${report.tree.reduce((sum, row) => sum + row.lines, 0)} lines`,
271
- )
272
- }
273
- if (report.headings !== undefined) {
274
- lines.push('## Headings')
275
- for (const row of report.headings) lines.push(`${row.path}:${row.line}\t${row.text}`)
276
- }
277
- if (report.frontmatter !== undefined) {
278
- lines.push('## Frontmatter')
279
- for (const row of report.frontmatter) lines.push(`${row.path}:${row.line} ${row.text}`)
280
- }
281
- if (report.exports !== undefined) {
282
- lines.push('## Exports (text reading)')
283
- for (const row of report.exports) lines.push(`${row.path}:${row.line}\t${row.text}`)
284
- }
285
- if (report.census !== undefined) {
286
- for (const entry of report.census) {
287
- lines.push(`## Census: ${entry.term} (${entry.rows.length})`)
288
- for (const row of entry.rows) lines.push(`${row.path}:${row.line}\t${row.text}`)
289
- }
290
- }
291
- if (report.refs !== undefined) {
292
- lines.push(`## Refs (${report.refs.length})`)
293
- for (const row of report.refs) lines.push(`${row.path}:${row.line}\t${row.text}`)
294
- }
295
- if (report.anchors !== undefined) {
296
- lines.push(`## Unresolved anchors (${report.anchors.length})`)
297
- for (const row of report.anchors)
298
- lines.push(`${row.path}:${row.line}\t${row.target} § ${row.heading}`)
299
- }
325
+ const { tree, headings, frontmatter, exports, census, refs, anchors } = report
326
+ const lines = [
327
+ ...(tree === undefined
328
+ ? []
329
+ : [
330
+ '## Tree',
331
+ ...tree.map((row) => `${row.path}\t${row.lines} lines\t${row.bytes} bytes`),
332
+ `total\t${tree.length} files\t${tree.reduce((sum, row) => sum + row.lines, 0)} lines`,
333
+ ]),
334
+ ...(headings === undefined
335
+ ? []
336
+ : ['## Headings', ...headings.map((row) => `${row.path}:${row.line}\t${row.text}`)]),
337
+ ...(frontmatter === undefined
338
+ ? []
339
+ : ['## Frontmatter', ...frontmatter.map((row) => `${row.path}:${row.line}\t${row.text}`)]),
340
+ ...(exports === undefined
341
+ ? []
342
+ : [
343
+ '## Exports (text reading)',
344
+ ...exports.map((row) => `${row.path}:${row.line}\t${row.text}`),
345
+ ]),
346
+ ...(census ?? []).flatMap((entry) => [
347
+ `## Census: ${entry.term} (${entry.rows.length})`,
348
+ ...entry.rows.map((row) => `${row.path}:${row.line}\t${row.text}`),
349
+ ]),
350
+ ...(refs === undefined
351
+ ? []
352
+ : [`## Refs (${refs.length})`, ...refs.map((row) => `${row.path}:${row.line}\t${row.text}`)]),
353
+ ...(anchors === undefined
354
+ ? []
355
+ : [
356
+ `## Unresolved anchors (${anchors.length})`,
357
+ ...anchors.map((row) => `${row.path}:${row.line}\t${row.target} § ${row.heading}`),
358
+ ]),
359
+ ]
300
360
  return `${lines.join('\n')}\n`
301
361
  }
302
362
 
@@ -203,8 +203,8 @@ Store child managers in `#` fields and expose readonly getters typed as their in
203
203
 
204
204
  - Middleware is behavior `(context, next) => ...`, never a class with one `handle` method.
205
205
  - Place middleware factories in `middlewares.ts` as `createX(options): Middleware`; keep them distinct from entity/value factories.
206
- - Keep middleware-local state closure-private.
207
- - If consumers must address, share, inspect, or replace that state, extract the state to a pluggable class supplied as an option, with an in-memory default.
206
+ - Keep the values a middleware never changes, such as its resolved options and what its factory derives from them, closure-private, and apply `.claude/rules/typescript.md` § Bindings to the state one request changes.
207
+ - Keep the state a middleware changes across requests, such as a per-key count, a cache, or a session table, in a pluggable class supplied as an option, with an in-memory default.
208
208
  - Extract reusable cross-middleware machinery to helpers/classes.
209
209
  - `MiddlewareManager` is the sole manager of the middleware composition chain; do not create one manager per middleware.
210
210
 
@@ -22,7 +22,9 @@ paths:
22
22
  resolves to `tests/setup<Name>.ts`. A root `tests/setup<Name>.ts` that declares an export has
23
23
  `tests/setup<Name>.test.ts` or is imported by `tests/setup.test.ts`, which can prove several
24
24
  setup modules when their helpers serve several projects. A vendored module is outside this
25
- population. The policy sweep (`tests/setupPolicy.ts`) enforces both directions.
25
+ population. The policy sweep (`tests/setupPolicy.ts`) enforces both directions. `tests/studies/**`
26
+ is outside the mirrored population: a study test names a package-level subject and mirrors no
27
+ module.
26
28
  - Prefer test filenames matching entrypoints: `index.test.ts` for `index.ts`, `main.test.ts` for `main.ts`.
27
29
  - Tests are deterministic: identical inputs produce identical results.
28
30
  - Keep default suites fast: give each timer the shortest real duration that still separates the orderings the test asserts on a loaded host, and make no network calls.
@@ -57,6 +59,7 @@ its own:
57
59
  | `tests/guides.test.ts` | Every documented API exists, every public API is documented, every compared summary, example, and pitch equals its source, and every executable fence returns what the guide says it returns |
58
60
  | `tests/conformance.test.ts` | Where this package drifts from the official tooling it tracks |
59
61
  | `tests/agents/**/*.test.ts` | Each skill script does what its `SKILL.md` states, driven as a child process against a scratch fixture from its mirrored proof; the scaffold checkout alone carries them |
62
+ | `tests/studies/**/*.test.ts` | Evaluations of the workspace's consumer study code under `studies/`, run through bounded subprocesses and protocol fixtures; the `test:studies` script is their gate |
60
63
  | `tests/distribution.test.ts` | The packed package installs and resolves through its public exports |
61
64
  | `tests/integration.test.ts` | The package's features work together end to end across environments |
62
65
  | `tests/setup*.test.ts` | Reusable behavior exported from sibling `tests/setup*.ts` modules works as the workspace's suites require |
@@ -167,7 +170,7 @@ A test that spawns a process, packs, installs, or drives a real build is a proof
167
170
  - Keep it out of the default run where the workspace has a gate that can hold it.
168
171
  `.claude/rules/workspace.md` fixes which gate each isolated project runs from, and that placement
169
172
  differs between a publishing and a `private: true` workspace.
170
- - The fixed expensive-proof projects are `distribution` and `service`.
173
+ - The fixed expensive-proof projects are `distribution`, `service`, and `studies`.
171
174
  - A `distribution` proof reads `import.meta.env.MODE` and fails, rather than skips, on an unreachable
172
175
  registry under `--mode release`. The publish gate invokes it that way, so a proof that skips there
173
176
  passes the gate without ever proving the artifact installs. An ordinary local run may still skip.
@@ -201,6 +204,9 @@ Place helpers by environment:
201
204
  - `tests/setupBrowser.ts`: DOM/Vue/browser helpers and setup CSS.
202
205
  - `tests/setupStyles.ts`: the CSSOM and sheet helpers `.claude/rules/styles.md` places there; every
203
206
  sheet-face project loads it after `tests/setupBrowser.ts`.
207
+ - `tests/setupStudy.ts`: the harness module the study tests import and, in a study-engine package,
208
+ its source tests too. Name it in no project's `setupFiles`; tests import it, as the policy proof
209
+ imports `tests/setupPolicy.ts`.
204
210
 
205
211
  ### Recorder
206
212
 
@@ -41,6 +41,136 @@ The non-negotiables and design laws in `AGENTS.md` apply without exception and a
41
41
  - Return copies or readonly views; never leak a mutable internal reference.
42
42
  - Compute derived facts instead of persisting duplicate state.
43
43
 
44
+ ## Bindings
45
+
46
+ - **Variables.** Never write a `let` or `var` declaration, at module scope, in a function, in a loop
47
+ head, or in a test; declare each variable with the `const` keyword. Never assign to a parameter or
48
+ a `catch` binding, whether by `=`, by a compound operator such as `+=` or `??=`, by `++` or `--`,
49
+ or as a destructuring target.
50
+ - **Derivation.** Derive a value instead of accumulating it: write the expression that produces it
51
+ (`some`, `every`, `find`, `filter`, `map`, `reduce`, a conditional expression), or extract a
52
+ `{verb}{Noun}` helper that returns it. Build text from its parts with `join` or a template literal,
53
+ never with a fold whose step appends a part to the text it carries.
54
+ - **Iteration.** Iterate with `for…of` over `entries()` or an iterable instead of an index counter.
55
+ - **Try results.** Take a value out of a `try` block through a helper that runs the block and
56
+ returns the value. Treat a log that the block appends to as a log rather than such a value, and
57
+ apply the **Lists and logs** rule to it.
58
+ - **Running values.** Treat a flag, a counter, a sum, a latest value, a one-slot cell, or text built
59
+ piece by piece as a running value. Treat carrying one by changing a record, an array, a `Map`, a
60
+ `Set`, or a recorder as a reassigned binding, whether a variable, a parameter, or a `reduce`
61
+ accumulator holds that collection, directly or through a property: derive the value, or keep it
62
+ where the **Per-call state** or **Entity state** rule places it. Treat a collection that holds at
63
+ most one entry and that code reads by position, such as `cell[0]`, `cell.at(-1)`, or
64
+ `const [entry] = cell`, as a one-slot cell. Treat selecting an entry by iteration order as a
65
+ position read, including an iterator read such as `values().next()` or `keys().next()`, and a key
66
+ obtained that way.
67
+ - **Lists and logs.** Build a list from a list with `map`, `filter`, or `flatMap`, and a lookup with
68
+ `new Map(…)` or `new Set(…)` over a derived list. When a loop awaits or yields between steps, or
69
+ its step depends on earlier steps, append each step's readonly result to a log that the loop owns,
70
+ and derive every aggregate from the log, never from a running value. Apply these conditions whether
71
+ the loop pushes to the log or a `reduce` call returns it:
72
+ - let only the loop's own steps, in order, or the callbacks of one event source, in emission order,
73
+ append to the log, and apply the **Per-call state** rule to a list that concurrent paths append
74
+ to;
75
+ - let a step read the record before it and carry an aggregate computed from that record, such as a
76
+ running total;
77
+ - append each record final: never replace, rewrite, or remove a record the log holds, and never
78
+ append a record whose text extends an earlier record's text;
79
+ - treat a log, or a `Map` keyed by step, whose every read takes its last entry or its length as the
80
+ carrier of a latest value or a counter: derive that value from the source list, return it from a
81
+ bounded recursion, or carry it in a `reduce` accumulator;
82
+ - find a log's reads through its aliases, the calls it is passed to, and the callers it is returned
83
+ to, and never count passing or returning the log as a read of its history.
84
+ - **Exemptions.** Exempt these from the **Running values**, **Lists and logs**, and **Per-call state**
85
+ rules:
86
+ - a traversal's worklist, which is a list, `Set`, or `Map` that a loop visits while its steps add
87
+ the entries left to visit, provided one step can add more than one entry. Treat a loop as a
88
+ traversal only when it reaches each node after its roots as a successor of a node it has already
89
+ reached, such as a child directory, a dependency, an amendment, or a child process. Count as one
90
+ step a complete iteration that removes or visits an entry of the worklist, its nested loops
91
+ included. Treat a worklist that no step can add more than one entry to as a cursor, and return
92
+ what it reaches from a bounded recursion;
93
+ - a traversal's membership set or map, which the traversal reads to avoid revisiting a node or to
94
+ discover the nodes reachable from nodes it has recorded, whether or not it iterates that
95
+ collection, and to which the cursor condition never applies;
96
+ - a memo, which is a `Map` that caches a computation's result by its input or identity key for
97
+ reuse within one owning operation, writes each key once, and never changes a stored value. Never
98
+ key a memo by iteration position: treat a `Map` keyed by step as a log;
99
+ - a list or record that straight-line branches outside any loop append to, with no entry written
100
+ twice;
101
+ - a write that a platform API requires, such as `process.exitCode`, a DOM property, or a Vue `ref`
102
+ value, and one write per key into a fresh local object that builds a fixture a literal cannot
103
+ express, such as a cycle or a sparse array.
104
+ - **Per-call state.** Keep state that the awaits or callbacks of one call change, other than a log,
105
+ in `#` fields of an entity created for that call. Treat a list that more than one concurrently
106
+ pending async path of one call appends to, such as the workers of a pool, as state of that call:
107
+ collect each path's result through `Promise.all` or `Promise.allSettled`, or keep the list in the
108
+ call's entity. Treat an iterator or an `AbortController` instance created for the call as that
109
+ entity for its own protocol, its cursor, its abort, and its first reason, and never treat an array,
110
+ a record, a `Map`, or a `Set` as that entity. Treat an entity created for one call, or for one unit
111
+ under the **Bound values** rule, as short-lived for that call or unit, and every other entity as
112
+ long-lived. Judge lifetime against the call or bound unit whose state you classify: treat an owner
113
+ that can serve another such call or unit as long-lived, even when that owner belongs to an enclosing
114
+ call or unit.
115
+ - **Entity state.** Keep state that outlives one call in a `#` field on its owning class, never in
116
+ an array, a record, a `Map`, or a `Set` that a function holds in its closure and changes after the
117
+ call that created the function returns. Keep a composable's reactive state as
118
+ `.claude/rules/browser.md` states, and a middleware's changing state where
119
+ `.claude/rules/architecture.md` § Middleware places it.
120
+ - **Call slots.** In a long-lived entity, never keep a call's own entity, context, promise, or
121
+ scratch value in a `#` field that the call sets when it starts or clears when it settles, for its
122
+ steps, its callbacks, a handler registered outside it, or a later call to reach while it runs:
123
+ treat that field as a reassigned binding, even when a refusal keeps the calls from overlapping.
124
+ Reach the call's entity through the **Registries** rule. Treat neither a bound value nor a latch,
125
+ under the **Bound values** rule, as a call slot.
126
+ - **Unit resets.** In a long-lived entity, never let a method that binds or releases a unit of work
127
+ reset a `#` field other than the bound value's own field for the unit's later calls to change,
128
+ whether by assigning the field or by emptying the collection it holds: treat that field as a
129
+ reassigned binding. Treat an assignment as such a reset when it discards the preceding unit's state
130
+ and initializes state for another unit, even when its expression reads the old field. Keep that
131
+ state in `#` fields of the bound value.
132
+ - **Bound values.** When a long-lived entity binds one unit of work at a time that later calls
133
+ continue, such as a goal, a session, or a lease, and holds that unit nowhere else, keep the unit's
134
+ value in one `#` field typed `T | undefined`, never in a collection:
135
+ - in the binding method, throw while the field holds a value and publish the incoming value, both
136
+ before the method's first suspension, and keep asynchronous initialization inside that value's
137
+ lifecycle;
138
+ - in a `start` method that restarts instead, call the bound value's own end method and publish the
139
+ incoming value before the method's first suspension, and await the previous value's end only
140
+ after publishing;
141
+ - set the field back to `undefined` in a later call, such as a `clear` method, the method that ends
142
+ the unit, or the entity's teardown, and never in the binding call's own settle path. Treat as a
143
+ later call only an independent invocation made after the binding invocation returned, never
144
+ cleanup that the binding invocation invokes or registers;
145
+ - narrow the field's `T | undefined` type by throwing, branching, or optional chaining before you
146
+ use the value as present, or return or forward it unnarrowed through a contract that admits
147
+ absence;
148
+ - keep state whose lifetime is the bound unit in `#` fields of the bound value, and keep state that
149
+ continues across units in the owning entity's `#` fields.
150
+
151
+ Treat an explicit bind-to-release lifecycle as a bound unit. Keep several units bound at once in a
152
+ registry under the **Registries** rule, read by key. Treat a field that selects an entry the entity
153
+ already holds, such as the id of its active conversation, as a selection, not a bound value. Treat a
154
+ field as a latch, not a bound value, when it can acquire a value only once during its owner's
155
+ lifetime, whether a repeated invocation throws or keeps that value.
156
+
157
+ - **Registries.** Reach the entities of operations in flight, such as calls, streams, tickets, or
158
+ units bound at once, through a `readonly` `#` registry, a `Set` or a `Map`: add each entity when its
159
+ operation starts, delete it on every path that ends the operation, and clear the registry in bulk
160
+ only in the method that tears the entity down. Delete each settling operation's entry even when
161
+ that leaves the registry empty. Apply this rule to unfinished calls, streams, tickets, and
162
+ explicitly bound units, and never to a manager's population, a subscription, a cache, or a
163
+ persisted record merely because a collection holds it. Read a registry through its `size` property,
164
+ through every entry, or through the entry a key names. Take its entry by position, as
165
+ `const [entry] = registry` does, only while a refusal on every add caps the registry at one entry
166
+ and the call that added the entry deletes it on every path that settles that call, and handle the
167
+ empty registry at that read.
168
+ - **Immutability exceptions.** As an exception to § Immutability, change in place the membership of a
169
+ registry that the **Registries** rule admits, and keep its field binding `readonly`. As an exception
170
+ to the § Immutability ban on mutating caller-owned inputs, let a helper that takes part in one
171
+ owning operation append to that operation's log under the **Lists and logs** rule, or insert into
172
+ its memo under the **Exemptions** rule, and mutate no other caller-owned input.
173
+
44
174
  ## Errors and outcomes
45
175
 
46
176
  | Condition | Required strategy |