@erclx/canon 5.14.1 → 5.16.0

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 (77) hide show
  1. package/README.md +3 -2
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/draft-screencast/REQUIREMENT.md +2 -1
  4. package/claude/skills/draft-screencast/SKILL.md +14 -9
  5. package/claude/skills/draft-wiki/SKILL.md +1 -1
  6. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  7. package/claude/skills/read-frames/SKILL.md +3 -2
  8. package/claude/skills/record-screencast/REQUIREMENT.md +3 -0
  9. package/claude/skills/record-screencast/SKILL.md +28 -5
  10. package/claude/skills/search-craft/REQUIREMENT.md +57 -0
  11. package/claude/skills/search-craft/SKILL.md +83 -0
  12. package/docs/agents/canvas.md +36 -0
  13. package/docs/agents/commands.md +3 -0
  14. package/docs/agents/demo.md +34 -7
  15. package/docs/agents/index.md +1 -0
  16. package/docs/agents/migrate.md +1 -1
  17. package/docs/agents/sandbox.md +25 -0
  18. package/docs/target/migrations.md +1 -1
  19. package/docs/workflow/ai-workflow.md +2 -3
  20. package/docs/workflow/skill-map.md +2 -1
  21. package/governance/renames.toml +7 -5
  22. package/governance/rules/canon/630-requirements.md +1 -1
  23. package/package.json +5 -2
  24. package/scripts/lib/sandbox-git.sh +2 -2
  25. package/src/canvas/address.ts +184 -0
  26. package/src/canvas/capture.ts +172 -0
  27. package/src/canvas/client/app.tsx +87 -0
  28. package/src/canvas/client/inspector.tsx +121 -0
  29. package/src/canvas/client/layers.tsx +118 -0
  30. package/src/canvas/client/main.tsx +62 -0
  31. package/src/canvas/client/pages-panel.tsx +157 -0
  32. package/src/canvas/client/shell.css +449 -0
  33. package/src/canvas/client/state.ts +372 -0
  34. package/src/canvas/client/surface.tsx +465 -0
  35. package/src/canvas/content.ts +617 -0
  36. package/src/canvas/index.html +14 -0
  37. package/src/canvas/server.ts +471 -0
  38. package/src/canvas/tokens.ts +71 -0
  39. package/src/claude/cases/misc.ts +5 -0
  40. package/src/claude/seeds.ts +0 -1
  41. package/src/cli.ts +2 -0
  42. package/src/commands/canvas.ts +522 -0
  43. package/src/commands/claude.ts +9 -14
  44. package/src/commands/demo.ts +6 -6
  45. package/src/commands/migrate.ts +1 -1
  46. package/src/commands/sandbox.ts +108 -0
  47. package/src/context/classify/prompts.ts +1 -1
  48. package/src/context/folders.ts +0 -1
  49. package/src/demo/compile.ts +8 -4
  50. package/src/demo/container.ts +1 -1
  51. package/src/gov/renames.ts +3 -2
  52. package/src/help.ts +1 -0
  53. package/src/migrate/rename.ts +5 -4
  54. package/src/record-root.ts +1 -0
  55. package/src/sandbox/equivalence.ts +847 -0
  56. package/src/serve/static.ts +25 -11
  57. package/src/surface-root.ts +0 -1
  58. package/src/teach/browser/compile.ts +44 -0
  59. package/src/teach/browser/dismiss.ts +76 -0
  60. package/src/teach/browser/focus-line.ts +19 -0
  61. package/src/teach/browser/glossary-filter.ts +44 -0
  62. package/src/teach/browser/head.ts +30 -0
  63. package/src/teach/browser/quiz.ts +19 -0
  64. package/src/teach/browser/sidebar.ts +233 -0
  65. package/src/teach/browser/theme.ts +18 -0
  66. package/src/teach/nav.ts +19 -374
  67. package/standards/architecture.md +1 -1
  68. package/standards/context.md +1 -1
  69. package/standards/design.md +1 -1
  70. package/standards/index.md +0 -1
  71. package/standards/requirements.md +4 -6
  72. package/standards/wireframes.md +2 -2
  73. package/tooling/claude/reference.md +0 -1
  74. package/tooling/claude/seeds/canon/REQUIREMENTS.md +0 -2
  75. package/governance/rules/canon/645-decisions.md +0 -12
  76. package/standards/decisions.md +0 -105
  77. package/tooling/claude/seeds/canon/decisions/index.md +0 -8
