@kolisachint/hoocode-agent 0.5.41 → 0.5.42

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,94 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.42] - 2026-08-31
4
+
5
+ ### Added
6
+
7
+ - Two built-in review agents, `code-review` and `security-review`.
8
+
9
+ Both ride the existing `templates/agents/` channel, so they cost nothing to
10
+ wire: `embed-templates.mjs` already embeds that directory and `Task` already
11
+ dispatches by name. They are agents rather than skills because review is the
12
+ case isolation is for — a review reads far more code than its findings are
13
+ worth carrying, and in a subagent that reading never lands in the parent
14
+ context.
15
+
16
+ Their tool allowlists include `bash` for read-only git inspection but exclude
17
+ `edit` and `write`, so a review reports and the caller decides. A test pins
18
+ that.
19
+
20
+ - `/new-canvas` scaffolds a styled page instead of a placeholder.
21
+
22
+ The template served `<p>0 note(s). TODO: build the UI.` — unstyled, and with
23
+ no pattern to copy, so every canvas started by inventing one. It now ships a
24
+ small correct page: palette as custom properties with a
25
+ `prefers-color-scheme` block, an explicit `body` background, a list, an empty
26
+ state, and a reduced-motion-respecting highlight on rows that changed.
27
+
28
+ It also demonstrates the thing `canvas-design` asks for. State renders on the
29
+ server, so the page is right the moment it loads and stays right with
30
+ scripting off; a `/state` endpoint and a one-second poll update it in place so
31
+ what the agent changed appears without a reload. Everything from state goes
32
+ through an escape helper and `textContent`, never `innerHTML`.
33
+
34
+ No CDN and no webfont: the page is served from `127.0.0.1` and now works with
35
+ the network off, which a remote script or font would have thrown away. The
36
+ markup is built with plain strings and concatenation rather than template
37
+ literals, because this file is itself generated and a backtick or
38
+ dollar-brace in it is an escaping hazard for no gain.
39
+
40
+ - A built-in `canvas-design` skill, loaded only when a canvas is being built.
41
+
42
+ `/new-canvas` handed the model a protocol contract and nothing about design,
43
+ and the scaffold serves `<p>0 note(s). TODO: build the UI.` — so whatever a
44
+ canvas looked like was improvised from nothing. `artifact-design` does not
45
+ cover it and would mislead if it fired: a canvas is a live page served from a
46
+ loopback server, not a file written to disk.
47
+
48
+ What is different is the whole skill: the markup lives in a JS template
49
+ string, `node_modules` and `package.json` are forbidden so there is nothing to
50
+ install and no build, the protocol carries no theme so the page owns its
51
+ palette outright, state is mutated by both the agent and the person, actions
52
+ are tool schemas that cost tokens while an instance is open, and a reload
53
+ issues a new URL that kills the open tab.
54
+
55
+ Grounded in the catalog rather than invented: 22 of GitHub's 23 canvas
56
+ extensions import nothing but the SDK and `node:` builtins, and the flagship
57
+ `pr-artifact-explorer` is a read surface that sidesteps concurrent editing
58
+ entirely. The skill takes both as the house style.
59
+
60
+ It costs nothing per turn. `disable-model-invocation` keeps it out of
61
+ `<available_skills>`, materialization is unconditional so the file is always
62
+ on disk, and `canvasBuildBrief` names its absolute path at the one moment it
63
+ is worth reading. Measured: the surface is unchanged at 8,470.
64
+
65
+ - A built-in `artifact-design` skill for building self-contained HTML visuals.
66
+
67
+ hoocode writes a visual as one `.html` file on disk that `/canvas` can open.
68
+ The skill covers what that file needs to be good: reading the treatment the
69
+ request actually calls for, writing the color/type/layout plan before the
70
+ markup, designing both themes through custom properties, and avoiding the
71
+ handful of looks generated design keeps landing on.
72
+
73
+ Named for the output rather than the activity: a bare `design` would sit in
74
+ the prompt next to `/new-canvas` and read as software design as readily as
75
+ visual design. Note that "artifact" is new vocabulary in hoocode; it names the
76
+ thing produced, not Claude's hosted artifacts.
77
+
78
+ Libraries get an explicit no by default. A hosted page and a file on disk fail
79
+ differently: a CDN dependency is free for a page that is always viewed online,
80
+ and fatal for a file opened offline, where Tailwind-from-a-CDN is an unstyled
81
+ document. Webfonts are the exception, because a fallback stack degrades
82
+ instead of collapsing.
83
+
84
+ Delivery is a `file://` markdown link, which hoocode's renderer turns into an
85
+ OSC 8 hyperlink wherever the terminal supports one.
86
+
87
+ It is the first ungated built-in skill, because there is no feature switch
88
+ that predicts a request for a visual. Measured cost is +130 tokens per turn,
89
+ of which part is the `<available_skills>` block header that no default session
90
+ previously paid; the two agents add +180 together.
91
+
3
92
  ## [0.5.41] - 2026-08-31
4
93
 
5
94
  ### Added
@@ -50,6 +50,17 @@ export declare function builtinSkillsCacheDir(agentDir?: string): string;
50
50
  * paths and hoocode runs exactly as it did before these existed.
51
51
  */
52
52
  export declare function materializeBuiltinSkills(agentDir?: string): string | null;
