@uniqbit/mate-core 0.18.5 → 1.0.0-canary.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 (209) hide show
  1. package/claude-plugin/hooks/companion-instructions.mjs +9 -0
  2. package/claude-plugin/hooks/hooks.json +12 -2
  3. package/package.json +10 -2
  4. package/src/brand/art.generated.ts +69 -0
  5. package/src/brand/index.ts +12 -0
  6. package/src/brand/palette.ts +19 -0
  7. package/src/brand/svg.generated.ts +8 -0
  8. package/src/brand/terminal.ts +83 -0
  9. package/src/brand/types.ts +33 -0
  10. package/src/cli/commands/artifact/artifact.ts +8 -0
  11. package/src/cli/commands/artifact/delivery/archive.ts +117 -0
  12. package/src/cli/commands/artifact/delivery/branch-flags.ts +55 -0
  13. package/src/cli/commands/artifact/delivery/environment.ts +104 -0
  14. package/src/cli/commands/artifact/delivery/link.ts +154 -0
  15. package/src/cli/commands/artifact/delivery/output.ts +58 -0
  16. package/src/cli/commands/artifact/finish/command.ts +33 -3
  17. package/src/cli/commands/artifact/finish/engine.ts +47 -3
  18. package/src/cli/commands/artifact/finish/finisher.ts +10 -0
  19. package/src/cli/commands/artifact/finish/openspec.ts +177 -138
  20. package/src/cli/commands/artifact/finish/secret-scan.ts +43 -0
  21. package/src/cli/commands/artifact/pending/command.ts +31 -3
  22. package/src/cli/commands/artifact/pending/discovery.ts +21 -57
  23. package/src/cli/commands/cap/graphify.ts +52 -3
  24. package/src/cli/commands/cap/index-cmd.ts +8 -1
  25. package/src/cli/commands/cap/openspec.ts +13 -25
  26. package/src/cli/commands/companion/open.ts +56 -39
  27. package/src/cli/commands/companion/update.ts +75 -42
  28. package/src/cli/commands/doctor.ts +176 -3
  29. package/src/cli/commands/plugin/install.ts +9 -4
  30. package/src/cli/commands/plugin/plugin.ts +3 -2
  31. package/src/cli/commands/report/adapter.ts +11 -2
  32. package/src/cli/commands/report/contract.ts +21 -0
  33. package/src/cli/commands/report/highlight.ts +34 -4
  34. package/src/cli/commands/report/markdown.ts +110 -0
  35. package/src/cli/commands/report/palette.ts +118 -0
  36. package/src/cli/commands/report/renderer.ts +456 -153
  37. package/src/cli/commands/report/types.ts +16 -0
  38. package/src/cli/commands/setup.ts +7 -15
  39. package/src/cli/commands/studio/client-asset.ts +16 -0
  40. package/src/cli/commands/studio/openspec-cli.ts +10 -58
  41. package/src/cli/commands/studio/routes.ts +2 -0
  42. package/src/cli/commands/studio/selection.ts +19 -1
  43. package/src/cli/commands/studio/server.ts +42 -1
  44. package/src/cli/commands/studio/views/client.ts +6 -616
  45. package/src/cli/commands/studio/views/dashboard/changes.tsx +5 -10
  46. package/src/cli/commands/studio/views/document.tsx +137 -69
  47. package/src/cli/commands/studio/views/markdown.tsx +250 -0
  48. package/src/cli/commands/studio/views/palette.ts +134 -0
  49. package/src/cli/commands/studio/views/reports/index.tsx +2 -7
  50. package/src/cli/commands/studio/views/skills/index.tsx +43 -62
  51. package/src/cli/commands/studio/views/specs/index.tsx +7 -15
  52. package/src/cli/commands/studio/views/studio-client/actions.ts +51 -0
  53. package/src/cli/commands/studio/views/studio-client/companion.ts +13 -0
  54. package/src/cli/commands/studio/views/studio-client/index.ts +32 -0
  55. package/src/cli/commands/studio/views/studio-client/keys.ts +3 -0
  56. package/src/cli/commands/studio/views/studio-client/main.ts +3 -0
  57. package/src/cli/commands/studio/views/studio-client/page.ts +146 -0
  58. package/src/cli/commands/studio/views/studio-client/panels.ts +57 -0
  59. package/src/cli/commands/studio/views/studio-client/reports.ts +122 -0
  60. package/src/cli/commands/studio/views/studio-client/theme.ts +63 -0
  61. package/src/cli/commands/studio/views/studio-client/vault-editor.ts +166 -0
  62. package/src/cli/commands/studio/views/studio-client/vault-pane.ts +202 -0
  63. package/src/cli/commands/studio/views/studio-client/vault-tree.ts +258 -0
  64. package/src/cli/commands/studio/views/studio-client.generated.ts +2 -0
  65. package/src/cli/commands/studio/views/styles.ts +217 -206
  66. package/src/cli/commands/studio/views/vault/state.ts +3 -5
  67. package/src/cli/commands/studio/views/workflow/delivery-path.tsx +370 -0
  68. package/src/cli/commands/studio/views/workflow/graph.ts +220 -0
  69. package/src/cli/commands/studio/views/workflow/index.tsx +2 -3
  70. package/src/cli/commands/studio/views/workflow/steps.ts +228 -287
  71. package/src/cli/commands/wrap.ts +5 -13
  72. package/src/cli/main.ts +4 -1
  73. package/src/cli/parse-flags.ts +25 -7
  74. package/src/cli/setup-selector.tsx +38 -162
  75. package/src/cli/usage.ts +4 -0
  76. package/src/hooks/companion-instructions.ts +190 -0
  77. package/src/hooks/hook-io.ts +63 -0
  78. package/src/hooks/session-banner.ts +21 -29
  79. package/src/hooks/session-guidance.ts +9 -17
  80. package/src/hooks/session-prepare.ts +115 -28
  81. package/src/hooks/validate-artifact-path.ts +82 -24
  82. package/src/index.ts +4 -0
  83. package/src/lib/change-layout.ts +154 -0
  84. package/src/lib/components/wizard-header.tsx +3 -1
  85. package/src/lib/context-mode-package.ts +24 -11
  86. package/src/lib/delivery/archive-change.ts +154 -0
  87. package/src/lib/delivery/branches.ts +108 -0
  88. package/src/lib/delivery/environment.ts +29 -0
  89. package/src/lib/delivery/forge.ts +279 -0
  90. package/src/lib/delivery/overlap.ts +202 -0
  91. package/src/lib/delivery/record-delivery.ts +196 -0
  92. package/src/lib/delivery/record.ts +343 -0
  93. package/src/lib/delivery/refresh.ts +174 -0
  94. package/src/lib/delivery/repository-access.ts +97 -0
  95. package/src/lib/delivery/stages.ts +111 -0
  96. package/src/lib/delivery/token.ts +58 -0
  97. package/src/lib/frontmatter.ts +18 -0
  98. package/src/lib/opencode-plugin-package.ts +5 -5
  99. package/src/lib/openspec-cli.ts +116 -0
  100. package/src/lib/openspec-scopes.ts +86 -0
  101. package/src/lib/orchestrator/adapters/claude-args.ts +50 -0
  102. package/src/lib/orchestrator/adapters/claude.ts +48 -40
  103. package/src/lib/orchestrator/adapters/opencode.ts +2 -0
  104. package/src/lib/orchestrator/editor.ts +9 -9
  105. package/src/lib/orchestrator/openspec-store.ts +14 -54
  106. package/src/lib/orchestrator/projection-claude-commands.ts +36 -0
  107. package/src/lib/orchestrator/projection-claude-links.ts +159 -0
  108. package/src/lib/orchestrator/projection-claude-skills.ts +19 -137
  109. package/src/lib/orchestrator/projection-entries.ts +45 -14
  110. package/src/lib/orchestrator/projection-runtime-documents.ts +15 -6
  111. package/src/lib/orchestrator/projection-types.ts +3 -0
  112. package/src/lib/orchestrator/refresh-working-claude-links.ts +87 -0
  113. package/src/lib/orchestrator/session-prepare.ts +45 -5
  114. package/src/lib/orchestrator/setup-compatibilities.ts +18 -75
  115. package/src/lib/orchestrator/types.ts +2 -2
  116. package/src/lib/orchestrator/wrap-pass.ts +22 -1
  117. package/src/lib/package-paths.ts +8 -0
  118. package/src/opencode/companion-hooks.ts +64 -5
  119. package/src/opencode/companion-policy.ts +37 -18
  120. package/src/opencode/projected-guidance.ts +4 -1
  121. package/src/opencode/session-prepare.ts +2 -1
  122. package/src/opencode/tui.tsx +4 -2
  123. package/src/runtime/artifact-refusal.ts +63 -0
  124. package/src/runtime/claude-delivery-plan.ts +89 -7
  125. package/src/runtime/commit-attribution-guard.ts +181 -0
  126. package/src/runtime/companion-guidance.ts +15 -3
  127. package/src/runtime/companion-instructions.ts +340 -0
  128. package/src/runtime/error-message.ts +4 -0
  129. package/src/runtime/index.ts +5 -0
  130. package/src/runtime/managed-block.ts +117 -0
  131. package/src/runtime/markdown-scan.ts +266 -0
  132. package/src/runtime/policy.ts +15 -1
  133. package/src/runtime/projected-flag.ts +7 -0
  134. package/src/runtime/projected-guidance.ts +1 -0
  135. package/src/runtime/runtime-document-manifest.ts +62 -11
  136. package/src/runtime/secret-guard.ts +148 -0
  137. package/src/runtime/secret-patterns.ts +164 -0
  138. package/src/runtime/secret-scan.ts +90 -0
  139. package/src/runtime/session-kind.ts +13 -0
  140. package/src/runtime/shell-heredoc.ts +81 -0
  141. package/src/runtime/wrap-state.ts +36 -2
  142. package/src/runtime/write-atomic.ts +64 -0
  143. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +8 -6
  144. package/src/templates/capabilities/openspec-cap/mate-minimal/templates/spec.md +3 -5
  145. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +7 -9
  146. package/src/templates/capabilities/openspec-cap/mate-v1/templates/spec.md +3 -5
  147. package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +10 -12
  148. package/src/templates/mate-skills/THIRD-PARTY-NOTICES.md +40 -0
  149. package/src/templates/mate-skills/agents/mate-apply-change/SKILL.md +76 -0
  150. package/src/templates/mate-skills/agents/mate-apply-change/references/openspec-apply-change.md +198 -0
  151. package/src/templates/mate-skills/agents/mate-archive-change/SKILL.md +59 -0
  152. package/src/templates/mate-skills/{claude/mate-openspec-backfill → agents/mate-backfill-spec}/SKILL.md +4 -4
  153. package/src/templates/mate-skills/agents/mate-clarify-intent/SKILL.md +95 -0
  154. package/src/templates/mate-skills/agents/mate-explore-change/SKILL.md +50 -0
  155. package/src/templates/mate-skills/agents/mate-explore-change/references/openspec-explore.md +348 -0
  156. package/src/templates/mate-skills/agents/mate-grill-me/SKILL.md +3 -3
  157. package/src/templates/mate-skills/agents/mate-interview-me/SKILL.md +5 -5
  158. package/src/templates/mate-skills/agents/mate-propose-change/SKILL.md +103 -0
  159. package/src/templates/mate-skills/agents/mate-propose-change/references/openspec-propose.md +167 -0
  160. package/src/templates/mate-skills/agents/mate-propose-change/references/openspec-update-change.md +101 -0
  161. package/src/templates/mate-skills/{claude/mate-artifact-publish → agents/mate-publish-artifact}/SKILL.md +91 -23
  162. package/src/templates/mate-skills/agents/{mate-artifact-publish → mate-publish-artifact}/references/openspec.md +4 -3
  163. package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +74 -16
  164. package/src/templates/mate-skills/agents/mate-simplify-code/SKILL.md +74 -68
  165. package/src/templates/mate-skills/claude/mate-apply-change/SKILL.md +76 -0
  166. package/src/templates/mate-skills/claude/mate-apply-change/references/openspec-apply-change.md +198 -0
  167. package/src/templates/mate-skills/claude/mate-archive-change/SKILL.md +59 -0
  168. package/src/templates/mate-skills/{agents/mate-openspec-backfill → claude/mate-backfill-spec}/SKILL.md +4 -4
  169. package/src/templates/mate-skills/claude/mate-clarify-intent/SKILL.md +95 -0
  170. package/src/templates/mate-skills/claude/mate-explore-change/SKILL.md +50 -0
  171. package/src/templates/mate-skills/claude/mate-explore-change/references/openspec-explore.md +348 -0
  172. package/src/templates/mate-skills/claude/mate-grill-me/SKILL.md +3 -3
  173. package/src/templates/mate-skills/claude/mate-interview-me/SKILL.md +5 -5
  174. package/src/templates/mate-skills/claude/mate-propose-change/SKILL.md +103 -0
  175. package/src/templates/mate-skills/claude/mate-propose-change/references/openspec-propose.md +167 -0
  176. package/src/templates/mate-skills/claude/mate-propose-change/references/openspec-update-change.md +101 -0
  177. package/src/templates/mate-skills/{agents/mate-artifact-publish → claude/mate-publish-artifact}/SKILL.md +91 -23
  178. package/src/templates/mate-skills/claude/{mate-artifact-publish → mate-publish-artifact}/references/openspec.md +4 -3
  179. package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +74 -16
  180. package/src/templates/mate-skills/claude/mate-simplify-code/SKILL.md +74 -68
  181. package/src/templates/mate-skills/openspec-references.ts +140 -0
  182. package/src/templates/root/TEMPLATE_AGENTS.md +2 -2
  183. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +3727 -320
  184. package/src/tools/setup/capabilities/context-mode.ts +25 -5
  185. package/src/tools/setup/capabilities/graphify-gate.ts +31 -0
  186. package/src/tools/setup/capabilities/graphify-secrets.ts +255 -0
  187. package/src/tools/setup/capabilities/graphify.ts +12 -0
  188. package/src/tools/setup/capabilities/openspec.ts +151 -270
  189. package/src/tools/setup/capabilities/tokensave.ts +36 -3
  190. package/src/tools/setup/context-services.ts +9 -17
  191. package/src/tools/setup/dynamic-plugins/install.ts +24 -28
  192. package/src/tools/setup/engine.ts +97 -21
  193. package/src/tools/setup/managed-output.ts +51 -0
  194. package/src/tools/setup/mate.ts +86 -11
  195. package/src/tools/setup/plugin.ts +35 -0
  196. package/src/tools/setup/plugins/guidance.ts +7 -4
  197. package/src/tools/setup/providers/agent-file-sections.ts +4 -2
  198. package/src/tools/setup/providers/claude-attribution.ts +65 -0
  199. package/src/tools/setup/providers/claude-plugin-folders.ts +625 -0
  200. package/src/tools/setup/providers/claude-plugin-hooks.ts +39 -19
  201. package/src/tools/setup/providers/claude.ts +284 -8
  202. package/src/tools/setup/providers/opencode.ts +180 -1
  203. package/src/tools/setup/utils.ts +14 -4
  204. package/src/tools/setup.ts +126 -10
  205. package/wrappers/bin/graphify +52 -4
  206. package/wrappers/bin/openspec +33 -9
  207. package/wrappers/lib/graphify-gate.mjs +8 -0
  208. package/src/cli/commands/studio/views/workflow/transcript.tsx +0 -190
  209. package/src/tools/setup/runtime-documents.ts +0 -291