@@ -0,0 +1,471 @@
1
+ import { type FSWatcher, mkdirSync, watch } from 'node:fs'
2
+ import {
3
+ type ElementAddress,
4
+ HASH_ATTRIBUTE,
5
+ TOKENS_ATTRIBUTE,
6
+ } from '@/canvas/address'
7
+ import {
8
+ type ContentRefused,
9
+ canvasDir,
10
+ contentHash,
11
+ listPages,
12
+ LOCK_SUFFIX,
13
+ moveFrame,
14
+ readSelection,
15
+ TEMP_SUFFIX,
16
+ writeSelection,
17
+ } from '@/canvas/content'
18
+ import { resolveFrameTokens, type TokenOptions } from '@/canvas/tokens'
19
+ import { buildDesignCss } from '@/design/css'
20
+ import {
21
+ bindFirstFree,
22
+ PORT_ATTEMPTS,
23
+ respond,
24
+ SERVE_HOST,
25
+ type ServeRefusal,
26
+ } from '@/serve/static'
27
+
28
+ /** Clear of `canon serve`'s default, so a lesson preview and the canvas both open. */
29
+ export const CANVAS_PORT = 8790
30
+
31
+ /** The URL prefix a frame and the files beside it are served under. */
32
+ export const FRAMES_PREFIX = '/frames/'
33
+
34
+ /**
35
+ * How long a burst of watch events for one file waits before it is sent. One
36
+ * editor save raises several, and each would otherwise reload the frame again.
37
+ */
38
+ const SETTLE_MS = 40
39
+
40
+ export interface CanvasStarted {
41
+ readonly ok: true
42
+ readonly root: string
43
+ readonly content: string
44
+ readonly host: string
45
+ readonly port: number
46
+ readonly url: string
47
+ readonly stop: () => Promise<void>
48
+ }
49
+
50
+ export interface CanvasRefused {
51
+ readonly ok: false
52
+ readonly reason: Extract<ServeRefusal, 'no-port' | 'bind-failed'>
53
+ readonly detail: string
54
+ }
55
+
56
+ export type CanvasOutcome = CanvasStarted | CanvasRefused
57
+
58
+ export interface CanvasOptions {
59
+ readonly port?: number
60
+ /**
61
+ * What answers `/`. The command passes the HTML import of `index.html`, which
62
+ * `Bun.serve` bundles on request, and a test passes a plain response, since
63
+ * the test runner cannot load an HTML module.
64
+ */
65
+ readonly shell: Bun.HTMLBundle | Response
66
+ readonly tokens?: TokenOptions
67
+ }
68
+
69
+ export interface ChangeEvent {
70
+ readonly page: string
71
+ /**
72
+ * The path under the page folder, or empty when the change is to the
73
+ * top-level entry itself, being a page folder added, renamed, or removed.
74
+ * An empty file names no frame, so a reader rereads the page list alone.
75
+ */
76
+ readonly file: string
77
+ }
78
+
79
+ /**
80
+ * Escapes the one sequence that would end the injected element early. The
81
+ * hash is hex, so it needs no escaping inside the attribute.
82
+ */
83
+ function styleElement(css: string, hash: string | undefined): string {
84
+ const stamp = hash === undefined ? '' : ` ${HASH_ATTRIBUTE}="${hash}"`
85
+ return `<style ${TOKENS_ATTRIBUTE}${stamp}>${css.replaceAll('</style', '<\\/style')}</style>`
86
+ }
87
+
88
+ /**
89
+ * Puts the tokens first in the head, so a stylesheet the frame links itself
90
+ * still wins the cascade. A frame with no head gets the element right after
91
+ * its doctype, which a browser hoists into the head it builds. A hash stamps
92
+ * the element even when no tokens resolve, since the shell sends it back with
93
+ * an element pick.
94
+ */
95
+ export function injectTokens(html: string, css: string, hash?: string): string {
96
+ if (css === '' && hash === undefined) return html
97
+ const element = styleElement(css, hash)
98
+ const head = html.match(/<head(?:\s[^>]*)?>/i)
99
+ if (head?.index === undefined) {
100
+ /* Ahead of a doctype the element would drop the frame into quirks mode. */
101
+ const doctype = html.match(/^\s*<!doctype[^>]*>/i)?.[0] ?? ''
102
+ return `${doctype}${element}${html.slice(doctype.length)}`
103
+ }
104
+ const at = head.index + head[0].length
105
+ return `${html.slice(0, at)}${element}${html.slice(at)}`
106
+ }
107
+
108
+ /**
109
+ * Whether a request names this server by a loopback name and its own port.
110
+ * The bind keeps other machines out, and this keeps out a page the operator
111
+ * visits whose domain was rebound to 127.0.0.1, which reaches the socket with
112
+ * its own name in the Host header.
113
+ */
114
+ export function isLoopbackHost(host: string | null, port: number): boolean {
115
+ return host === `${SERVE_HOST}:${port}` || host === `localhost:${port}`
116
+ }
117
+
118
+ type Handler = (
119
+ request: Request,
120
+ server: Bun.Server<undefined>,
121
+ ) => Response | Promise<Response>
122
+
123
+ function guarded(handler: Handler): Handler {
124
+ return (request, server) =>
125
+ isLoopbackHost(request.headers.get('host'), server.port ?? 0)
126
+ ? handler(request, server)
127
+ : new Response('Forbidden\n', {
128
+ status: 403,
129
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
130
+ })
131
+ }
132
+
133
+ function json(body: unknown, status = 200): Response {
134
+ return new Response(JSON.stringify(body), {
135
+ status,
136
+ headers: {
137
+ 'content-type': 'application/json; charset=utf-8',
138
+ 'cache-control': 'no-store',
139
+ },
140
+ })
141
+ }
142
+
143
+ const REFUSAL_STATUS: Record<ContentRefused['reason'], number> = {
144
+ 'invalid-name': 400,
145
+ 'invalid-size': 400,
146
+ 'invalid-position': 400,
147
+ 'no-page': 404,
148
+ 'no-frame': 404,
149
+ exists: 409,
150
+ 'malformed-layout': 409,
151
+ busy: 409,
152
+ 'invalid-address': 400,
153
+ 'address-mismatch': 409,
154
+ 'stale-address': 409,
155
+ }
156
+
157
+ function refusal(refused: ContentRefused): Response {
158
+ return json(refused, REFUSAL_STATUS[refused.reason])
159
+ }
160
+
161
+ function badBody(detail: string): Response {
162
+ return json({ ok: false, reason: 'invalid-body', detail }, 400)
163
+ }
164
+
165
+ /**
166
+ * The Host guard cannot tell the shell from a page on another origin that
167
+ * reaches this socket, since a browser sends the loopback Host either way. A
168
+ * write also carries an Origin when a page sent it, so one naming any other
169
+ * origin is refused, and a JSON content type forces that page through a
170
+ * preflight this server never answers.
171
+ */
172
+ function mutation(handler: (body: unknown) => Response): Handler {
173
+ return async (request, server) => {
174
+ const origin = request.headers.get('origin')
175
+ if (origin !== null && !isLoopbackOrigin(origin, server.port ?? 0)) {
176
+ return new Response('Forbidden\n', { status: 403 })
177
+ }
178
+ const type = request.headers.get('content-type') ?? ''
179
+ if (!type.startsWith('application/json')) {
180
+ return json(
181
+ { ok: false, reason: 'invalid-body', detail: 'send application/json' },
182
+ 415,
183
+ )
184
+ }
185
+ let body: unknown
186
+ try {
187
+ body = await request.json()
188
+ } catch {
189
+ return badBody('the body is not JSON')
190
+ }
191
+ return handler(body)
192
+ }
193
+ }
194
+
195
+ function isLoopbackOrigin(origin: string, port: number): boolean {
196
+ return (
197
+ origin === `http://${SERVE_HOST}:${port}` ||
198
+ origin === `http://localhost:${port}`
199
+ )
200
+ }
201
+
202
+ function isRecord(value: unknown): value is Record<string, unknown> {
203
+ return typeof value === 'object' && value !== null
204
+ }
205
+
206
+ function isAddressShape(value: unknown): value is ElementAddress {
207
+ return (
208
+ isRecord(value) &&
209
+ typeof value.index === 'number' &&
210
+ typeof value.tag === 'string' &&
211
+ typeof value.count === 'number' &&
212
+ (value.hash === undefined || typeof value.hash === 'string')
213
+ )
214
+ }
215
+
216
+ async function serveFrame(
217
+ root: string,
218
+ content: string,
219
+ request: Request,
220
+ tokens: TokenOptions,
221
+ ): Promise<Response> {
222
+ const url = new URL(request.url)
223
+ url.pathname = `/${url.pathname.slice(FRAMES_PREFIX.length)}`
224
+ const file = await respond(content, new Request(url))
225
+
226
+ const type = file.headers.get('content-type') ?? ''
227
+ if (file.status !== 200 || !type.startsWith('text/html')) return file
228
+
229
+ const { css } = resolveFrameTokens(root, tokens)
230
+ /*
231
+ * Hashed over the bytes on disk, the same input `writeSelection` reads,
232
+ * since decoding to text drops a byte order mark the file still holds.
233
+ */
234
+ const bytes = new Uint8Array(await file.arrayBuffer())
235
+ const html = new TextDecoder().decode(bytes)
236
+ return new Response(injectTokens(html, css, contentHash(bytes)), {
237
+ headers: { 'content-type': type, 'cache-control': 'no-store' },
238
+ })
239
+ }
240
+
241
+ /**
242
+ * Fans each change under the canvas folder out to every open shell. The first
243
+ * segment names the page and the rest names the file within it, so a
244
+ * top-level entry goes out with an empty file. Only an empty path is skipped.
245
+ */
246
+ class ChangeStream {
247
+ private readonly clients = new Set<ReadableStreamDefaultController<string>>()
248
+ private readonly pending = new Map<string, ReturnType<typeof setTimeout>>()
249
+ private readonly watcher: FSWatcher
250
+
251
+ constructor(content: string) {
252
+ this.watcher = watch(content, { recursive: true }, (_event, filename) => {
253
+ if (filename) this.queue(filename.toString())
254
+ })
255
+ }
256
+
257
+ /**
258
+ * A one-segment path is kept because the recursive watch reports a page
259
+ * folder appearing, going, or being renamed under its bare name with nothing
260
+ * beneath it. A stray file beside the pages goes out the same way, which
261
+ * costs the shell one page-list reread.
262
+ */
263
+ private queue(path: string): void {
264
+ const [page, ...rest] = path.split(/[\\/]/)
265
+ if (page === undefined || page === '') return
266
+ /* A write's staging or lock file, gone a moment later. */
267
+ if (path.endsWith(TEMP_SUFFIX) || path.endsWith(LOCK_SUFFIX)) return
268
+ clearTimeout(this.pending.get(path))
269
+ this.pending.set(
270
+ path,
271
+ setTimeout(() => {
272
+ this.pending.delete(path)
273
+ this.send({ page, file: rest.join('/') })
274
+ }, SETTLE_MS),
275
+ )
276
+ }
277
+
278
+ private send(change: ChangeEvent): void {
279
+ const frame = `data: ${JSON.stringify(change)}\n\n`
280
+ for (const client of this.clients) client.enqueue(frame)
281
+ }
282
+
283
+ open(): Response {
284
+ let self: ReadableStreamDefaultController<string> | undefined
285
+ const stream = new ReadableStream<string>({
286
+ start: (controller) => {
287
+ self = controller
288
+ this.clients.add(controller)
289
+ controller.enqueue(': open\n\n')
290
+ },
291
+ cancel: () => {
292
+ if (self) this.clients.delete(self)
293
+ },
294
+ })
295
+ return new Response(stream, {
296
+ headers: {
297
+ 'content-type': 'text/event-stream',
298
+ 'cache-control': 'no-store',
299
+ },
300
+ })
301
+ }
302
+
303
+ close(): void {
304
+ this.watcher.close()
305
+ for (const timer of this.pending.values()) clearTimeout(timer)
306
+ for (const client of this.clients) {
307
+ try {
308
+ client.close()
309
+ } catch {
310
+ /* Already closed by the browser going away. */
311
+ }
312
+ }
313
+ this.clients.clear()
314
+ }
315
+ }
316
+
317
+ export function startCanvas(
318
+ root: string,
319
+ options: CanvasOptions,
320
+ ): CanvasOutcome {
321
+ const content = canvasDir(root)
322
+ /* Created up front, since the change stream has to watch something. */
323
+ mkdirSync(content, { recursive: true })
324
+
325
+ const tokens = options.tokens ?? {}
326
+ /*
327
+ * Fonts embedded, so the chrome renders in its own face on a machine that
328
+ * never installed it rather than in whatever the fallback stack finds.
329
+ */
330
+ const chromeCss = buildDesignCss(undefined, {
331
+ components: false,
332
+ embedFonts: true,
333
+ })
334
+ const changes = new ChangeStream(content)
335
+ const first = options.port ?? CANVAS_PORT
336
+
337
+ let bound: ReturnType<typeof bindFirstFree>
338
+ try {
339
+ bound = bindFirstFree(first, (port) =>
340
+ Bun.serve({
341
+ hostname: SERVE_HOST,
342
+ port,
343
+ development: false,
344
+ /* The change stream stays open for as long as the shell does. */
345
+ idleTimeout: 0,
346
+ routes: {
347
+ /*
348
+ * The shell and the chunks it bundles carry toolkit code and no
349
+ * project content, so they are the one route left unguarded.
350
+ */
351
+ '/': options.shell,
352
+ '/api/pages': guarded(() => {
353
+ const resolved = resolveFrameTokens(root, tokens)
354
+ const { css: _css, ...source } = resolved
355
+ const selected = readSelection(root)
356
+ return json({
357
+ pages: listPages(root),
358
+ tokens: source,
359
+ selection: selected && {
360
+ page: selected.page,
361
+ frame: selected.frame,
362
+ ...(selected.element && {
363
+ element: {
364
+ index: selected.element.index,
365
+ tag: selected.element.tag,
366
+ stale: selected.element.stale,
367
+ },
368
+ }),
369
+ },
370
+ })
371
+ }),
372
+ '/api/frames/move': {
373
+ POST: guarded(
374
+ mutation((body) => {
375
+ if (
376
+ !isRecord(body) ||
377
+ typeof body.page !== 'string' ||
378
+ typeof body.frame !== 'string' ||
379
+ typeof body.x !== 'number' ||
380
+ typeof body.y !== 'number'
381
+ ) {
382
+ return badBody('send page, frame, x, and y')
383
+ }
384
+ const outcome = moveFrame(root, body.page, body.frame, {
385
+ x: body.x,
386
+ y: body.y,
387
+ })
388
+ return outcome.ok ? json(outcome) : refusal(outcome)
389
+ }),
390
+ ),
391
+ },
392
+ '/api/selection': {
393
+ POST: guarded(
394
+ mutation((body) => {
395
+ if (!isRecord(body)) return badBody('send a frame or {}')
396
+ const { page, frame } = body
397
+ const target =
398
+ typeof page === 'string' && typeof frame === 'string'
399
+ ? { page, frame }
400
+ : undefined
401
+ if (target === undefined && (page != null || frame != null)) {
402
+ return badBody('send both page and frame, or neither')
403
+ }
404
+ const { element } = body
405
+ if (element != null && target === undefined) {
406
+ return badBody('send an element with its page and frame')
407
+ }
408
+ if (element != null && !isAddressShape(element)) {
409
+ return badBody('send an element as index, tag, and count')
410
+ }
411
+ const outcome = writeSelection(
412
+ root,
413
+ target && element != null ? { ...target, element } : target,
414
+ )
415
+ return outcome.ok ? json(outcome) : refusal(outcome)
416
+ }),
417
+ ),
418
+ },
419
+ '/api/chrome.css': guarded(
420
+ () =>
421
+ new Response(chromeCss, {
422
+ headers: {
423
+ 'content-type': 'text/css; charset=utf-8',
424
+ 'cache-control': 'no-store',
425
+ },
426
+ }),
427
+ ),
428
+ '/api/events': guarded(() => changes.open()),
429
+ },
430
+ fetch: guarded((request) => {
431
+ const { pathname } = new URL(request.url)
432
+ if (pathname.startsWith(FRAMES_PREFIX)) {
433
+ return serveFrame(root, content, request, tokens)
434
+ }
435
+ return new Response('Not found\n', { status: 404 })
436
+ }),
437
+ }),
438
+ )
439
+ } catch (error) {
440
+ changes.close()
441
+ const code = (error as NodeJS.ErrnoException).code ?? 'unknown'
442
+ return {
443
+ ok: false,
444
+ reason: 'bind-failed',
445
+ detail: `could not bind ${SERVE_HOST}:${first} (${code})`,
446
+ }
447
+ }
448
+
449
+ if (!bound) {
450
+ changes.close()
451
+ return {
452
+ ok: false,
453
+ reason: 'no-port',
454
+ detail: `no free port between ${first} and ${first + PORT_ATTEMPTS - 1}`,
455
+ }
456
+ }
457
+
458
+ const { server, port } = bound
459
+ return {
460
+ ok: true,
461
+ root,
462
+ content,
463
+ host: SERVE_HOST,
464
+ port,
465
+ url: `http://${SERVE_HOST}:${port}/`,
466
+ stop: async () => {
467
+ changes.close()
468
+ await server.stop(true)
469
+ },
470
+ }
471
+ }
@@ -0,0 +1,71 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { DESIGN_INSTALL_DIR, DESIGN_PROJECT_SUBDIR } from '@/design/adapter'
4
+ import { buildDesignCss } from '@/design/css'
5
+ import { isOwnCheckout } from '@/project-root'
6
+
7
+ /**
8
+ * The stylesheet injected into every frame, so a frame drawn with
9
+ * `var(--color-*)` shows the project's own values. Parsing a `DESIGN.md` into
10
+ * custom properties is left out, since no such mapping exists, and a frame can
11
+ * always link a stylesheet of its own.
12
+ */
13
+ export type FrameTokens =
14
+ | { readonly source: 'toolkit'; readonly css: string }
15
+ | {
16
+ readonly source: 'installed'
17
+ readonly css: string
18
+ /** Relative to the root, in the order the stylesheet concatenates them. */
19
+ readonly files: readonly string[]
20
+ }
21
+ | { readonly source: 'none'; readonly css: ''; readonly notice: string }
22
+
23
+ export interface TokenOptions {
24
+ /** Overrides the checkout test, which reads this package's own name. */
25
+ readonly isOwnCheckout?: boolean
26
+ }
27
+
28
+ const BASE_FILE = 'base.css'
29
+
30
+ const NONE_NOTICE = `No token stylesheet, so frames render unstyled. Run canon design install to add ${join(DESIGN_INSTALL_DIR, BASE_FILE)}, put overrides under ${join(DESIGN_INSTALL_DIR, DESIGN_PROJECT_SUBDIR)}/, or link a stylesheet from the frame.`
31
+
32
+ /** The installed files in cascade order: the base first, then each override. */
33
+ function installedFiles(root: string): string[] {
34
+ const files: string[] = []
35
+ const base = join(DESIGN_INSTALL_DIR, BASE_FILE)
36
+ if (existsSync(join(root, base))) files.push(base)
37
+
38
+ const projectDir = join(DESIGN_INSTALL_DIR, DESIGN_PROJECT_SUBDIR)
39
+ if (existsSync(join(root, projectDir))) {
40
+ const overrides = readdirSync(join(root, projectDir))
41
+ .filter((name) => name.endsWith('.css'))
42
+ .sort((a, b) => a.localeCompare(b))
43
+ .map((name) => join(projectDir, name))
44
+ files.push(...overrides)
45
+ }
46
+ return files
47
+ }
48
+
49
+ /**
50
+ * The toolkit's own module where the root is this checkout, else the installed
51
+ * base plus its project overrides, else nothing with a notice naming where
52
+ * tokens would come from.
53
+ */
54
+ export function resolveFrameTokens(
55
+ root: string,
56
+ options: TokenOptions = {},
57
+ ): FrameTokens {
58
+ if (options.isOwnCheckout ?? isOwnCheckout(root)) {
59
+ return { source: 'toolkit', css: buildDesignCss() }
60
+ }
61
+
62
+ const files = installedFiles(root)
63
+ if (files.length > 0) {
64
+ const css = files
65
+ .map((file) => `/* ${file} */\n${readFileSync(join(root, file), 'utf8')}`)
66
+ .join('\n')
67
+ return { source: 'installed', css, files }
68
+ }
69
+
70
+ return { source: 'none', css: '', notice: NONE_NOTICE }
71
+ }
@@ -79,6 +79,11 @@ export const MISC_CASES: readonly SkillCase[] = [
79
79
  'The same discount rule sits in two files and I need a third variant. Should I extract it, or add a strategy class for the two cases?',
80
80
  expect: 'code-craft',
81
81
  },