53
+ /**
54
+ * Absolute path to the canvas-design guide, or undefined if it is not on disk.
55
+ *
56
+ * `canvas-design` is hidden from the per-turn skill list, so nothing would ever
57
+ * surface it without this: `/new-canvas` names the path in its build brief, at
58
+ * the one moment the guidance is worth loading. Materialization is
59
+ * unconditional, so the file is normally there whether or not any skill is
60
+ * contributed — but a degraded session (read-only home, full disk) has no cache
61
+ * at all, and then the brief simply ships without the line.
62
+ */
63
+ export declare function canvasDesignGuidePath(agentDir?: string): string | undefined;
53
64
  /**
54
65
  * The skill directories to load this session, after gating.
55
66
  *
@@ -1 +1 @@
1
- {"version":3,"file":"builtin-skills.d.ts","sourceRoot":"","sources":["../../src/core/builtin-skills.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAQH,wEAAwE;AACxE,MAAM,WAAW,gBAAgB;IAChC,2EAA2E;IAC3E,iBAAiB,EAAE,OAAO,CAAC;CAC3B;AAED,MAAM,WAAW,YAAY;IAC5B,yEAAyE;IACzE,IAAI,EAAE,MAAM,CAAC;IACb,0EAA0E;IAC1E,OAAO,EAAE,MAAM,CAAC;IAChB;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,gBAAgB,KAAK,OAAO,CAAC;CAC9C;AAED,eAAO,MAAM,cAAc,EAAE,SAAS,YAAY,EASjD,CAAC;AAcF,6DAA6D;AAC7D,wBAAgB,qBAAqB,CAAC,QAAQ,GAAE,MAAsB,GAAG,MAAM,CAE9E;AAED;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,CAAC,QAAQ,GAAE,MAAsB,GAAG,MAAM,GAAG,IAAI,CA2BxF;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,gBAAgB,EAAE,QAAQ,GAAE,MAAsB,GAAG,MAAM,EAAE,CAQpG","sourcesContent":["/**\n * Skills hoocode itself ships.\n *\n * hoocode reads skills from `~/.agents/skills`, `.hoocode/skills`, `.claude/skills`\n * and installed packages — every source except its own. That gap is why it\n * shipped three subagents and zero skills while telling users skills are the\n * extension unit: there was simply nowhere for a first-party skill to live.\n *\n * The obstacle is that a skill's `<location>` has to be a real readable path —\n * the model loads a skill by `read`ing it — and the Bun-compiled binary has no\n * `templates/` beside it. So rather than resolve the package directory (which\n * differs across npm/pnpm/source/binary layouts and would give the compiled\n * binary a silently degraded skill set), every install materializes the same\n * embedded copy into a cache directory. One code path, same behaviour\n * everywhere.\n *\n * The cache is keyed by content hash, so an upgrade writes a new directory and\n * a dev build that changes a skill without changing the version still takes\n * effect. It is a cache, not user-editable state: `~/.agents/skills` is where a\n * user's own skills go, and nothing here ever writes there.\n */\n\nimport { createHash } from \"node:crypto\";\nimport { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { getAgentDir } from \"../config.js\";\nimport { EMBEDDED_SKILLS } from \"../init-templates.generated.js\";\n\n/** What decides whether a built-in skill is registered this session. */\nexport interface BuiltinSkillGate {\n\t/** The `enablePluginTools` setting (the plugin system's master switch). */\n\tenablePluginTools: boolean;\n}\n\nexport interface BuiltinSkill {\n\t/** Directory name under `templates/skills`, and the skill's own name. */\n\tname: string;\n\t/** Why hoocode ships it. Documentation, and the catalog test reads it. */\n\tsummary: string;\n\t/**\n\t * Registered only when this returns true. A skill costs its description on\n\t * every turn, so one that only makes sense alongside a feature rides that\n\t * feature's switch rather than the default user's token budget.\n\t *\n\t * Omit for a skill that should always be available.\n\t */\n\tgate?: (options: BuiltinSkillGate) => boolean;\n}\n\nexport const BUILTIN_SKILLS: readonly BuiltinSkill[] = [\n\t{\n\t\tname: \"plugin-authoring\",\n\t\tsummary:\n\t\t\t\"The craft half of ProposePlugin/UpdatePlugin: when a capability is worth extracting, naming it so it triggers again, portability, and the hook trap.\",\n\t\t// Useless without the tools it describes, and those are off by default,\n\t\t// so this costs nothing for a user who never enables the plugin system.\n\t\tgate: (options) => options.enablePluginTools,\n\t},\n];\n\n/** Stable short hash of the embedded skill tree; the cache directory's name. */\nfunction contentHash(): string {\n\tconst hash = createHash(\"sha256\");\n\tfor (const key of Object.keys(EMBEDDED_SKILLS).sort()) {\n\t\thash.update(key);\n\t\thash.update(\"\\0\");\n\t\thash.update(EMBEDDED_SKILLS[key] ?? \"\");\n\t\thash.update(\"\\0\");\n\t}\n\treturn hash.digest(\"hex\").slice(0, 12);\n}\n\n/** Root of the materialized copy for the current content. */\nexport function builtinSkillsCacheDir(agentDir: string = getAgentDir()): string {\n\treturn join(agentDir, \"cache\", \"builtin-skills\", contentHash());\n}\n\n/**\n * Write the embedded skills to the cache directory if they are not already\n * there, and return its path.\n *\n * Returns null when nothing could be written — a read-only home, a full disk.\n * That is a degraded session, not a broken one: the caller contributes no skill\n * paths and hoocode runs exactly as it did before these existed.\n */\nexport function materializeBuiltinSkills(agentDir: string = getAgentDir()): string | null {\n\tconst root = builtinSkillsCacheDir(agentDir);\n\ttry {\n\t\tfor (const [relativePath, content] of Object.entries(EMBEDDED_SKILLS)) {\n\t\t\tconst target = join(root, relativePath);\n\t\t\t// Content is hash-addressed, so an existing file with the right size is\n\t\t\t// already correct; re-reading beats re-writing on every startup.\n\t\t\tif (existsSync(target) && readFileSync(target, \"utf-8\") === content) continue;\n\t\t\tmkdirSync(dirname(target), { recursive: true });\n\t\t\t// Write-then-rename so a killed process never leaves a half-written\n\t\t\t// SKILL.md that would parse as a malformed skill on the next run.\n\t\t\tconst temp = `${target}.${process.pid}.tmp`;\n\t\t\twriteFileSync(temp, content, \"utf-8\");\n\t\t\trenameSync(temp, target);\n\t\t}\n\t\treturn root;\n\t} catch {\n\t\t// Drop a partial tree so the next run rebuilds it rather than loading a\n\t\t// half-written skill. The cleanup gets its own guard: `force` swallows\n\t\t// ENOENT but not ENOTDIR, and a cleanup that throws would turn the\n\t\t// degraded path back into a crash — which is the failure this whole\n\t\t// branch exists to prevent.\n\t\ttry {\n\t\t\trmSync(root, { recursive: true, force: true });\n\t\t} catch {}\n\t\treturn null;\n\t}\n}\n\n/**\n * The skill directories to load this session, after gating.\n *\n * Returns per-skill directories rather than the root so a gated-off skill is\n * genuinely absent rather than loaded and filtered later — the load is what\n * costs the description on every turn.\n */\nexport function builtinSkillPaths(gate: BuiltinSkillGate, agentDir: string = getAgentDir()): string[] {\n\tconst enabled = BUILTIN_SKILLS.filter((skill) => !skill.gate || skill.gate(gate));\n\tif (enabled.length === 0) return [];\n\n\tconst root = materializeBuiltinSkills(agentDir);\n\tif (!root) return [];\n\n\treturn enabled.map((skill) => join(root, skill.name)).filter((dir) => existsSync(dir));\n}\n"]}
1
+ {"version":3,"file":"builtin-skills.d.ts","sourceRoot":"","sources":["../../src/core/builtin-skills.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAQH,wEAAwE;AACxE,MAAM,WAAW,gBAAgB;IAChC,2EAA2E;IAC3E,iBAAiB,EAAE,OAAO,CAAC;CAC3B;AAED,MAAM,WAAW,YAAY;IAC5B,yEAAyE;IACzE,IAAI,EAAE,MAAM,CAAC;IACb,0EAA0E;IAC1E,OAAO,EAAE,MAAM,CAAC;IAChB;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,gBAAgB,KAAK,OAAO,CAAC;CAC9C;AAED,eAAO,MAAM,cAAc,EAAE,SAAS,YAAY,EA0BjD,CAAC;AAcF,6DAA6D;AAC7D,wBAAgB,qBAAqB,CAAC,QAAQ,GAAE,MAAsB,GAAG,MAAM,CAE9E;AAED;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,CAAC,QAAQ,GAAE,MAAsB,GAAG,MAAM,GAAG,IAAI,CA2BxF;AAED;;;;;;;;;GASG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,GAAE,MAAsB,GAAG,MAAM,GAAG,SAAS,CAI1F;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,gBAAgB,EAAE,QAAQ,GAAE,MAAsB,GAAG,MAAM,EAAE,CAQpG","sourcesContent":["/**\n * Skills hoocode itself ships.\n *\n * hoocode reads skills from `~/.agents/skills`, `.hoocode/skills`, `.claude/skills`\n * and installed packages — every source except its own. That gap is why it\n * shipped three subagents and zero skills while telling users skills are the\n * extension unit: there was simply nowhere for a first-party skill to live.\n *\n * The obstacle is that a skill's `<location>` has to be a real readable path —\n * the model loads a skill by `read`ing it — and the Bun-compiled binary has no\n * `templates/` beside it. So rather than resolve the package directory (which\n * differs across npm/pnpm/source/binary layouts and would give the compiled\n * binary a silently degraded skill set), every install materializes the same\n * embedded copy into a cache directory. One code path, same behaviour\n * everywhere.\n *\n * The cache is keyed by content hash, so an upgrade writes a new directory and\n * a dev build that changes a skill without changing the version still takes\n * effect. It is a cache, not user-editable state: `~/.agents/skills` is where a\n * user's own skills go, and nothing here ever writes there.\n */\n\nimport { createHash } from \"node:crypto\";\nimport { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { getAgentDir } from \"../config.js\";\nimport { EMBEDDED_SKILLS } from \"../init-templates.generated.js\";\n\n/** What decides whether a built-in skill is registered this session. */\nexport interface BuiltinSkillGate {\n\t/** The `enablePluginTools` setting (the plugin system's master switch). */\n\tenablePluginTools: boolean;\n}\n\nexport interface BuiltinSkill {\n\t/** Directory name under `templates/skills`, and the skill's own name. */\n\tname: string;\n\t/** Why hoocode ships it. Documentation, and the catalog test reads it. */\n\tsummary: string;\n\t/**\n\t * Registered only when this returns true. A skill costs its description on\n\t * every turn, so one that only makes sense alongside a feature rides that\n\t * feature's switch rather than the default user's token budget.\n\t *\n\t * Omit for a skill that should always be available.\n\t */\n\tgate?: (options: BuiltinSkillGate) => boolean;\n}\n\nexport const BUILTIN_SKILLS: readonly BuiltinSkill[] = [\n\t{\n\t\tname: \"artifact-design\",\n\t\tsummary:\n\t\t\t\"Craft for a self-contained HTML visual: read the treatment the request calls for, write the color/type/layout plan before the markup, and avoid the looks generated design keeps landing on.\",\n\t\t// No feature switch to ride - a visual can be asked for in any session,\n\t\t// and there is no setting that predicts it. Ungated, with the cost kept\n\t\t// to one tight description line.\n\t},\n\t{\n\t\tname: \"canvas-design\",\n\t\tsummary:\n\t\t\t\"The canvas half of design craft: template-string markup, no dependencies and no build, owning the theme outright, rendering state two operators both mutate, and what actions cost while an instance is open.\",\n\t\t// Costs nothing per turn: `disable-model-invocation` keeps it out of\n\t\t// <available_skills>, and canvasBuildBrief names its path at the one moment\n\t\t// it is needed. Materialization is unconditional, so the path is always\n\t\t// there to name.\n\t},\n\t{\n\t\tname: \"plugin-authoring\",\n\t\tsummary:\n\t\t\t\"The craft half of ProposePlugin/UpdatePlugin: when a capability is worth extracting, naming it so it triggers again, portability, and the hook trap.\",\n\t\t// Useless without the tools it describes, and those are off by default,\n\t\t// so this costs nothing for a user who never enables the plugin system.\n\t\tgate: (options) => options.enablePluginTools,\n\t},\n];\n\n/** Stable short hash of the embedded skill tree; the cache directory's name. */\nfunction contentHash(): string {\n\tconst hash = createHash(\"sha256\");\n\tfor (const key of Object.keys(EMBEDDED_SKILLS).sort()) {\n\t\thash.update(key);\n\t\thash.update(\"\\0\");\n\t\thash.update(EMBEDDED_SKILLS[key] ?? \"\");\n\t\thash.update(\"\\0\");\n\t}\n\treturn hash.digest(\"hex\").slice(0, 12);\n}\n\n/** Root of the materialized copy for the current content. */\nexport function builtinSkillsCacheDir(agentDir: string = getAgentDir()): string {\n\treturn join(agentDir, \"cache\", \"builtin-skills\", contentHash());\n}\n\n/**\n * Write the embedded skills to the cache directory if they are not already\n * there, and return its path.\n *\n * Returns null when nothing could be written — a read-only home, a full disk.\n * That is a degraded session, not a broken one: the caller contributes no skill\n * paths and hoocode runs exactly as it did before these existed.\n */\nexport function materializeBuiltinSkills(agentDir: string = getAgentDir()): string | null {\n\tconst root = builtinSkillsCacheDir(agentDir);\n\ttry {\n\t\tfor (const [relativePath, content] of Object.entries(EMBEDDED_SKILLS)) {\n\t\t\tconst target = join(root, relativePath);\n\t\t\t// Content is hash-addressed, so an existing file with the right size is\n\t\t\t// already correct; re-reading beats re-writing on every startup.\n\t\t\tif (existsSync(target) && readFileSync(target, \"utf-8\") === content) continue;\n\t\t\tmkdirSync(dirname(target), { recursive: true });\n\t\t\t// Write-then-rename so a killed process never leaves a half-written\n\t\t\t// SKILL.md that would parse as a malformed skill on the next run.\n\t\t\tconst temp = `${target}.${process.pid}.tmp`;\n\t\t\twriteFileSync(temp, content, \"utf-8\");\n\t\t\trenameSync(temp, target);\n\t\t}\n\t\treturn root;\n\t} catch {\n\t\t// Drop a partial tree so the next run rebuilds it rather than loading a\n\t\t// half-written skill. The cleanup gets its own guard: `force` swallows\n\t\t// ENOENT but not ENOTDIR, and a cleanup that throws would turn the\n\t\t// degraded path back into a crash — which is the failure this whole\n\t\t// branch exists to prevent.\n\t\ttry {\n\t\t\trmSync(root, { recursive: true, force: true });\n\t\t} catch {}\n\t\treturn null;\n\t}\n}\n\n/**\n * Absolute path to the canvas-design guide, or undefined if it is not on disk.\n *\n * `canvas-design` is hidden from the per-turn skill list, so nothing would ever\n * surface it without this: `/new-canvas` names the path in its build brief, at\n * the one moment the guidance is worth loading. Materialization is\n * unconditional, so the file is normally there whether or not any skill is\n * contributed — but a degraded session (read-only home, full disk) has no cache\n * at all, and then the brief simply ships without the line.\n */\nexport function canvasDesignGuidePath(agentDir: string = getAgentDir()): string | undefined {\n\tif (!materializeBuiltinSkills(agentDir)) return undefined;\n\tconst guide = join(builtinSkillsCacheDir(agentDir), \"canvas-design\", \"SKILL.md\");\n\treturn existsSync(guide) ? guide : undefined;\n}\n\n/**\n * The skill directories to load this session, after gating.\n *\n * Returns per-skill directories rather than the root so a gated-off skill is\n * genuinely absent rather than loaded and filtered later — the load is what\n * costs the description on every turn.\n */\nexport function builtinSkillPaths(gate: BuiltinSkillGate, agentDir: string = getAgentDir()): string[] {\n\tconst enabled = BUILTIN_SKILLS.filter((skill) => !skill.gate || skill.gate(gate));\n\tif (enabled.length === 0) return [];\n\n\tconst root = materializeBuiltinSkills(agentDir);\n\tif (!root) return [];\n\n\treturn enabled.map((skill) => join(root, skill.name)).filter((dir) => existsSync(dir));\n}\n"]}
@@ -25,6 +25,21 @@ import { dirname, join } from "node:path";
25
25
  import { getAgentDir } from "../config.js";
26
26
  import { EMBEDDED_SKILLS } from "../init-templates.generated.js";
27
27
  export const BUILTIN_SKILLS = [
28
+ {
29
+ name: "artifact-design",
30
+ summary: "Craft for a self-contained HTML visual: read the treatment the request calls for, write the color/type/layout plan before the markup, and avoid the looks generated design keeps landing on.",
31
+ // No feature switch to ride - a visual can be asked for in any session,
32
+ // and there is no setting that predicts it. Ungated, with the cost kept
33
+ // to one tight description line.
34
+ },
35
+ {
36
+ name: "canvas-design",
37
+ summary: "The canvas half of design craft: template-string markup, no dependencies and no build, owning the theme outright, rendering state two operators both mutate, and what actions cost while an instance is open.",
38
+ // Costs nothing per turn: `disable-model-invocation` keeps it out of
39
+ // <available_skills>, and canvasBuildBrief names its path at the one moment
40
+ // it is needed. Materialization is unconditional, so the path is always
41
+ // there to name.
42
+ },
28
43
  {
29
44
  name: "plugin-authoring",
30
45
  summary: "The craft half of ProposePlugin/UpdatePlugin: when a capability is worth extracting, naming it so it triggers again, portability, and the hook trap.",
@@ -87,6 +102,22 @@ export function materializeBuiltinSkills(agentDir = getAgentDir()) {
87
102
  return null;
88
103
  }
89
104
  }
105
+ /**
106
+ * Absolute path to the canvas-design guide, or undefined if it is not on disk.
107
+ *
108
+ * `canvas-design` is hidden from the per-turn skill list, so nothing would ever
109
+ * surface it without this: `/new-canvas` names the path in its build brief, at
110
+ * the one moment the guidance is worth loading. Materialization is
111
+ * unconditional, so the file is normally there whether or not any skill is
112
+ * contributed — but a degraded session (read-only home, full disk) has no cache
113
+ * at all, and then the brief simply ships without the line.
114
+ */
115
+ export function canvasDesignGuidePath(agentDir = getAgentDir()) {
116
+ if (!materializeBuiltinSkills(agentDir))
117
+ return undefined;
118
+ const guide = join(builtinSkillsCacheDir(agentDir), "canvas-design", "SKILL.md");
119
+ return existsSync(guide) ? guide : undefined;
120
+ }
90
121
  /**
91
122
  * The skill directories to load this session, after gating.
92
123
  *
@@ -1 +1 @@
1
- {"version":3,"file":"builtin-skills.js","sourceRoot":"","sources":["../../src/core/builtin-skills.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Skills hoocode itself ships.\n *\n * hoocode reads skills from `~/.agents/skills`, `.hoocode/skills`, `.claude/skills`\n * and installed packages — every source except its own. That gap is why it\n * shipped three subagents and zero skills while telling users skills are the\n * extension unit: there was simply nowhere for a first-party skill to live.\n *\n * The obstacle is that a skill's `<location>` has to be a real readable path —\n * the model loads a skill by `read`ing it — and the Bun-compiled binary has no\n * `templates/` beside it. So rather than resolve the package directory (which\n * differs across npm/pnpm/source/binary layouts and would give the compiled\n * binary a silently degraded skill set), every install materializes the same\n * embedded copy into a cache directory. One code path, same behaviour\n * everywhere.\n *\n * The cache is keyed by content hash, so an upgrade writes a new directory and\n * a dev build that changes a skill without changing the version still takes\n * effect. It is a cache, not user-editable state: `~/.agents/skills` is where a\n * user's own skills go, and nothing here ever writes there.\n */\n\nimport { createHash } from \"node:crypto\";\nimport { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { getAgentDir } from \"../config.js\";\nimport { EMBEDDED_SKILLS } from \"../init-templates.generated.js\";\n\n/** What decides whether a built-in skill is registered this session. */\nexport interface BuiltinSkillGate {\n\t/** The `enablePluginTools` setting (the plugin system's master switch). */\n\tenablePluginTools: boolean;\n}\n\nexport interface BuiltinSkill {\n\t/** Directory name under `templates/skills`, and the skill's own name. */\n\tname: string;\n\t/** Why hoocode ships it. Documentation, and the catalog test reads it. */\n\tsummary: string;\n\t/**\n\t * Registered only when this returns true. A skill costs its description on\n\t * every turn, so one that only makes sense alongside a feature rides that\n\t * feature's switch rather than the default user's token budget.\n\t *\n\t * Omit for a skill that should always be available.\n\t */\n\tgate?: (options: BuiltinSkillGate) => boolean;\n}\n\nexport const BUILTIN_SKILLS: readonly BuiltinSkill[] = [\n\t{\n\t\tname: \"plugin-authoring\",\n\t\tsummary:\n\t\t\t\"The craft half of ProposePlugin/UpdatePlugin: when a capability is worth extracting, naming it so it triggers again, portability, and the hook trap.\",\n\t\t// Useless without the tools it describes, and those are off by default,\n\t\t// so this costs nothing for a user who never enables the plugin system.\n\t\tgate: (options) => options.enablePluginTools,\n\t},\n];\n\n/** Stable short hash of the embedded skill tree; the cache directory's name. */\nfunction contentHash(): string {\n\tconst hash = createHash(\"sha256\");\n\tfor (const key of Object.keys(EMBEDDED_SKILLS).sort()) {\n\t\thash.update(key);\n\t\thash.update(\"\\0\");\n\t\thash.update(EMBEDDED_SKILLS[key] ?? \"\");\n\t\thash.update(\"\\0\");\n\t}\n\treturn hash.digest(\"hex\").slice(0, 12);\n}\n\n/** Root of the materialized copy for the current content. */\nexport function builtinSkillsCacheDir(agentDir: string = getAgentDir()): string {\n\treturn join(agentDir, \"cache\", \"builtin-skills\", contentHash());\n}\n\n/**\n * Write the embedded skills to the cache directory if they are not already\n * there, and return its path.\n *\n * Returns null when nothing could be written — a read-only home, a full disk.\n * That is a degraded session, not a broken one: the caller contributes no skill\n * paths and hoocode runs exactly as it did before these existed.\n */\nexport function materializeBuiltinSkills(agentDir: string = getAgentDir()): string | null {\n\tconst root = builtinSkillsCacheDir(agentDir);\n\ttry {\n\t\tfor (const [relativePath, content] of Object.entries(EMBEDDED_SKILLS)) {\n\t\t\tconst target = join(root, relativePath);\n\t\t\t// Content is hash-addressed, so an existing file with the right size is\n\t\t\t// already correct; re-reading beats re-writing on every startup.\n\t\t\tif (existsSync(target) && readFileSync(target, \"utf-8\") === content) continue;\n\t\t\tmkdirSync(dirname(target), { recursive: true });\n\t\t\t// Write-then-rename so a killed process never leaves a half-written\n\t\t\t// SKILL.md that would parse as a malformed skill on the next run.\n\t\t\tconst temp = `${target}.${process.pid}.tmp`;\n\t\t\twriteFileSync(temp, content, \"utf-8\");\n\t\t\trenameSync(temp, target);\n\t\t}\n\t\treturn root;\n\t} catch {\n\t\t// Drop a partial tree so the next run rebuilds it rather than loading a\n\t\t// half-written skill. The cleanup gets its own guard: `force` swallows\n\t\t// ENOENT but not ENOTDIR, and a cleanup that throws would turn the\n\t\t// degraded path back into a crash — which is the failure this whole\n\t\t// branch exists to prevent.\n\t\ttry {\n\t\t\trmSync(root, { recursive: true, force: true });\n\t\t} catch {}\n\t\treturn null;\n\t}\n}\n\n/**\n * The skill directories to load this session, after gating.\n *\n * Returns per-skill directories rather than the root so a gated-off skill is\n * genuinely absent rather than loaded and filtered later — the load is what\n * costs the description on every turn.\n */\nexport function builtinSkillPaths(gate: BuiltinSkillGate, agentDir: string = getAgentDir()): string[] {\n\tconst enabled = BUILTIN_SKILLS.filter((skill) => !skill.gate || skill.gate(gate));\n\tif (enabled.length === 0) return [];\n\n\tconst root = materializeBuiltinSkills(agentDir);\n\tif (!root) return [];\n\n\treturn enabled.map((skill) => join(root, skill.name)).filter((dir) => existsSync(dir));\n}\n"]}
1
+ {"version":3,"file":"builtin-skills.js","sourceRoot":"","sources":["../../src/core/builtin-skills.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Skills hoocode itself ships.\n *\n * hoocode reads skills from `~/.agents/skills`, `.hoocode/skills`, `.claude/skills`\n * and installed packages — every source except its own. That gap is why it\n * shipped three subagents and zero skills while telling users skills are the\n * extension unit: there was simply nowhere for a first-party skill to live.\n *\n * The obstacle is that a skill's `<location>` has to be a real readable path —\n * the model loads a skill by `read`ing it — and the Bun-compiled binary has no\n * `templates/` beside it. So rather than resolve the package directory (which\n * differs across npm/pnpm/source/binary layouts and would give the compiled\n * binary a silently degraded skill set), every install materializes the same\n * embedded copy into a cache directory. One code path, same behaviour\n * everywhere.\n *\n * The cache is keyed by content hash, so an upgrade writes a new directory and\n * a dev build that changes a skill without changing the version still takes\n * effect. It is a cache, not user-editable state: `~/.agents/skills` is where a\n * user's own skills go, and nothing here ever writes there.\n */\n\nimport { createHash } from \"node:crypto\";\nimport { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { getAgentDir } from \"../config.js\";\nimport { EMBEDDED_SKILLS } from \"../init-templates.generated.js\";\n\n/** What decides whether a built-in skill is registered this session. */\nexport interface BuiltinSkillGate {\n\t/** The `enablePluginTools` setting (the plugin system's master switch). */\n\tenablePluginTools: boolean;\n}\n\nexport interface BuiltinSkill {\n\t/** Directory name under `templates/skills`, and the skill's own name. */\n\tname: string;\n\t/** Why hoocode ships it. Documentation, and the catalog test reads it. */\n\tsummary: string;\n\t/**\n\t * Registered only when this returns true. A skill costs its description on\n\t * every turn, so one that only makes sense alongside a feature rides that\n\t * feature's switch rather than the default user's token budget.\n\t *\n\t * Omit for a skill that should always be available.\n\t */\n\tgate?: (options: BuiltinSkillGate) => boolean;\n}\n\nexport const BUILTIN_SKILLS: readonly BuiltinSkill[] = [\n\t{\n\t\tname: \"artifact-design\",\n\t\tsummary:\n\t\t\t\"Craft for a self-contained HTML visual: read the treatment the request calls for, write the color/type/layout plan before the markup, and avoid the looks generated design keeps landing on.\",\n\t\t// No feature switch to ride - a visual can be asked for in any session,\n\t\t// and there is no setting that predicts it. Ungated, with the cost kept\n\t\t// to one tight description line.\n\t},\n\t{\n\t\tname: \"canvas-design\",\n\t\tsummary:\n\t\t\t\"The canvas half of design craft: template-string markup, no dependencies and no build, owning the theme outright, rendering state two operators both mutate, and what actions cost while an instance is open.\",\n\t\t// Costs nothing per turn: `disable-model-invocation` keeps it out of\n\t\t// <available_skills>, and canvasBuildBrief names its path at the one moment\n\t\t// it is needed. Materialization is unconditional, so the path is always\n\t\t// there to name.\n\t},\n\t{\n\t\tname: \"plugin-authoring\",\n\t\tsummary:\n\t\t\t\"The craft half of ProposePlugin/UpdatePlugin: when a capability is worth extracting, naming it so it triggers again, portability, and the hook trap.\",\n\t\t// Useless without the tools it describes, and those are off by default,\n\t\t// so this costs nothing for a user who never enables the plugin system.\n\t\tgate: (options) => options.enablePluginTools,\n\t},\n];\n\n/** Stable short hash of the embedded skill tree; the cache directory's name. */\nfunction contentHash(): string {\n\tconst hash = createHash(\"sha256\");\n\tfor (const key of Object.keys(EMBEDDED_SKILLS).sort()) {\n\t\thash.update(key);\n\t\thash.update(\"\\0\");\n\t\thash.update(EMBEDDED_SKILLS[key] ?? \"\");\n\t\thash.update(\"\\0\");\n\t}\n\treturn hash.digest(\"hex\").slice(0, 12);\n}\n\n/** Root of the materialized copy for the current content. */\nexport function builtinSkillsCacheDir(agentDir: string = getAgentDir()): string {\n\treturn join(agentDir, \"cache\", \"builtin-skills\", contentHash());\n}\n\n/**\n * Write the embedded skills to the cache directory if they are not already\n * there, and return its path.\n *\n * Returns null when nothing could be written — a read-only home, a full disk.\n * That is a degraded session, not a broken one: the caller contributes no skill\n * paths and hoocode runs exactly as it did before these existed.\n */\nexport function materializeBuiltinSkills(agentDir: string = getAgentDir()): string | null {\n\tconst root = builtinSkillsCacheDir(agentDir);\n\ttry {\n\t\tfor (const [relativePath, content] of Object.entries(EMBEDDED_SKILLS)) {\n\t\t\tconst target = join(root, relativePath);\n\t\t\t// Content is hash-addressed, so an existing file with the right size is\n\t\t\t// already correct; re-reading beats re-writing on every startup.\n\t\t\tif (existsSync(target) && readFileSync(target, \"utf-8\") === content) continue;\n\t\t\tmkdirSync(dirname(target), { recursive: true });\n\t\t\t// Write-then-rename so a killed process never leaves a half-written\n\t\t\t// SKILL.md that would parse as a malformed skill on the next run.\n\t\t\tconst temp = `${target}.${process.pid}.tmp`;\n\t\t\twriteFileSync(temp, content, \"utf-8\");\n\t\t\trenameSync(temp, target);\n\t\t}\n\t\treturn root;\n\t} catch {\n\t\t// Drop a partial tree so the next run rebuilds it rather than loading a\n\t\t// half-written skill. The cleanup gets its own guard: `force` swallows\n\t\t// ENOENT but not ENOTDIR, and a cleanup that throws would turn the\n\t\t// degraded path back into a crash — which is the failure this whole\n\t\t// branch exists to prevent.\n\t\ttry {\n\t\t\trmSync(root, { recursive: true, force: true });\n\t\t} catch {}\n\t\treturn null;\n\t}\n}\n\n/**\n * Absolute path to the canvas-design guide, or undefined if it is not on disk.\n *\n * `canvas-design` is hidden from the per-turn skill list, so nothing would ever\n * surface it without this: `/new-canvas` names the path in its build brief, at\n * the one moment the guidance is worth loading. Materialization is\n * unconditional, so the file is normally there whether or not any skill is\n * contributed — but a degraded session (read-only home, full disk) has no cache\n * at all, and then the brief simply ships without the line.\n */\nexport function canvasDesignGuidePath(agentDir: string = getAgentDir()): string | undefined {\n\tif (!materializeBuiltinSkills(agentDir)) return undefined;\n\tconst guide = join(builtinSkillsCacheDir(agentDir), \"canvas-design\", \"SKILL.md\");\n\treturn existsSync(guide) ? guide : undefined;\n}\n\n/**\n * The skill directories to load this session, after gating.\n *\n * Returns per-skill directories rather than the root so a gated-off skill is\n * genuinely absent rather than loaded and filtered later — the load is what\n * costs the description on every turn.\n */\nexport function builtinSkillPaths(gate: BuiltinSkillGate, agentDir: string = getAgentDir()): string[] {\n\tconst enabled = BUILTIN_SKILLS.filter((skill) => !skill.gate || skill.gate(gate));\n\tif (enabled.length === 0) return [];\n\n\tconst root = materializeBuiltinSkills(agentDir);\n\tif (!root) return [];\n\n\treturn enabled.map((skill) => join(root, skill.name)).filter((dir) => existsSync(dir));\n}\n"]}
@@ -119,5 +119,5 @@ export interface CanvasBriefTarget {
119
119
  * against no longer exists. `displayName` is the half they actually see, so it
120
120
  * is the half to change.
121
121
  */
122
- export declare function canvasBuildBrief(name: string, description: string, entryPath: string, target: CanvasBriefTarget | undefined): string;
122
+ export declare function canvasBuildBrief(name: string, description: string, entryPath: string, target: CanvasBriefTarget | undefined, guidePath?: string): string;
123
123
  //# sourceMappingURL=scaffold.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"scaffold.d.ts","sourceRoot":"","sources":["../../../src/core/canvas/scaffold.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAIH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,wCAAwC,CAAC;AAGlF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,YAAY,EAAE,OAAO,CAAC,MAAM,CAAC,mBAAmB,EAAE,MAAM,EAAE,CAAC,CAGvE,CAAC;AAEF,gGAAgG;AAChG,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAM9D;AA+GD;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAsBjF;AAED,6CAA6C;AAC7C,MAAM,WAAW,aAAa;IAC7B,uCAAuC;IACvC,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;CAChC;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,aAAa,GAAG,MAAM,CAkBxE;AAED;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,qBAAqB,0BAkFjC,CAAC;AAEF,yCAAyC;AACzC,MAAM,WAAW,oBAAoB;IACpC,uEAAuE;IACvE,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,+EAA+E;IAC/E,OAAO,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,mBAAmB,EAAE,GAAG,oBAAoB,CAiBhH;AAED,6DAA6D;AAC7D,MAAM,WAAW,iBAAiB;IACjC,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,EAAE,MAAM,GAAG,SAAS,CAAC;CACxB;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,gBAAgB,CAC/B,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,iBAAiB,GAAG,SAAS,GACnC,MAAM,CAsCR","sourcesContent":["/**\n * Authoring a canvas: the request a person types, the files it writes, and the\n * brief the model builds from.\n *\n * Design: `docs/canvas-extensions-design.md` §9 Phase 3, §13.\n *\n * This lives in `core/` rather than beside the other `/new-*` scaffolds because\n * `/new-canvas` stopped being a file-writing command. Copilot's `/create-canvas`\n * takes a sentence, has the agent write the extension, and opens it in a panel to\n * iterate on; matching that means the command has to reach the canvas session to\n * open, and the agent loop to build. Every decision that does not need either —\n * what the name is, where the file goes, what goes in it, what the model is told —\n * is here, testable without a terminal, a fork or a model.\n */\n\nimport { existsSync, mkdirSync, writeFileSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport type { MarketplacePlatform } from \"../extensions/plugins/formats/types.js\";\nimport { CANVAS_ENTRY_FILE } from \"./discovery.js\";\n\n/**\n * Where a canvas extension lives, per platform.\n *\n * This does not go through `WorkspaceLayout` like the other scaffolds, and the\n * reason is that a canvas has no Claude convention to emit into: the surface\n * exists in Copilot and in hoocode's own `.agents/` tree and nowhere else. A\n * layout method returning nothing for one adapter would be a worse lie than\n * naming the two real homes here — these are exactly the roots\n * `core/canvas/discovery.ts` searches, which is what makes a scaffold live on the\n * next `/canvas`.\n */\nexport const CANVAS_HOMES: Partial<Record<MarketplacePlatform, string[]>> = {\n\tagents: [\".agents\", \"extensions\"],\n\tgithub: [\".github\", \"extensions\"],\n};\n\n/** Validates a canvas name: lowercase a-z, 0-9, hyphens, no leading/trailing/double hyphens. */\nexport function validateCanvasName(name: string): string | null {\n\tif (!name) return \"name is required\";\n\tif (!/^[a-z0-9-]+$/.test(name)) return \"name must be lowercase a-z, 0-9, and hyphens only\";\n\tif (name.startsWith(\"-\") || name.endsWith(\"-\")) return \"name must not start or end with a hyphen\";\n\tif (name.includes(\"--\")) return \"name must not contain consecutive hyphens\";\n\treturn null;\n}\n\n/**\n * Words dropped wherever they appear, because they carry no subject.\n *\n * Only grammar, never subject matter: a list that reached further would start\n * deciding which of someone's nouns mattered.\n */\nconst FILLER = new Set([\n\t\"a\",\n\t\"about\",\n\t\"across\",\n\t\"an\",\n\t\"and\",\n\t\"are\",\n\t\"as\",\n\t\"at\",\n\t\"by\",\n\t\"be\",\n\t\"can\",\n\t\"for\",\n\t\"from\",\n\t\"i\",\n\t\"in\",\n\t\"is\",\n\t\"into\",\n\t\"it\",\n\t\"its\",\n\t\"me\",\n\t\"my\",\n\t\"of\",\n\t\"on\",\n\t\"one\",\n\t\"or\",\n\t\"our\",\n\t\"over\",\n\t\"so\",\n\t\"that\",\n\t\"the\",\n\t\"their\",\n\t\"them\",\n\t\"then\",\n\t\"this\",\n\t\"to\",\n\t\"us\",\n\t\"we\",\n\t\"which\",\n\t\"via\",\n\t\"when\",\n\t\"where\",\n\t\"while\",\n\t\"with\",\n\t\"you\",\n\t\"your\",\n]);\n\n/**\n * Words that open a request rather than describe one.\n *\n * People type `/new-canvas` as an instruction — \"create a…\", \"build me a…\",\n * \"help me…\", \"I want to…\" — and the opening clause was landing in the directory\n * name: `create-lightweight-games`, `help-compare-two`, `want-review-pull`. On a\n * spread of twelve realistic descriptions, seven produced a name that named the\n * request instead of the thing.\n *\n * These are dropped anywhere, not only at the front, because \"add a canvas that\n * shows X\" buries one in the middle.\n */\nconst REQUEST_WORDS = new Set([\n\t\"add\",\n\t\"build\",\n\t\"could\",\n\t\"create\",\n\t\"design\",\n\t\"generate\",\n\t\"give\",\n\t\"help\",\n\t\"let\",\n\t\"make\",\n\t\"need\",\n\t\"new\",\n\t\"please\",\n\t\"produce\",\n\t\"set\",\n\t\"show\",\n\t\"something\",\n\t\"up\",\n\t\"want\",\n\t\"would\",\n]);\n\n/** Counting words: \"compare two benchmark runs\" is about the runs. */\nconst CARDINALS = new Set([\"two\", \"three\", \"four\", \"five\", \"six\", \"seven\", \"eight\", \"nine\", \"ten\"]);\n\n/** How many words a derived name keeps. Enough to be recognisable, short enough to type. */\nconst DERIVED_NAME_WORDS = 3;\n\n/**\n * Whether a word is probably a verb form rather than the thing being described.\n *\n * Crude on purpose — no part-of-speech tagger for a directory name. It exists\n * because a participle sits between the request and its subject and pushes the\n * subject out of a three-word name: \"a dashboard **showing** flaky tests\" became\n * `dashboard-showing-flaky`, and \"a spreadsheet for **tracking** API latency\"\n * became `spreadsheet-tracking-api`. The length floor spares short words where\n * the ending is a coincidence (`feed`, `used`, `ring`).\n */\nfunction looksLikeVerbForm(word: string): boolean {\n\treturn word.length > 4 && (word.endsWith(\"ing\") || word.endsWith(\"ed\"));\n}\n\n/**\n * Turn a sentence into a directory name.\n *\n * Returns undefined when nothing usable survives — an all-punctuation request, or\n * a sentence of nothing but filler — because inventing `canvas-1` would hide from\n * the person that we did not understand them.\n */\nexport function canvasNameFromDescription(description: string): string | undefined {\n\tconst words = description\n\t\t.toLowerCase()\n\t\t.replace(/[^a-z0-9]+/g, \" \")\n\t\t.trim()\n\t\t.split(\" \")\n\t\t.filter((word) => word.length > 0);\n\n\tconst content = words.filter((word) => !FILLER.has(word) && !REQUEST_WORDS.has(word) && !CARDINALS.has(word));\n\t// \"canvas\" is dropped only when something else remains: \"/new-canvas a canvas\"\n\t// should still produce `canvas` rather than nothing.\n\tconst named = content.filter((word) => word !== \"canvas\");\n\tlet kept = named.length > 0 ? named : content;\n\n\t// Prefer nouns — but only while enough of them remain. Where they do not, the\n\t// participle *is* the subject (\"a canvas for onboarding\") and dropping it would\n\t// leave nothing worth naming.\n\tconst nouns = kept.filter((word) => !looksLikeVerbForm(word));\n\tif (nouns.length >= 2) kept = nouns;\n\n\tconst name = kept.slice(0, DERIVED_NAME_WORDS).join(\"-\");\n\treturn validateCanvasName(name) === null ? name : undefined;\n}\n\n/** What a person asked `/new-canvas` for. */\nexport interface CanvasRequest {\n\t/** Directory and default canvas id. */\n\tname: string;\n\t/**\n\t * What they want it to do, in their words, or undefined when they only named\n\t * one. Present means the model is expected to build it (Copilot's\n\t * `/create-canvas` shape); absent means they want the template to edit by hand.\n\t */\n\tdescription: string | undefined;\n}\n\n/**\n * Parse `/new-canvas`'s argument.\n *\n * Three shapes, in the order they are tested:\n *\n * - `my-board` — a bare name. Unchanged from before descriptions existed, and\n * tested first so it can never be re-read as a one-word description.\n * - `my-board: a kanban board for the release checklist` — both, when someone\n * cares what the directory is called. A colon rather than a flag because the\n * rest of the line is prose and a flag parser would have to guess where it ends.\n * - `a kanban board for the release checklist` — a description, the shape\n * `/create-canvas` uses. The name is derived and reported.\n *\n * Returns a string when the input cannot become a canvas; the caller shows it.\n */\nexport function parseCanvasRequest(input: string): CanvasRequest | string {\n\tconst trimmed = input.trim();\n\tif (trimmed.length === 0) return \"name or description is required\";\n\n\tif (validateCanvasName(trimmed) === null) return { name: trimmed, description: undefined };\n\n\tconst colon = trimmed.indexOf(\":\");\n\tif (colon > 0) {\n\t\tconst name = trimmed.slice(0, colon).trim();\n\t\tconst description = trimmed.slice(colon + 1).trim();\n\t\tif (validateCanvasName(name) === null && description.length > 0) return { name, description };\n\t}\n\n\tconst derived = canvasNameFromDescription(trimmed);\n\tif (!derived) {\n\t\treturn `could not derive a directory name from that. Give one explicitly: /new-canvas <name>: ${trimmed}`;\n\t}\n\treturn { name: derived, description: trimmed };\n}\n\n/**\n * A working single-canvas extension.\n *\n * Deliberately complete rather than a stub: a canvas has no passive half — its\n * name, its actions and its UI all come from running its code — so a scaffold\n * that does not run teaches nothing and cannot be checked with `/canvas open`.\n * This one opens, serves a page, answers an action, and closes cleanly, which is\n * the whole contract; everything past that is the author's.\n *\n * Shaped like the catalog extensions hoocode already hosts: the only import is\n * `@github/copilot-sdk/extension` (host-resolved — never installed, see\n * `docs/canvas-extensions-design.md` §4.1) plus `node:` builtins, and the UI is\n * served from an ephemeral loopback port behind a per-instance token so nothing\n * else on the machine can read it.\n */\nexport const CANVAS_ENTRY_TEMPLATE = (name: string): string => `\\\n// A canvas extension. Run it with: /canvas open ${name}\n//\n// The \"@github/copilot-sdk/extension\" import is resolved by the host at fork\n// time. Do not install it, and do not add a node_modules here.\nimport { createCanvas, CanvasError, joinSession } from \"@github/copilot-sdk/extension\";\nimport { randomBytes } from \"node:crypto\";\nimport { createServer } from \"node:http\";\n\n// The canvas's identity, in one place. An open instance is bound to this id, so\n// changing it drops the canvas anyone is currently looking at — rename the\n// canvas with \\`/canvas rename\\`, or change NAME alone if you only want a\n// different label on the page.\nconst ID = \"${name}\";\nconst NAME = \"${name}\";\n\n/** Per-instance state. A canvas can be opened more than once at a time. */\nconst instances = new Map();\n\nconst session = await joinSession({\n\tcanvases: [\n\t\tcreateCanvas({\n\t\t\tid: ID,\n\t\t\tdisplayName: NAME,\n\t\t\tdescription: \"TODO: one sentence — the agent reads this to decide whether to open it.\",\n\t\t\tactions: [\n\t\t\t\t{\n\t\t\t\t\tname: \"add_note\",\n\t\t\t\t\tdescription: \"TODO: describe what the agent can do to this canvas.\",\n\t\t\t\t\tinputSchema: {\n\t\t\t\t\t\ttype: \"object\",\n\t\t\t\t\t\tproperties: { text: { type: \"string\" } },\n\t\t\t\t\t\trequired: [\"text\"],\n\t\t\t\t\t},\n\t\t\t\t\thandler: (ctx) => {\n\t\t\t\t\t\tconst entry = instances.get(ctx.instanceId);\n\t\t\t\t\t\t// CanvasError carries a code the host shows verbatim; a bare\n\t\t\t\t\t\t// throw arrives as an opaque internal error instead.\n\t\t\t\t\t\tif (!entry) throw new CanvasError(\"no_instance\", \\`Instance \"\\${ctx.instanceId}\" is not open.\\`);\n\t\t\t\t\t\tentry.notes.push(ctx.input.text);\n\t\t\t\t\t\treturn { notes: entry.notes.length };\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t],\n\t\t\topen: async (ctx) => {\n\t\t\t\tconst token = randomBytes(32).toString(\"base64url\");\n\t\t\t\tconst notes = [];\n\t\t\t\tconst server = createServer((req, res) => {\n\t\t\t\t\tconst url = new URL(req.url ?? \"/\", \"http://127.0.0.1\");\n\t\t\t\t\tif (url.searchParams.get(\"token\") !== token) {\n\t\t\t\t\t\tres.writeHead(403, { \"Content-Type\": \"text/plain\" });\n\t\t\t\t\t\tres.end(\"forbidden\");\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tres.writeHead(200, { \"Content-Type\": \"text/html; charset=utf-8\" });\n\t\t\t\t\tres.end(\n\t\t\t\t\t\t\\`<!doctype html><meta charset=\"utf-8\"><title>\\${NAME}</title>\\` +\n\t\t\t\t\t\t\t\\`<p>\\${notes.length} note(s). TODO: build the UI.\\`,\n\t\t\t\t\t);\n\t\t\t\t});\n\t\t\t\t// Port 0 on 127.0.0.1: an ephemeral port, reachable only from this machine.\n\t\t\t\tawait new Promise((resolve) => server.listen(0, \"127.0.0.1\", resolve));\n\t\t\t\tconst { port } = server.address();\n\t\t\t\tinstances.set(ctx.instanceId, { server, notes });\n\t\t\t\treturn {\n\t\t\t\t\turl: \\`http://127.0.0.1:\\${port}/?token=\\${token}\\`,\n\t\t\t\t\ttitle: NAME,\n\t\t\t\t};\n\t\t\t},\n\t\t\tonClose: async (ctx) => {\n\t\t\t\tconst entry = instances.get(ctx.instanceId);\n\t\t\t\t// Called for an abandoned open too, so the instance may be unknown.\n\t\t\t\tif (!entry) return;\n\t\t\t\tinstances.delete(ctx.instanceId);\n\t\t\t\tawait new Promise((resolve) => entry.server.close(resolve));\n\t\t\t},\n\t\t}),\n\t],\n});\n\n// stdout is the protocol channel. Use session.log, never console.log.\nawait session.log(\\`\\${ID} ready\\`);\n`;\n\n/** What {@link scaffoldCanvas} wrote. */\nexport interface CanvasScaffoldResult {\n\t/** Workspace-relative entry files created, one per platform target. */\n\tcreated: string[];\n\t/** Workspace-relative entry files that already existed and were left alone. */\n\tskipped: string[];\n}\n\n/**\n * Write the template into every platform target that has a canvas home.\n *\n * Existing files are never clobbered — they are reported and skipped, so running\n * `/new-canvas` twice on a canvas you have been editing cannot lose it.\n */\nexport function scaffoldCanvas(cwd: string, name: string, platforms: MarketplacePlatform[]): CanvasScaffoldResult {\n\tconst created: string[] = [];\n\tconst skipped: string[] = [];\n\tfor (const platform of platforms) {\n\t\tconst home = CANVAS_HOMES[platform];\n\t\tif (!home) continue;\n\t\tconst relative = join(...home, name, CANVAS_ENTRY_FILE);\n\t\tconst absolute = join(cwd, relative);\n\t\tif (existsSync(absolute)) {\n\t\t\tskipped.push(relative);\n\t\t\tcontinue;\n\t\t}\n\t\tmkdirSync(dirname(absolute), { recursive: true });\n\t\twriteFileSync(absolute, CANVAS_ENTRY_TEMPLATE(name), \"utf8\");\n\t\tcreated.push(relative);\n\t}\n\treturn { created, skipped };\n}\n\n/** Where a build brief's canvas is, if opening it worked. */\nexport interface CanvasBriefTarget {\n\tinstanceId: string;\n\turl: string | undefined;\n}\n\n/**\n * The message the model builds from — hoocode's half of `/create-canvas`.\n *\n * A scaffold plus a sentence is not a canvas, and the gap between them is the\n * agent's work. This is what turns \"a kanban board for the release checklist\"\n * into a build task with the contract attached, so the model does not have to\n * infer the rules of a surface it cannot see from a template it has not read.\n *\n * Four of those rules are stated because getting them wrong fails in ways whose\n * symptom does not name the cause: an installed dependency (the resolver already\n * provides the SDK, and a `node_modules` here is a §4.1 violation), a\n * `console.log` (corrupts the JSON-RPC channel and surfaces as \"non-protocol\n * stdout\"), a write without a reload (changes nothing at all, because the running\n * child was forked from the old bytes), and a renamed canvas id.\n *\n * That last one is the newest and was found the hard way, by building a canvas\n * with this command: the scaffold names the canvas after the directory, a model\n * that thinks of a better name renames the `id`, and the next reload drops the\n * instance the person is looking at — correctly, since the canvas it was opened\n * against no longer exists. `displayName` is the half they actually see, so it\n * is the half to change.\n */\nexport function canvasBuildBrief(\n\tname: string,\n\tdescription: string,\n\tentryPath: string,\n\ttarget: CanvasBriefTarget | undefined,\n): string {\n\tconst lines = [\n\t\t`/new-canvas: build a canvas extension named \"${name}\".`,\n\t\t\"\",\n\t\t\"What the person asked for, in their words:\",\n\t\t` ${description}`,\n\t\t\"\",\n\t\t`A working template is already at ${entryPath}. Edit it until it does what was asked.`,\n\t];\n\n\tif (target) {\n\t\tlines.push(\n\t\t\t\"\",\n\t\t\ttarget.url\n\t\t\t\t? `It is already open at ${target.url} (instance ${target.instanceId}), so the person is watching it as you work.`\n\t\t\t\t: `It is already open as instance ${target.instanceId}.`,\n\t\t\t`After every edit, call reload_canvas with extensionId \"${name}\". A write to the file changes nothing on its own — the running process was forked from the old code. Reloading hands back a NEW url; give it to the person, because the tab they have open dies with the old process.`,\n\t\t);\n\t} else {\n\t\tlines.push(\n\t\t\t\"\",\n\t\t\t`It is not open — the person will run /canvas open ${name} when they want to look. Build it anyway; you can check it runs by reading it carefully rather than by opening it yourself.`,\n\t\t);\n\t}\n\n\tlines.push(\n\t\t\"\",\n\t\t\"The contract this surface runs under, which is not yours to change:\",\n\t\t'- The only non-`node:` import allowed is \"@github/copilot-sdk/extension\". The host resolves it when it forks the extension. Do not install anything, and do not add a package.json or node_modules in the extension directory.',\n\t\t\"- stdout is the JSON-RPC channel. Use `session.log(...)`; a `console.log` corrupts the protocol.\",\n\t\t\"- Do not change the canvas's `id` (the template holds it in `ID`). An open instance is bound to it, so renaming it drops the canvas the person is watching on your next reload. Change `NAME` for a nicer label — that is the one they see — and if the directory name itself is wrong, say so and let them run `/canvas rename`, which moves everything at once.\",\n\t\t\"- After a reload, read the action list it reports back. That is the host telling you which of your actions it can actually see, and it is the only confirmation an action you just wrote is really callable.\",\n\t\t\"- Serve the UI from the loopback server the template already starts. Keep the per-instance token check, and keep `onClose` shutting the server down — that is what stops a port outliving the session.\",\n\t\t\"- Everything in `actions: [...]` becomes callable by you through invoke_canvas_action once it is open, so give each action a real description and inputSchema. That list is how you drive the canvas; the person drives the same state through the page.\",\n\t\t\"\",\n\t\t\"Build the thing that was asked for, not a stub: leave no TODO behind, and make the page render real state rather than a placeholder.\",\n\t);\n\treturn lines.join(\"\\n\");\n}\n"]}
1
+ {"version":3,"file":"scaffold.d.ts","sourceRoot":"","sources":["../../../src/core/canvas/scaffold.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAIH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,wCAAwC,CAAC;AAGlF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,YAAY,EAAE,OAAO,CAAC,MAAM,CAAC,mBAAmB,EAAE,MAAM,EAAE,CAAC,CAGvE,CAAC;AAEF,gGAAgG;AAChG,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAM9D;AA+GD;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAsBjF;AAED,6CAA6C;AAC7C,MAAM,WAAW,aAAa;IAC7B,uCAAuC;IACvC,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;CAChC;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,aAAa,GAAG,MAAM,CAkBxE;AAED;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,qBAAqB,0BA2KjC,CAAC;AAEF,yCAAyC;AACzC,MAAM,WAAW,oBAAoB;IACpC,uEAAuE;IACvE,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,+EAA+E;IAC/E,OAAO,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,mBAAmB,EAAE,GAAG,oBAAoB,CAiBhH;AAED,6DAA6D;AAC7D,MAAM,WAAW,iBAAiB;IACjC,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,EAAE,MAAM,GAAG,SAAS,CAAC;CACxB;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,gBAAgB,CAC/B,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,iBAAiB,GAAG,SAAS,EACrC,SAAS,CAAC,EAAE,MAAM,GAChB,MAAM,CAgDR","sourcesContent":["/**\n * Authoring a canvas: the request a person types, the files it writes, and the\n * brief the model builds from.\n *\n * Design: `docs/canvas-extensions-design.md` §9 Phase 3, §13.\n *\n * This lives in `core/` rather than beside the other `/new-*` scaffolds because\n * `/new-canvas` stopped being a file-writing command. Copilot's `/create-canvas`\n * takes a sentence, has the agent write the extension, and opens it in a panel to\n * iterate on; matching that means the command has to reach the canvas session to\n * open, and the agent loop to build. Every decision that does not need either —\n * what the name is, where the file goes, what goes in it, what the model is told —\n * is here, testable without a terminal, a fork or a model.\n */\n\nimport { existsSync, mkdirSync, writeFileSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport type { MarketplacePlatform } from \"../extensions/plugins/formats/types.js\";\nimport { CANVAS_ENTRY_FILE } from \"./discovery.js\";\n\n/**\n * Where a canvas extension lives, per platform.\n *\n * This does not go through `WorkspaceLayout` like the other scaffolds, and the\n * reason is that a canvas has no Claude convention to emit into: the surface\n * exists in Copilot and in hoocode's own `.agents/` tree and nowhere else. A\n * layout method returning nothing for one adapter would be a worse lie than\n * naming the two real homes here — these are exactly the roots\n * `core/canvas/discovery.ts` searches, which is what makes a scaffold live on the\n * next `/canvas`.\n */\nexport const CANVAS_HOMES: Partial<Record<MarketplacePlatform, string[]>> = {\n\tagents: [\".agents\", \"extensions\"],\n\tgithub: [\".github\", \"extensions\"],\n};\n\n/** Validates a canvas name: lowercase a-z, 0-9, hyphens, no leading/trailing/double hyphens. */\nexport function validateCanvasName(name: string): string | null {\n\tif (!name) return \"name is required\";\n\tif (!/^[a-z0-9-]+$/.test(name)) return \"name must be lowercase a-z, 0-9, and hyphens only\";\n\tif (name.startsWith(\"-\") || name.endsWith(\"-\")) return \"name must not start or end with a hyphen\";\n\tif (name.includes(\"--\")) return \"name must not contain consecutive hyphens\";\n\treturn null;\n}\n\n/**\n * Words dropped wherever they appear, because they carry no subject.\n *\n * Only grammar, never subject matter: a list that reached further would start\n * deciding which of someone's nouns mattered.\n */\nconst FILLER = new Set([\n\t\"a\",\n\t\"about\",\n\t\"across\",\n\t\"an\",\n\t\"and\",\n\t\"are\",\n\t\"as\",\n\t\"at\",\n\t\"by\",\n\t\"be\",\n\t\"can\",\n\t\"for\",\n\t\"from\",\n\t\"i\",\n\t\"in\",\n\t\"is\",\n\t\"into\",\n\t\"it\",\n\t\"its\",\n\t\"me\",\n\t\"my\",\n\t\"of\",\n\t\"on\",\n\t\"one\",\n\t\"or\",\n\t\"our\",\n\t\"over\",\n\t\"so\",\n\t\"that\",\n\t\"the\",\n\t\"their\",\n\t\"them\",\n\t\"then\",\n\t\"this\",\n\t\"to\",\n\t\"us\",\n\t\"we\",\n\t\"which\",\n\t\"via\",\n\t\"when\",\n\t\"where\",\n\t\"while\",\n\t\"with\",\n\t\"you\",\n\t\"your\",\n]);\n\n/**\n * Words that open a request rather than describe one.\n *\n * People type `/new-canvas` as an instruction — \"create a…\", \"build me a…\",\n * \"help me…\", \"I want to…\" — and the opening clause was landing in the directory\n * name: `create-lightweight-games`, `help-compare-two`, `want-review-pull`. On a\n * spread of twelve realistic descriptions, seven produced a name that named the\n * request instead of the thing.\n *\n * These are dropped anywhere, not only at the front, because \"add a canvas that\n * shows X\" buries one in the middle.\n */\nconst REQUEST_WORDS = new Set([\n\t\"add\",\n\t\"build\",\n\t\"could\",\n\t\"create\",\n\t\"design\",\n\t\"generate\",\n\t\"give\",\n\t\"help\",\n\t\"let\",\n\t\"make\",\n\t\"need\",\n\t\"new\",\n\t\"please\",\n\t\"produce\",\n\t\"set\",\n\t\"show\",\n\t\"something\",\n\t\"up\",\n\t\"want\",\n\t\"would\",\n]);\n\n/** Counting words: \"compare two benchmark runs\" is about the runs. */\nconst CARDINALS = new Set([\"two\", \"three\", \"four\", \"five\", \"six\", \"seven\", \"eight\", \"nine\", \"ten\"]);\n\n/** How many words a derived name keeps. Enough to be recognisable, short enough to type. */\nconst DERIVED_NAME_WORDS = 3;\n\n/**\n * Whether a word is probably a verb form rather than the thing being described.\n *\n * Crude on purpose — no part-of-speech tagger for a directory name. It exists\n * because a participle sits between the request and its subject and pushes the\n * subject out of a three-word name: \"a dashboard **showing** flaky tests\" became\n * `dashboard-showing-flaky`, and \"a spreadsheet for **tracking** API latency\"\n * became `spreadsheet-tracking-api`. The length floor spares short words where\n * the ending is a coincidence (`feed`, `used`, `ring`).\n */\nfunction looksLikeVerbForm(word: string): boolean {\n\treturn word.length > 4 && (word.endsWith(\"ing\") || word.endsWith(\"ed\"));\n}\n\n/**\n * Turn a sentence into a directory name.\n *\n * Returns undefined when nothing usable survives — an all-punctuation request, or\n * a sentence of nothing but filler — because inventing `canvas-1` would hide from\n * the person that we did not understand them.\n */\nexport function canvasNameFromDescription(description: string): string | undefined {\n\tconst words = description\n\t\t.toLowerCase()\n\t\t.replace(/[^a-z0-9]+/g, \" \")\n\t\t.trim()\n\t\t.split(\" \")\n\t\t.filter((word) => word.length > 0);\n\n\tconst content = words.filter((word) => !FILLER.has(word) && !REQUEST_WORDS.has(word) && !CARDINALS.has(word));\n\t// \"canvas\" is dropped only when something else remains: \"/new-canvas a canvas\"\n\t// should still produce `canvas` rather than nothing.\n\tconst named = content.filter((word) => word !== \"canvas\");\n\tlet kept = named.length > 0 ? named : content;\n\n\t// Prefer nouns — but only while enough of them remain. Where they do not, the\n\t// participle *is* the subject (\"a canvas for onboarding\") and dropping it would\n\t// leave nothing worth naming.\n\tconst nouns = kept.filter((word) => !looksLikeVerbForm(word));\n\tif (nouns.length >= 2) kept = nouns;\n\n\tconst name = kept.slice(0, DERIVED_NAME_WORDS).join(\"-\");\n\treturn validateCanvasName(name) === null ? name : undefined;\n}\n\n/** What a person asked `/new-canvas` for. */\nexport interface CanvasRequest {\n\t/** Directory and default canvas id. */\n\tname: string;\n\t/**\n\t * What they want it to do, in their words, or undefined when they only named\n\t * one. Present means the model is expected to build it (Copilot's\n\t * `/create-canvas` shape); absent means they want the template to edit by hand.\n\t */\n\tdescription: string | undefined;\n}\n\n/**\n * Parse `/new-canvas`'s argument.\n *\n * Three shapes, in the order they are tested:\n *\n * - `my-board` — a bare name. Unchanged from before descriptions existed, and\n * tested first so it can never be re-read as a one-word description.\n * - `my-board: a kanban board for the release checklist` — both, when someone\n * cares what the directory is called. A colon rather than a flag because the\n * rest of the line is prose and a flag parser would have to guess where it ends.\n * - `a kanban board for the release checklist` — a description, the shape\n * `/create-canvas` uses. The name is derived and reported.\n *\n * Returns a string when the input cannot become a canvas; the caller shows it.\n */\nexport function parseCanvasRequest(input: string): CanvasRequest | string {\n\tconst trimmed = input.trim();\n\tif (trimmed.length === 0) return \"name or description is required\";\n\n\tif (validateCanvasName(trimmed) === null) return { name: trimmed, description: undefined };\n\n\tconst colon = trimmed.indexOf(\":\");\n\tif (colon > 0) {\n\t\tconst name = trimmed.slice(0, colon).trim();\n\t\tconst description = trimmed.slice(colon + 1).trim();\n\t\tif (validateCanvasName(name) === null && description.length > 0) return { name, description };\n\t}\n\n\tconst derived = canvasNameFromDescription(trimmed);\n\tif (!derived) {\n\t\treturn `could not derive a directory name from that. Give one explicitly: /new-canvas <name>: ${trimmed}`;\n\t}\n\treturn { name: derived, description: trimmed };\n}\n\n/**\n * A working single-canvas extension.\n *\n * Deliberately complete rather than a stub: a canvas has no passive half — its\n * name, its actions and its UI all come from running its code — so a scaffold\n * that does not run teaches nothing and cannot be checked with `/canvas open`.\n * This one opens, serves a page, answers an action, and closes cleanly, which is\n * the whole contract; everything past that is the author's.\n *\n * Shaped like the catalog extensions hoocode already hosts: the only import is\n * `@github/copilot-sdk/extension` (host-resolved — never installed, see\n * `docs/canvas-extensions-design.md` §4.1) plus `node:` builtins, and the UI is\n * served from an ephemeral loopback port behind a per-instance token so nothing\n * else on the machine can read it.\n */\nexport const CANVAS_ENTRY_TEMPLATE = (name: string): string => `\\\n// A canvas extension. Run it with: /canvas open ${name}\n//\n// The \"@github/copilot-sdk/extension\" import is resolved by the host at fork\n// time. Do not install it, and do not add a node_modules here.\nimport { createCanvas, CanvasError, joinSession } from \"@github/copilot-sdk/extension\";\nimport { randomBytes } from \"node:crypto\";\nimport { createServer } from \"node:http\";\n\n// The canvas's identity, in one place. An open instance is bound to this id, so\n// changing it drops the canvas anyone is currently looking at — rename the\n// canvas with \\`/canvas rename\\`, or change NAME alone if you only want a\n// different label on the page.\nconst ID = \"${name}\";\nconst NAME = \"${name}\";\n\n/** Per-instance state. A canvas can be opened more than once at a time. */\nconst instances = new Map();\n\n// Styling, in one chunk so it stays findable. Plain strings and concatenation\n// throughout this section rather than template literals: a backtick or a\n// dollar-brace in markup you are generating is an escaping hazard for no gain.\n// Nothing is loaded from a CDN — this page is served from 127.0.0.1 and works\n// with no network, which a remote script would throw away.\nconst CSS = [\n\t\":root{--ground:#f7f8fa;--surface:#fff;--ink:#16191f;--muted:#5f6875;--line:#e2e6ec;--accent:#3862c4;color-scheme:light}\",\n\t\"@media(prefers-color-scheme:dark){:root{--ground:#0f1216;--surface:#171b21;--ink:#e8ebf0;--muted:#9aa3b0;--line:#262c35;--accent:#7aa2f7;color-scheme:dark}}\",\n\t\"*{box-sizing:border-box}\",\n\t\"body{margin:0;background:var(--ground);color:var(--ink);font:15px/1.55 system-ui,-apple-system,Segoe UI,sans-serif}\",\n\t\"main{max-width:44rem;margin:0 auto;padding:2rem 1.25rem 3rem;display:flex;flex-direction:column;gap:1.25rem}\",\n\t\"header{display:flex;flex-direction:column;gap:.25rem;border-bottom:1px solid var(--line);padding-bottom:1rem}\",\n\t\"h1{margin:0;font-size:1.3rem;letter-spacing:-.01em}\",\n\t\"p.sub{margin:0;color:var(--muted);font-size:.85rem}\",\n\t\"ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:.5rem}\",\n\t\"li{background:var(--surface);border:1px solid var(--line);border-left:3px solid var(--accent);border-radius:4px;padding:.6rem .8rem}\",\n\t\"li.new{animation:in .6s ease}\",\n\t\"@keyframes in{from{background:color-mix(in srgb,var(--accent) 18%,var(--surface))}to{background:var(--surface)}}\",\n\t\"@media(prefers-reduced-motion:reduce){li.new{animation:none}}\",\n\t\"p.empty{margin:0;color:var(--muted);background:var(--surface);border:1px dashed var(--line);border-radius:4px;padding:1.25rem;text-align:center}\",\n].join(\"\");\n\n// Polls /state so state the agent changed appears without anyone reloading. It\n// only updates what the server already rendered, so the page is correct before\n// this runs and stays correct if it never does.\nconst CLIENT = [\n\t\"var seen=-1;\",\n\t\"async function tick(){\",\n\t\" try{\",\n\t\" var s=await (await fetch('/state'+location.search)).json();\",\n\t\" if(seen<0)seen=s.notes.length;\",\n\t\" var list=document.getElementById('list');\",\n\t\" document.getElementById('count').textContent=s.notes.length+' note(s)';\",\n\t\" document.getElementById('empty').hidden=s.notes.length>0;\",\n\t\" list.textContent='';\",\n\t\" s.notes.forEach(function(n,i){\",\n\t\" var li=document.createElement('li');\",\n\t\" li.textContent=n;\",\n\t\" if(i>=seen)li.className='new';\",\n\t\" list.appendChild(li);\",\n\t\" });\",\n\t\" seen=s.notes.length;\",\n\t\" }catch(e){}\",\n\t\" setTimeout(tick,1000);\",\n\t\"}tick();\",\n].join(\"\");\n\n/** Escape anything that came from state before it goes into markup. */\nconst esc = (s) =>\n\tString(s).replace(/&/g, \"&amp;\").replace(/</g, \"&lt;\").replace(/>/g, \"&gt;\").replace(/\"/g, \"&quot;\");\n\n/**\n * The served page, rendered from state on the server.\n *\n * Rendered here rather than left to the poll so the page is right the moment it\n * loads, and stays right with scripting off. CLIENT only updates it.\n */\nconst page = (name, notes) =>\n\t'<!doctype html><meta charset=\"utf-8\">' +\n\t'<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">' +\n\t\"<title>\" +\n\tesc(name) +\n\t\"</title><style>\" +\n\tCSS +\n\t\"</style>\" +\n\t\"<main><header><h1>\" +\n\tesc(name) +\n\t\"</h1>\" +\n\t'<p class=\"sub\"><span id=\"count\">' +\n\tnotes.length +\n\t\" note(s)</span>. What is below changes when the agent acts.</p></header>\" +\n\t'<ul id=\"list\">' +\n\tnotes\n\t\t.map(function (n) {\n\t\t\treturn \"<li>\" + esc(n) + \"</li>\";\n\t\t})\n\t\t.join(\"\") +\n\t\"</ul>\" +\n\t'<p class=\"empty\" id=\"empty\"' +\n\t(notes.length > 0 ? \" hidden\" : \"\") +\n\t\">Nothing yet. TODO: replace this with the real UI.</p>\" +\n\t\"</main><script>\" +\n\tCLIENT +\n\t\"</script>\";\n\nconst session = await joinSession({\n\tcanvases: [\n\t\tcreateCanvas({\n\t\t\tid: ID,\n\t\t\tdisplayName: NAME,\n\t\t\tdescription: \"TODO: one sentence — the agent reads this to decide whether to open it.\",\n\t\t\tactions: [\n\t\t\t\t{\n\t\t\t\t\tname: \"add_note\",\n\t\t\t\t\tdescription: \"TODO: describe what the agent can do to this canvas.\",\n\t\t\t\t\tinputSchema: {\n\t\t\t\t\t\ttype: \"object\",\n\t\t\t\t\t\tproperties: { text: { type: \"string\" } },\n\t\t\t\t\t\trequired: [\"text\"],\n\t\t\t\t\t},\n\t\t\t\t\thandler: (ctx) => {\n\t\t\t\t\t\tconst entry = instances.get(ctx.instanceId);\n\t\t\t\t\t\t// CanvasError carries a code the host shows verbatim; a bare\n\t\t\t\t\t\t// throw arrives as an opaque internal error instead.\n\t\t\t\t\t\tif (!entry) throw new CanvasError(\"no_instance\", \\`Instance \"\\${ctx.instanceId}\" is not open.\\`);\n\t\t\t\t\t\tentry.notes.push(ctx.input.text);\n\t\t\t\t\t\treturn { notes: entry.notes.length };\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t],\n\t\t\topen: async (ctx) => {\n\t\t\t\tconst token = randomBytes(32).toString(\"base64url\");\n\t\t\t\tconst notes = [];\n\t\t\t\tconst server = createServer((req, res) => {\n\t\t\t\t\tconst url = new URL(req.url ?? \"/\", \"http://127.0.0.1\");\n\t\t\t\t\tif (url.searchParams.get(\"token\") !== token) {\n\t\t\t\t\t\tres.writeHead(403, { \"Content-Type\": \"text/plain\" });\n\t\t\t\t\t\tres.end(\"forbidden\");\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\t// The page renders from this, and so does the poll. One source of\n\t\t\t\t\t// truth means state the agent changed shows up without a reload.\n\t\t\t\t\tif (url.pathname === \"/state\") {\n\t\t\t\t\t\tres.writeHead(200, { \"Content-Type\": \"application/json\" });\n\t\t\t\t\t\tres.end(JSON.stringify({ notes }));\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tres.writeHead(200, { \"Content-Type\": \"text/html; charset=utf-8\" });\n\t\t\t\t\tres.end(page(NAME, notes));\n\t\t\t\t});\n\t\t\t\t// Port 0 on 127.0.0.1: an ephemeral port, reachable only from this machine.\n\t\t\t\tawait new Promise((resolve) => server.listen(0, \"127.0.0.1\", resolve));\n\t\t\t\tconst { port } = server.address();\n\t\t\t\tinstances.set(ctx.instanceId, { server, notes });\n\t\t\t\treturn {\n\t\t\t\t\turl: \\`http://127.0.0.1:\\${port}/?token=\\${token}\\`,\n\t\t\t\t\ttitle: NAME,\n\t\t\t\t};\n\t\t\t},\n\t\t\tonClose: async (ctx) => {\n\t\t\t\tconst entry = instances.get(ctx.instanceId);\n\t\t\t\t// Called for an abandoned open too, so the instance may be unknown.\n\t\t\t\tif (!entry) return;\n\t\t\t\tinstances.delete(ctx.instanceId);\n\t\t\t\tawait new Promise((resolve) => entry.server.close(resolve));\n\t\t\t},\n\t\t}),\n\t],\n});\n\n// stdout is the protocol channel. Use session.log, never console.log.\nawait session.log(\\`\\${ID} ready\\`);\n`;\n\n/** What {@link scaffoldCanvas} wrote. */\nexport interface CanvasScaffoldResult {\n\t/** Workspace-relative entry files created, one per platform target. */\n\tcreated: string[];\n\t/** Workspace-relative entry files that already existed and were left alone. */\n\tskipped: string[];\n}\n\n/**\n * Write the template into every platform target that has a canvas home.\n *\n * Existing files are never clobbered — they are reported and skipped, so running\n * `/new-canvas` twice on a canvas you have been editing cannot lose it.\n */\nexport function scaffoldCanvas(cwd: string, name: string, platforms: MarketplacePlatform[]): CanvasScaffoldResult {\n\tconst created: string[] = [];\n\tconst skipped: string[] = [];\n\tfor (const platform of platforms) {\n\t\tconst home = CANVAS_HOMES[platform];\n\t\tif (!home) continue;\n\t\tconst relative = join(...home, name, CANVAS_ENTRY_FILE);\n\t\tconst absolute = join(cwd, relative);\n\t\tif (existsSync(absolute)) {\n\t\t\tskipped.push(relative);\n\t\t\tcontinue;\n\t\t}\n\t\tmkdirSync(dirname(absolute), { recursive: true });\n\t\twriteFileSync(absolute, CANVAS_ENTRY_TEMPLATE(name), \"utf8\");\n\t\tcreated.push(relative);\n\t}\n\treturn { created, skipped };\n}\n\n/** Where a build brief's canvas is, if opening it worked. */\nexport interface CanvasBriefTarget {\n\tinstanceId: string;\n\turl: string | undefined;\n}\n\n/**\n * The message the model builds from — hoocode's half of `/create-canvas`.\n *\n * A scaffold plus a sentence is not a canvas, and the gap between them is the\n * agent's work. This is what turns \"a kanban board for the release checklist\"\n * into a build task with the contract attached, so the model does not have to\n * infer the rules of a surface it cannot see from a template it has not read.\n *\n * Four of those rules are stated because getting them wrong fails in ways whose\n * symptom does not name the cause: an installed dependency (the resolver already\n * provides the SDK, and a `node_modules` here is a §4.1 violation), a\n * `console.log` (corrupts the JSON-RPC channel and surfaces as \"non-protocol\n * stdout\"), a write without a reload (changes nothing at all, because the running\n * child was forked from the old bytes), and a renamed canvas id.\n *\n * That last one is the newest and was found the hard way, by building a canvas\n * with this command: the scaffold names the canvas after the directory, a model\n * that thinks of a better name renames the `id`, and the next reload drops the\n * instance the person is looking at — correctly, since the canvas it was opened\n * against no longer exists. `displayName` is the half they actually see, so it\n * is the half to change.\n */\nexport function canvasBuildBrief(\n\tname: string,\n\tdescription: string,\n\tentryPath: string,\n\ttarget: CanvasBriefTarget | undefined,\n\tguidePath?: string,\n): string {\n\tconst lines = [\n\t\t`/new-canvas: build a canvas extension named \"${name}\".`,\n\t\t\"\",\n\t\t\"What the person asked for, in their words:\",\n\t\t` ${description}`,\n\t\t\"\",\n\t\t`A working template is already at ${entryPath}. Edit it until it does what was asked.`,\n\t];\n\n\t// The craft half of this brief. It is a skill hidden from the per-turn skill\n\t// list precisely so it costs nothing until a canvas is actually being built,\n\t// which makes this line the only thing that surfaces it.\n\tif (guidePath) {\n\t\tlines.push(\n\t\t\t\"\",\n\t\t\t`Read ${guidePath} before you design the page. The template renders an unstyled placeholder on purpose; that guide covers what the surface needs — the markup lives in a template string, no dependencies and no build are available, nothing about the theme is inherited, and the page has to render state that you and the person both change.`,\n\t\t);\n\t}\n\n\tif (target) {\n\t\tlines.push(\n\t\t\t\"\",\n\t\t\ttarget.url\n\t\t\t\t? `It is already open at ${target.url} (instance ${target.instanceId}), so the person is watching it as you work.`\n\t\t\t\t: `It is already open as instance ${target.instanceId}.`,\n\t\t\t`After every edit, call reload_canvas with extensionId \"${name}\". A write to the file changes nothing on its own — the running process was forked from the old code. Reloading hands back a NEW url; give it to the person, because the tab they have open dies with the old process.`,\n\t\t);\n\t} else {\n\t\tlines.push(\n\t\t\t\"\",\n\t\t\t`It is not open — the person will run /canvas open ${name} when they want to look. Build it anyway; you can check it runs by reading it carefully rather than by opening it yourself.`,\n\t\t);\n\t}\n\n\tlines.push(\n\t\t\"\",\n\t\t\"The contract this surface runs under, which is not yours to change:\",\n\t\t'- The only non-`node:` import allowed is \"@github/copilot-sdk/extension\". The host resolves it when it forks the extension. Do not install anything, and do not add a package.json or node_modules in the extension directory.',\n\t\t\"- stdout is the JSON-RPC channel. Use `session.log(...)`; a `console.log` corrupts the protocol.\",\n\t\t\"- Do not change the canvas's `id` (the template holds it in `ID`). An open instance is bound to it, so renaming it drops the canvas the person is watching on your next reload. Change `NAME` for a nicer label — that is the one they see — and if the directory name itself is wrong, say so and let them run `/canvas rename`, which moves everything at once.\",\n\t\t\"- After a reload, read the action list it reports back. That is the host telling you which of your actions it can actually see, and it is the only confirmation an action you just wrote is really callable.\",\n\t\t\"- Serve the UI from the loopback server the template already starts. Keep the per-instance token check, and keep `onClose` shutting the server down — that is what stops a port outliving the session.\",\n\t\t\"- Everything in `actions: [...]` becomes callable by you through invoke_canvas_action once it is open, so give each action a real description and inputSchema. That list is how you drive the canvas; the person drives the same state through the page.\",\n\t\t\"\",\n\t\t\"Build the thing that was asked for, not a stub: leave no TODO behind, and make the page render real state rather than a placeholder.\",\n\t);\n\treturn lines.join(\"\\n\");\n}\n"]}
@@ -242,6 +242,91 @@ const NAME = "${name}";
242
242
  /** Per-instance state. A canvas can be opened more than once at a time. */
243
243
  const instances = new Map();
244
244
 
245
+ // Styling, in one chunk so it stays findable. Plain strings and concatenation
246
+ // throughout this section rather than template literals: a backtick or a
247
+ // dollar-brace in markup you are generating is an escaping hazard for no gain.
248
+ // Nothing is loaded from a CDN — this page is served from 127.0.0.1 and works
249
+ // with no network, which a remote script would throw away.
250
+ const CSS = [
251
+ ":root{--ground:#f7f8fa;--surface:#fff;--ink:#16191f;--muted:#5f6875;--line:#e2e6ec;--accent:#3862c4;color-scheme:light}",
252
+ "@media(prefers-color-scheme:dark){:root{--ground:#0f1216;--surface:#171b21;--ink:#e8ebf0;--muted:#9aa3b0;--line:#262c35;--accent:#7aa2f7;color-scheme:dark}}",
253
+ "*{box-sizing:border-box}",
254
+ "body{margin:0;background:var(--ground);color:var(--ink);font:15px/1.55 system-ui,-apple-system,Segoe UI,sans-serif}",
255
+ "main{max-width:44rem;margin:0 auto;padding:2rem 1.25rem 3rem;display:flex;flex-direction:column;gap:1.25rem}",
256
+ "header{display:flex;flex-direction:column;gap:.25rem;border-bottom:1px solid var(--line);padding-bottom:1rem}",
257
+ "h1{margin:0;font-size:1.3rem;letter-spacing:-.01em}",
258
+ "p.sub{margin:0;color:var(--muted);font-size:.85rem}",
259
+ "ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:.5rem}",
260
+ "li{background:var(--surface);border:1px solid var(--line);border-left:3px solid var(--accent);border-radius:4px;padding:.6rem .8rem}",
261
+ "li.new{animation:in .6s ease}",
262
+ "@keyframes in{from{background:color-mix(in srgb,var(--accent) 18%,var(--surface))}to{background:var(--surface)}}",
263
+ "@media(prefers-reduced-motion:reduce){li.new{animation:none}}",
264
+ "p.empty{margin:0;color:var(--muted);background:var(--surface);border:1px dashed var(--line);border-radius:4px;padding:1.25rem;text-align:center}",
265
+ ].join("");
266
+
267
+ // Polls /state so state the agent changed appears without anyone reloading. It
268
+ // only updates what the server already rendered, so the page is correct before
269
+ // this runs and stays correct if it never does.
270
+ const CLIENT = [
271
+ "var seen=-1;",
272
+ "async function tick(){",
273
+ " try{",
274
+ " var s=await (await fetch('/state'+location.search)).json();",
275
+ " if(seen<0)seen=s.notes.length;",
276
+ " var list=document.getElementById('list');",
277
+ " document.getElementById('count').textContent=s.notes.length+' note(s)';",
278
+ " document.getElementById('empty').hidden=s.notes.length>0;",
279
+ " list.textContent='';",
280
+ " s.notes.forEach(function(n,i){",
281
+ " var li=document.createElement('li');",
282
+ " li.textContent=n;",
283
+ " if(i>=seen)li.className='new';",
284
+ " list.appendChild(li);",
285
+ " });",
286
+ " seen=s.notes.length;",
287
+ " }catch(e){}",
288
+ " setTimeout(tick,1000);",
289
+ "}tick();",
290
+ ].join("");
291
+
292
+ /** Escape anything that came from state before it goes into markup. */
293
+ const esc = (s) =>
294
+ String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
295
+
296
+ /**
297
+ * The served page, rendered from state on the server.
298
+ *
299
+ * Rendered here rather than left to the poll so the page is right the moment it
300
+ * loads, and stays right with scripting off. CLIENT only updates it.
301
+ */
302
+ const page = (name, notes) =>
303
+ '<!doctype html><meta charset="utf-8">' +
304
+ '<meta name="viewport" content="width=device-width,initial-scale=1">' +
305
+ "<title>" +
306
+ esc(name) +
307
+ "</title><style>" +
308
+ CSS +
309
+ "</style>" +
310
+ "<main><header><h1>" +
311
+ esc(name) +
312
+ "</h1>" +
313
+ '<p class="sub"><span id="count">' +
314
+ notes.length +
315
+ " note(s)</span>. What is below changes when the agent acts.</p></header>" +
316
+ '<ul id="list">' +
317
+ notes
318
+ .map(function (n) {
319
+ return "<li>" + esc(n) + "</li>";
320
+ })
321
+ .join("") +
322
+ "</ul>" +
323
+ '<p class="empty" id="empty"' +
324
+ (notes.length > 0 ? " hidden" : "") +
325
+ ">Nothing yet. TODO: replace this with the real UI.</p>" +
326
+ "</main><script>" +
327
+ CLIENT +
328
+ "</script>";
329
+
245
330
  const session = await joinSession({
246
331
  canvases: [
247
332
  createCanvas({
@@ -277,11 +362,15 @@ const session = await joinSession({
277
362
  res.end("forbidden");
278
363
  return;
279
364
  }
365
+ // The page renders from this, and so does the poll. One source of
366
+ // truth means state the agent changed shows up without a reload.
367
+ if (url.pathname === "/state") {
368
+ res.writeHead(200, { "Content-Type": "application/json" });
369
+ res.end(JSON.stringify({ notes }));
370
+ return;
371
+ }
280
372
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
281
- res.end(
282
- \`<!doctype html><meta charset="utf-8"><title>\${NAME}</title>\` +
283
- \`<p>\${notes.length} note(s). TODO: build the UI.\`,
284
- );
373
+ res.end(page(NAME, notes));
285
374
  });
286
375
  // Port 0 on 127.0.0.1: an ephemeral port, reachable only from this machine.
287
376
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
@@ -353,7 +442,7 @@ export function scaffoldCanvas(cwd, name, platforms) {
353
442
  * against no longer exists. `displayName` is the half they actually see, so it
354
443
  * is the half to change.
355
444
  */
356
- export function canvasBuildBrief(name, description, entryPath, target) {
445
+ export function canvasBuildBrief(name, description, entryPath, target, guidePath) {
357
446
  const lines = [
358
447
  `/new-canvas: build a canvas extension named "${name}".`,
359
448
  "",
@@ -362,6 +451,12 @@ export function canvasBuildBrief(name, description, entryPath, target) {
362
451
  "",
363
452
  `A working template is already at ${entryPath}. Edit it until it does what was asked.`,
364
453
  ];
454
+ // The craft half of this brief. It is a skill hidden from the per-turn skill
455
+ // list precisely so it costs nothing until a canvas is actually being built,
456
+ // which makes this line the only thing that surfaces it.
457
+ if (guidePath) {
458
+ lines.push("", `Read ${guidePath} before you design the page. The template renders an unstyled placeholder on purpose; that guide covers what the surface needs — the markup lives in a template string, no dependencies and no build are available, nothing about the theme is inherited, and the page has to render state that you and the person both change.`);
459
+ }
365
460
  if (target) {
366
461
  lines.push("", target.url
367
462
  ? `It is already open at ${target.url} (instance ${target.instanceId}), so the person is watching it as you work.`