@@ -0,0 +1,340 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { MATE_ENV } from "./env-names";
5
+ import { FRAMEWORK_NAME } from "./framework";
6
+ import {
7
+ findCompanionBlock,
8
+ findManagedBlocks,
9
+ managedBlockPluginId,
10
+ stripManagedBlocks,
11
+ type ManagedBlock,
12
+ } from "./managed-block";
13
+ import { joinLines, scanLinesWithoutComments, type ScannedLine } from "./markdown-scan";
14
+
15
+ /**
16
+ * Companion Instructions: the companion's own `AGENTS.md`, rendered for one
17
+ * Claude session. Read-only. The text mirrors what native loading of the
18
+ * companion `CLAUDE.md` (`@AGENTS.md`) yields: block-level HTML comments
19
+ * stripped, `AGENTS.override.md` not read. `@` lines stay literal, because
20
+ * expanding an import outside the project needs an approval a hook or `-p`
21
+ * session never gets.
22
+ */
23
+
24
+ export const COMPANION_INSTRUCTIONS_TAG = "companion-instructions";
25
+
26
+ /**
27
+ * Below Claude Code's 10,000-character cap on one hook's `additionalContext`;
28
+ * a longer value arrives as a file preview instead of in context.
29
+ */
30
+ export const COMPANION_INSTRUCTIONS_BUDGET = 9_000;
31
+
32
+ /** Where the Claude Runtime Surface writes its `mate:managed` blocks; those in `AGENTS.md` are OpenCode's. */
33
+ const CLAUDE_BLOCK_FILE = "CLAUDE.md";
34
+
35
+ export interface CompanionInstructionsInput {
36
+ companionPath: string;
37
+ repository: { id: string; path: string };
38
+ wrapperBinPath: string;
39
+ }
40
+
41
+ export type CompanionInstructions =
42
+ | {
43
+ kind: "available";
44
+ /** Absolute path of the companion `AGENTS.md`. */
45
+ source: string;
46
+ text: string;
47
+ omitted: string[];
48
+ /** The `MATE:COMPANION` block alone overruns the budget; it is never cut, so every other segment is omitted. */
49
+ oversized: boolean;
50
+ }
51
+ | { kind: "unavailable"; source: string; reason: string };
52
+
53
+ export type InstructionReader = (input: CompanionInstructionsInput) => CompanionInstructions;
54
+
55
+ export function companionInstructionsUnavailableLine(
56
+ result: Extract<CompanionInstructions, { kind: "unavailable" }>,
57
+ ): string {
58
+ return `Companion instructions unavailable: ${result.source} ${result.reason}`;
59
+ }
60
+
61
+ export interface CompanionInstructionsDeps {
62
+ isFile?: (filePath: string) => boolean;
63
+ readFile?: (filePath: string) => string;
64
+ }
65
+
66
+ function defaultIsFile(filePath: string): boolean {
67
+ return fs.statSync(filePath).isFile();
68
+ }
69
+
70
+ function defaultReadFile(filePath: string): string {
71
+ return fs.readFileSync(filePath, "utf8");
72
+ }
73
+
74
+ /** Only a regular file is opened: a FIFO would block the read, and a hook that never returns stalls the session. */
75
+ function readText(
76
+ filePath: string,
77
+ deps: CompanionInstructionsDeps,
78
+ ): { text: string } | { reason: string } {
79
+ const isFile = deps.isFile ?? defaultIsFile;
80
+ const readFile = deps.readFile ?? defaultReadFile;
81
+ try {
82
+ if (!isFile(filePath)) return { reason: "is not a regular file" };
83
+ return { text: readFile(filePath).replace(/\r\n?/g, "\n") };
84
+ } catch (error) {
85
+ const code = (error as NodeJS.ErrnoException).code;
86
+ if (code === "ENOENT") return { reason: "not found" };
87
+ return { reason: `could not be read (${code ?? (error as Error).message})` };
88
+ }
89
+ }
90
+
91
+ function fillPlaceholders(text: string, input: CompanionInstructionsInput): string {
92
+ const values: Record<string, string> = {
93
+ [MATE_ENV.repositoryPath]: input.repository.path,
94
+ [MATE_ENV.repositoryId]: input.repository.id,
95
+ [MATE_ENV.companionPath]: input.companionPath,
96
+ [MATE_ENV.wrapperBinPath]: input.wrapperBinPath,
97
+ };
98
+ return text.replace(
99
+ /\$\{(MATE_\w+)\}|\$(MATE_\w+)/g,
100
+ (match, braced?: string, bare?: string) => values[(braced ?? bare)!] ?? match,
101
+ );
102
+ }
103
+
104
+ interface Segment {
105
+ /** The `##` heading, or a description for text no heading introduces. */
106
+ name: string;
107
+ text: string;
108
+ /** The `MATE:COMPANION` block: always kept whole. */
109
+ required: boolean;
110
+ source: string;
111
+ /** Shared by the two halves of a section the block sits in, so they are kept or omitted together. */
112
+ section: object;
113
+ }
114
+
115
+ const UNTITLED = "text before the first section";
116
+ /** An empty `##` heading still opens a section, which the omission line must name. */
117
+ const EMPTY_HEADING = "untitled section";
118
+
119
+ function splitSections(text: string, source: string): Segment[] {
120
+ const sections: { name: string; lines: ScannedLine[] }[] = [{ name: UNTITLED, lines: [] }];
121
+ for (const line of scanLinesWithoutComments(text)) {
122
+ if (line.heading !== null) sections.push({ name: line.heading || EMPTY_HEADING, lines: [] });
123
+ sections.at(-1)!.lines.push(line);
124
+ }
125
+ return sections.map(({ name, lines }) => ({
126
+ name,
127
+ text: joinLines(lines),
128
+ required: false,
129
+ source,
130
+ section: {},
131
+ }));
132
+ }
133
+
134
+ /** The block's markers are found before the scan, which would strip them as comments. */
135
+ function segmentAgents(text: string, source: string): Segment[] {
136
+ const found = findCompanionBlock(text, FRAMEWORK_NAME);
137
+ if (!found) return splitSections(text, source).filter((part) => part.text.length > 0);
138
+
139
+ const before = splitSections(text.slice(0, found.start), source);
140
+ const after = splitSections(text.slice(found.end), source);
141
+ /** The text after the block, up to the next heading, continues the section the block opened in. */
142
+ const enclosing = before.at(-1)!;
143
+ after[0] = { ...after[0]!, name: enclosing.name, section: enclosing.section };
144
+ const block: Segment = {
145
+ name: `${FRAMEWORK_NAME.toUpperCase()}:COMPANION block`,
146
+ text: joinLines(scanLinesWithoutComments(text.slice(found.start, found.end))),
147
+ required: true,
148
+ source,
149
+ section: {},
150
+ };
151
+ return [...before, block, ...after].filter((part) => part.text.length > 0);
152
+ }
153
+
154
+ function segmentBlocks(
155
+ blocks: readonly ManagedBlock[],
156
+ source: string,
157
+ input: CompanionInstructionsInput,
158
+ ): Segment[] {
159
+ return blocks
160
+ .map((block) => ({
161
+ name: `${path.basename(source)} block ${managedBlockPluginId(block.key)}`,
162
+ text: joinLines(scanLinesWithoutComments(fillPlaceholders(block.body, input))),
163
+ required: false,
164
+ source,
165
+ section: {},
166
+ }))
167
+ .filter((part) => part.text.length > 0);
168
+ }
169
+
170
+ function segment(
171
+ agents: string,
172
+ source: string,
173
+ input: CompanionInstructionsInput,
174
+ deps: CompanionInstructionsDeps,
175
+ ): Segment[] {
176
+ const own = stripManagedBlocks(agents, FRAMEWORK_NAME);
177
+ const blockSource = path.join(input.companionPath, CLAUDE_BLOCK_FILE);
178
+ const read = readText(blockSource, deps);
179
+ const blocks = "text" in read ? findManagedBlocks(read.text, FRAMEWORK_NAME) : [];
180
+ return [
181
+ ...segmentAgents(fillPlaceholders(own, input), source),
182
+ ...segmentBlocks(blocks, blockSource, input),
183
+ ];
184
+ }
185
+
186
+ function escapeAttribute(value: string): string {
187
+ return value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;");
188
+ }
189
+
190
+ function render(source: string, segments: readonly Segment[], closing?: string): string {
191
+ const body = [...segments.map((part) => part.text), ...(closing ? [closing] : [])].join("\n\n");
192
+ return `<${COMPANION_INSTRUCTIONS_TAG} source="${escapeAttribute(source)}">\n${body}\n</${COMPANION_INSTRUCTIONS_TAG}>`;
193
+ }
194
+
195
+ /** Hundreds of headings would otherwise make the omission line alone exceed the budget. */
196
+ const OMISSION_NAMES_CAP = 400;
197
+ const OMISSION_NAME_MAX = 80;
198
+
199
+ /** Cut by code point, so a heading of astral characters never ends in half a surrogate pair. */
200
+ function omissionName(name: string): string {
201
+ const points = [...name];
202
+ if (points.length <= OMISSION_NAME_MAX) return name;
203
+ return `${points.slice(0, OMISSION_NAME_MAX - 1).join("")}…`;
204
+ }
205
+
206
+ function listOmitted(names: readonly string[], cap: number): string {
207
+ const shown: string[] = [];
208
+ for (const name of names.map(omissionName)) {
209
+ const listing = [...shown, name].join(", ");
210
+ if (shown.length > 0 && listing.length > cap) break;
211
+ shown.push(name);
212
+ }
213
+ const more = names.length - shown.length;
214
+ return more > 0 ? `${shown.join(", ")} and ${more} more` : shown.join(", ");
215
+ }
216
+
217
+ function omissionLine(listing: string, sources: readonly string[]): string {
218
+ return `Sections not included here: ${listing}. Full text: ${sources.join(" and ")}`;
219
+ }
220
+
221
+ /** Capped at `room` too: a block that leaves little room gets a count, not a listing that carries the result past Claude Code's hook cap. */
222
+ function closingLine(names: readonly string[], sources: readonly string[], room: number): string {
223
+ const fixed = omissionLine("", sources).length + ` and ${names.length} more`.length;
224
+ return omissionLine(listOmitted(names, Math.min(OMISSION_NAMES_CAP, room - fixed)), sources);
225
+ }
226
+
227
+ function fullTextSources(source: string, parts: readonly Segment[]): string[] {
228
+ const others = new Set(parts.map((part) => part.source).filter((file) => file !== source));
229
+ return [source, ...others];
230
+ }
231
+
232
+ /**
233
+ * The longest omission line any subset of `optional` can produce. A subset
234
+ * lists at most the capped names, and its count of unlisted names, which only
235
+ * appears once the full listing passes the cap, has at most as many digits as
236
+ * the whole.
237
+ */
238
+ function omissionReserve(sections: readonly Segment[][], source: string): number {
239
+ const listed = sections.map((parts) => omissionName(parts[0]!.name)).join(", ").length;
240
+ const more = listed > OMISSION_NAMES_CAP ? ` and ${sections.length} more`.length : 0;
241
+ const fixed = omissionLine("", fullTextSources(source, sections.flat())).length;
242
+ return fixed + Math.min(listed, OMISSION_NAMES_CAP) + more;
243
+ }
244
+
245
+ /** The segments other than the block, grouped by section in document order. */
246
+ function optionalSections(segments: readonly Segment[]): Segment[][] {
247
+ const sections = new Map<object, Segment[]>();
248
+ for (const part of segments) {
249
+ if (part.required) continue;
250
+ const parts = sections.get(part.section);
251
+ if (parts) parts.push(part);
252
+ else sections.set(part.section, [part]);
253
+ }
254
+ return [...sections.values()];
255
+ }
256
+
257
+ /** The omission line's worst case is reserved before any section is added, so adding the real line never pushes a fitting result over. */
258
+ function fit(
259
+ source: string,
260
+ segments: readonly Segment[],
261
+ ): { text: string; omitted: string[]; oversized: boolean } {
262
+ const whole = render(source, segments);
263
+ if (whole.length <= COMPANION_INSTRUCTIONS_BUDGET) {
264
+ return { text: whole, omitted: [], oversized: false };
265
+ }
266
+
267
+ const sections = optionalSections(segments);
268
+ const reserve = "#".repeat(omissionReserve(sections, source));
269
+ const kept = new Set<Segment>(segments.filter((part) => part.required));
270
+ let length = render(source, [...kept], reserve).length;
271
+ for (const parts of sections) {
272
+ const next = parts.reduce((sum, part) => sum + part.text.length + "\n\n".length, length);
273
+ if (next > COMPANION_INSTRUCTIONS_BUDGET) continue;
274
+ for (const part of parts) kept.add(part);
275
+ length = next;
276
+ }
277
+
278
+ const build = (): { text: string; omitted: string[] } => {
279
+ const chosen = segments.filter((part) => kept.has(part));
280
+ const omitted = sections.filter((parts) => !kept.has(parts[0]!));
281
+ const names = omitted.map((parts) => parts[0]!.name);
282
+ const body = render(source, chosen);
283
+ const closing =
284
+ omitted.length > 0
285
+ ? closingLine(
286
+ names,
287
+ fullTextSources(source, omitted.flat()),
288
+ COMPANION_INSTRUCTIONS_BUDGET - body.length - "\n\n".length,
289
+ )
290
+ : undefined;
291
+ return { text: render(source, chosen, closing), omitted: names };
292
+ };
293
+
294
+ let result = build();
295
+ /**
296
+ * The reserve names every section; a dropped one may still fit beside the
297
+ * real, shorter line. Each section added shortens that line, so the pass
298
+ * repeats until none is added.
299
+ */
300
+ for (let added = true; added;) {
301
+ added = false;
302
+ for (const parts of sections) {
303
+ if (result.text.length > COMPANION_INSTRUCTIONS_BUDGET || kept.has(parts[0]!)) continue;
304
+ for (const part of parts) kept.add(part);
305
+ const next = build();
306
+ if (next.text.length <= COMPANION_INSTRUCTIONS_BUDGET) {
307
+ result = next;
308
+ added = true;
309
+ } else for (const part of parts) kept.delete(part);
310
+ }
311
+ }
312
+ return { ...result, oversized: result.text.length > COMPANION_INSTRUCTIONS_BUDGET };
313
+ }
314
+
315
+ export function buildCompanionInstructions(
316
+ input: CompanionInstructionsInput,
317
+ deps: CompanionInstructionsDeps = {},
318
+ ): CompanionInstructions {
319
+ const source = path.join(input.companionPath, "AGENTS.md");
320
+ const read = readText(source, deps);
321
+ if (!("text" in read)) return { kind: "unavailable", source, reason: read.reason };
322
+
323
+ return { kind: "available", source, ...fit(source, segment(read.text, source, input, deps)) };
324
+ }
325
+
326
+ /**
327
+ * Text beyond the `@AGENTS.md` import, Mate-managed blocks and HTML comments:
328
+ * only a companion-scoped session reads it; the Companion Instructions leave it out.
329
+ */
330
+ export function companionClaudeMdHasOwnText(
331
+ companionPath: string,
332
+ deps: CompanionInstructionsDeps = {},
333
+ ): boolean {
334
+ const read = readText(path.join(companionPath, "CLAUDE.md"), deps);
335
+ if (!("text" in read)) return false;
336
+ return scanLinesWithoutComments(stripManagedBlocks(read.text, FRAMEWORK_NAME)).some((line) => {
337
+ const trimmed = line.text.trim();
338
+ return trimmed !== "" && trimmed !== "@AGENTS.md" && trimmed !== "@./AGENTS.md";
339
+ });
340
+ }
@@ -0,0 +1,4 @@
1
+ /** The message of a thrown value, whether or not it is an `Error`. */
2
+ export function errorMessage(error: unknown): string {
3
+ return error instanceof Error ? error.message : String(error);
4
+ }
@@ -1,3 +1,8 @@
1
+ export {
2
+ ARTIFACT_REFUSAL_HEADLINE,
3
+ artifactRefusal,
4
+ type ArtifactRefusalInput,
5
+ } from "./artifact-refusal";
1
6
  export {
2
7
  GRAPHIFY_SHARED_COMPANION_PATH_CONTRACT,
3
8
  GRAPHIFY_COMPANION_PATH_CONTRACT,
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Shared by the setup writers and the Companion Instructions reader. In
3
+ * `runtime/` because hooks may import nothing outside it.
4
+ */
5
+
6
+ import { fencedRanges, trimBlankLines } from "./markdown-scan";
7
+
8
+ export interface ManagedBlockMarkers {
9
+ start: string;
10
+ end: string;
11
+ }
12
+
13
+ export function managedBlockMarkers(frameworkName: string, blockKey: string): ManagedBlockMarkers {
14
+ return {
15
+ start: `<!-- ${frameworkName}:managed:${blockKey} start -->`,
16
+ end: `<!-- ${frameworkName}:managed:${blockKey} end -->`,
17
+ };
18
+ }
19
+
20
+ export function companionBlockMarkers(frameworkName: string): ManagedBlockMarkers {
21
+ const name = frameworkName.toUpperCase();
22
+ return {
23
+ start: `<!-- ${name}:COMPANION:START -->`,
24
+ end: `<!-- ${name}:COMPANION:END -->`,
25
+ };
26
+ }
27
+
28
+ /**
29
+ * The plugin owning a block whose key is `<pluginId>:<content hash>`, the
30
+ * shape every setup writer produces; a key without a hash is returned whole.
31
+ */
32
+ export function managedBlockPluginId(blockKey: string): string {
33
+ const separator = blockKey.lastIndexOf(":");
34
+ return separator === -1 ? blockKey : blockKey.slice(0, separator);
35
+ }
36
+
37
+ function escapeRegExp(value: string): string {
38
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
39
+ }
40
+
41
+ /** Matches a start marker; group 1 is the key. Given `pluginId`, only that plugin's blocks match. */
42
+ export function managedBlockStartPattern(frameworkName: string, pluginId?: string): RegExp {
43
+ const keyPattern = pluginId === undefined ? "[^\\s]+" : `${escapeRegExp(pluginId)}:[0-9a-f]+`;
44
+ return new RegExp(`<!-- ${escapeRegExp(frameworkName)}:managed:(${keyPattern}) start -->`, "g");
45
+ }
46
+
47
+ export interface ManagedBlock {
48
+ key: string;
49
+ /** Offsets of the whole block, markers included. */
50
+ start: number;
51
+ end: number;
52
+ body: string;
53
+ }
54
+
55
+ /** A marker quoted in a code fence is an example, not a block. */
56
+ function inFence(fenced: readonly [number, number][], index: number): boolean {
57
+ return fenced.some(([from, to]) => index >= from && index < to);
58
+ }
59
+
60
+ /** The first `marker` at or after `from` outside a code fence, or -1. */
61
+ function indexOutsideFence(
62
+ text: string,
63
+ marker: string,
64
+ from: number,
65
+ fenced: readonly [number, number][],
66
+ ): number {
67
+ let index = text.indexOf(marker, from);
68
+ while (index !== -1 && inFence(fenced, index)) {
69
+ index = text.indexOf(marker, index + marker.length);
70
+ }
71
+ return index;
72
+ }
73
+
74
+ /** Offsets of the first `MATE:COMPANION` block, markers included; `null` without one. */
75
+ export function findCompanionBlock(
76
+ text: string,
77
+ frameworkName: string,
78
+ ): { start: number; end: number } | null {
79
+ const { start, end } = companionBlockMarkers(frameworkName);
80
+ const fenced = fencedRanges(text);
81
+ const startIndex = indexOutsideFence(text, start, 0, fenced);
82
+ if (startIndex === -1) return null;
83
+ const endIndex = indexOutsideFence(text, end, startIndex + start.length, fenced);
84
+ return endIndex === -1 ? null : { start: startIndex, end: endIndex + end.length };
85
+ }
86
+
87
+ export function findManagedBlocks(text: string, frameworkName: string): ManagedBlock[] {
88
+ const blocks: ManagedBlock[] = [];
89
+ const fenced = fencedRanges(text);
90
+ for (const match of text.matchAll(managedBlockStartPattern(frameworkName))) {
91
+ if (inFence(fenced, match.index)) continue;
92
+ const key = match[1]!;
93
+ const { start, end } = managedBlockMarkers(frameworkName, key);
94
+ const bodyStart = match.index + start.length;
95
+ const endIndex = indexOutsideFence(text, end, bodyStart, fenced);
96
+ if (endIndex === -1) continue;
97
+ const previous = blocks.at(-1);
98
+ if (previous && match.index < previous.end) continue;
99
+ blocks.push({
100
+ key,
101
+ start: match.index,
102
+ end: endIndex + end.length,
103
+ body: trimBlankLines(text.slice(bodyStart, endIndex)),
104
+ });
105
+ }
106
+ return blocks;
107
+ }
108
+
109
+ export function stripManagedBlocks(text: string, frameworkName: string): string {
110
+ let result = "";
111
+ let cursor = 0;
112
+ for (const block of findManagedBlocks(text, frameworkName)) {
113
+ result += text.slice(cursor, block.start);
114
+ cursor = block.end;
115
+ }
116
+ return result + text.slice(cursor);
117
+ }