82
+ {
83
+ prompt:
84
+ 'Find me the strongest evidence on whether rent control reduces housing supply. Where should I look?',
85
+ expect: 'search-craft',
86
+ },
82
87
  {
83
88
  prompt:
84
89
  "I've got the plan approved and it touches the parser, the command, and the docs. How should I build it so I don't end up with one giant diff?",
@@ -20,7 +20,6 @@ const HOOKS = 'hooks'
20
20
  export const SUBDIRS: readonly string[] = [
21
21
  HOOKS,
22
22
  'context',
23
- 'decisions',
24
23
  'diagrams',
25
24
  'memory',
26
25
  'tasks',
package/src/cli.ts CHANGED
@@ -23,6 +23,7 @@ import { register as transcripts } from '@/commands/transcripts'
23
23
  import { register as tasks } from '@/commands/tasks'
24
24
  import { register as intake } from '@/commands/intake'
25
25
  import { register as teach } from '@/commands/teach'
26
+ import { register as canvas } from '@/commands/canvas'
26
27
  import { register as comments } from '@/commands/comments'
27
28
  import { register as context } from '@/commands/context'
28
29
  import { register as markdown } from '@/commands/markdown'
@@ -84,6 +85,7 @@ transcripts(program)
84
85
  tasks(program)
85
86
  intake(program)
86
87
  teach(program)
88
+ canvas(program)
87
89
  comments(program)
88
90
  context(program)
89
91
  markdown(program)