@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,266 @@
1
+ /**
2
+ * A line scan of CommonMark, deep enough to find `##` sections, fenced code
3
+ * and block-level HTML comments as Claude Code's memory loading reads them.
4
+ */
5
+
6
+ /** A block quote, or a list item whose content starts `width` columns into its parent's. */
7
+ type Container = { kind: "quote" } | { kind: "item"; width: number };
8
+
9
+ interface Fence {
10
+ char: string;
11
+ length: number;
12
+ /** The containers the fence opened in, outermost first; a line that leaves one ends the fence. */
13
+ containers: readonly Container[];
14
+ }
15
+
16
+ const FENCE_OPEN = /^(`{3,}|~{3,})(.*)$/;
17
+ const FENCE_CLOSE = /^(`{3,}|~{3,})[ \t]*$/;
18
+ const LIST_MARKER = /^(?:[-+*]|\d{1,9}[.)])/;
19
+ const BLANK = /^[ \t]*$/;
20
+
21
+ function indentation(text: string, from: number): { column: number; rest: string } {
22
+ let column = from;
23
+ let index = 0;
24
+ for (; index < text.length; index++) {
25
+ if (text[index] === " ") column++;
26
+ else if (text[index] === "\t") column += 4 - (column % 4);
27
+ else break;
28
+ }
29
+ return { column, rest: text.slice(index) };
30
+ }
31
+
32
+ /** Where a line's text stands: `rest` starts at `column`; the innermost container's content starts at `base`. */
33
+ interface Position {
34
+ column: number;
35
+ rest: string;
36
+ base: number;
37
+ }
38
+
39
+ /** CommonMark: `>` after up to three spaces of indentation, and one optional space that belongs to the marker. */
40
+ function afterQuoteMarker({ column, rest, base }: Position): Position | null {
41
+ if (column - base > 3 || !rest.startsWith(">")) return null;
42
+ const marker = column + 1;
43
+ const after = indentation(rest.slice(1), marker);
44
+ return { ...after, base: after.column > marker ? marker + 1 : marker };
45
+ }
46
+
47
+ /** The open containers a line continues, outermost first. A blank line continues list items, never a quote. */
48
+ function continueContainers(
49
+ line: string,
50
+ containers: readonly Container[],
51
+ ): { matched: Container[]; at: Position } {
52
+ let at: Position = { ...indentation(line, 0), base: 0 };
53
+ const matched: Container[] = [];
54
+ for (const container of containers) {
55
+ if (container.kind === "quote") {
56
+ const next = afterQuoteMarker(at);
57
+ if (!next) break;
58
+ at = next;
59
+ } else {
60
+ if (at.rest !== "" && at.column < at.base + container.width) break;
61
+ at = { ...at, base: at.base + container.width };
62
+ }
63
+ matched.push(container);
64
+ }
65
+ return { matched, at };
66
+ }
67
+
68
+ /** The open containers carried from line to line. */
69
+ interface ContainerContext {
70
+ containers: readonly Container[];
71
+ /** A paragraph is open, so a line that leaves its containers may continue it lazily. */
72
+ paragraph: boolean;
73
+ /** The innermost item opened on a blank line, so a blank line next ends it. */
74
+ emptyItem: boolean;
75
+ }
76
+
77
+ const TOP_LEVEL: ContainerContext = { containers: [], paragraph: false, emptyItem: false };
78
+
79
+ /** CommonMark: a line that starts a block, so it ends a paragraph rather than continuing it. */
80
+ const INTERRUPTS = /^(?:`{3,}|~{3,}|#{1,6}(?:[ \t]|$)|>|<!--|(?:[-+*]|\d{1,9}[.)])(?:[ \t]|$))/;
81
+ const THEMATIC_BREAK = /^(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/;
82
+
83
+ /**
84
+ * Tracks only as much of CommonMark's block quotes and list items as placing a
85
+ * fence needs. A backtick fence's info string may hold no backtick, so
86
+ * ```` ```a``` ```` is inline code.
87
+ */
88
+ function lineBlock(
89
+ line: string,
90
+ context: ContainerContext,
91
+ ): { fence: Fence | null; context: ContainerContext } {
92
+ const { matched, at } = continueContainers(line, context.containers);
93
+ const continues = matched.length === context.containers.length;
94
+ let { column, rest, base } = at;
95
+ if (rest === "") {
96
+ const containers = continues && context.emptyItem ? matched.slice(0, -1) : matched;
97
+ return { fence: null, context: { containers, paragraph: false, emptyItem: false } };
98
+ }
99
+ if (!continues) {
100
+ const starts = INTERRUPTS.test(rest) || THEMATIC_BREAK.test(rest);
101
+ const lazy = context.paragraph && !(column - base <= 3 && starts);
102
+ if (lazy) return { fence: null, context };
103
+ }
104
+ let containers = matched;
105
+ let paragraph = context.paragraph && continues;
106
+
107
+ while (column - base <= 3 && !THEMATIC_BREAK.test(rest)) {
108
+ const quoted = afterQuoteMarker({ column, rest, base });
109
+ if (quoted) {
110
+ containers = [...containers, { kind: "quote" }];
111
+ ({ column, rest, base } = quoted);
112
+ paragraph = false;
113
+ if (rest === "") return { fence: null, context: { containers, paragraph, emptyItem: false } };
114
+ continue;
115
+ }
116
+ const marker = LIST_MARKER.exec(rest);
117
+ if (!marker) break;
118
+ const markerEnd = column + marker[0].length;
119
+ const after = indentation(rest.slice(marker[0].length), markerEnd);
120
+ const gap = after.column - markerEnd;
121
+ if (gap < 1 && after.rest !== "") break;
122
+ const ordered = marker[0].length > 1;
123
+ if (paragraph && (after.rest === "" || (ordered && Number.parseInt(marker[0], 10) !== 1))) {
124
+ break;
125
+ }
126
+ const content = gap > 4 || after.rest === "" ? markerEnd + 1 : after.column;
127
+ containers = [...containers, { kind: "item", width: content - base }];
128
+ base = content;
129
+ paragraph = false;
130
+ if (after.rest === "") {
131
+ return { fence: null, context: { containers, paragraph, emptyItem: true } };
132
+ }
133
+ column = after.column;
134
+ rest = after.rest;
135
+ }
136
+
137
+ if (column - base > 3) {
138
+ return { fence: null, context: { containers, paragraph, emptyItem: false } };
139
+ }
140
+ const match = FENCE_OPEN.exec(rest);
141
+ const run = match?.[1];
142
+ if (run && !(run.startsWith("`") && match[2]!.includes("`"))) {
143
+ const fence = { char: run[0]!, length: run.length, containers };
144
+ return { fence, context: { containers, paragraph: false, emptyItem: false } };
145
+ }
146
+ paragraph = !THEMATIC_BREAK.test(rest) && !/^#{1,6}(?:[ \t]|$)/.test(rest);
147
+ return { fence: null, context: { containers, paragraph, emptyItem: false } };
148
+ }
149
+
150
+ /** `ends`: a line that leaves one of the fence's containers ends it; fenced code never continues lazily. */
151
+ function fenceLine(line: string, fence: Fence): "content" | "close" | "ends" {
152
+ const { matched, at } = continueContainers(line, fence.containers);
153
+ if (matched.length < fence.containers.length) return "ends";
154
+ if (at.rest === "") return "content";
155
+ const match = at.column - at.base <= 3 ? FENCE_CLOSE.exec(at.rest) : null;
156
+ return match !== null && match[1]!.startsWith(fence.char) && match[1]!.length >= fence.length
157
+ ? "close"
158
+ : "content";
159
+ }
160
+
161
+ /** CommonMark: an ATX heading may be indented by up to three spaces, and be empty. */
162
+ const HEADING = /^ {0,3}##(?:[ \t]+(.*))?$/;
163
+ /** CommonMark: a closing run of `#`s, after a space or tab, is no part of the heading. */
164
+ const CLOSING_SEQUENCE = /(?:^|[ \t]+)#+[ \t]*$/;
165
+
166
+ function headingText(line: string): string | null {
167
+ const match = HEADING.exec(line);
168
+ if (!match) return null;
169
+ return (match[1] ?? "").replace(CLOSING_SEQUENCE, "").trim();
170
+ }
171
+
172
+ export interface ScannedLine {
173
+ text: string;
174
+ /** The heading text, `""` when empty, when the line opens a top-level (`##`) section: outside fenced code, list items and block quotes, whose scope it would otherwise leave. */
175
+ heading: string | null;
176
+ /** Inside fenced code, where blank lines are content. */
177
+ fenced: boolean;
178
+ /** Where the source line starts in the scanned text. */
179
+ offset: number;
180
+ }
181
+
182
+ /** CommonMark: an HTML block opens on a line that begins, after up to three spaces, with `<!--`. */
183
+ const BLOCK_COMMENT_OPEN = /^ {0,3}<!--/;
184
+ /** Claude Code's own pattern for the comments it removes from such a block. */
185
+ const CLOSED_COMMENT = /<!--[\s\S]*?-->/g;
186
+
187
+ type ScanState = { in: "prose" } | { in: "fence"; fence: Fence } | { in: "comment" };
188
+
189
+ const PROSE: ScanState = { in: "prose" };
190
+ const COMMENT: ScanState = { in: "comment" };
191
+
192
+ /**
193
+ * Strips block-level HTML comments as Claude Code's memory loading does: only
194
+ * an HTML block that opens with `<!--` and holds a `-->`, so a `<!--` inside
195
+ * prose, inside fenced code, or never closed stays. The rest of a closing line
196
+ * is still part of the HTML block, so it opens no heading and no fence.
197
+ */
198
+ export function scanLinesWithoutComments(text: string): ScannedLine[] {
199
+ const lines: ScannedLine[] = [];
200
+ let state = PROSE;
201
+ let list = TOP_LEVEL;
202
+ let offset = 0;
203
+
204
+ const emit = (line: string): void => {
205
+ const next = lineBlock(line, list);
206
+ const heading = next.context.containers.length === 0 ? headingText(line) : null;
207
+ lines.push({ text: line, heading, fenced: false, offset });
208
+ list = next.context;
209
+ state = next.fence ? { in: "fence", fence: next.fence } : PROSE;
210
+ };
211
+ /** A comment's lines are one HTML block: it ends the containers it leaves. */
212
+ const enterComment = (line: string): void => {
213
+ list = { ...lineBlock(line, list).context, paragraph: false };
214
+ };
215
+ const emitAfterClose = (line: string, close: number): void => {
216
+ const rest = line.slice(close + 3).replace(CLOSED_COMMENT, "");
217
+ lines.push({ text: rest, heading: null, fenced: false, offset });
218
+ state = PROSE;
219
+ };
220
+
221
+ for (const raw of text.split("\n")) {
222
+ const inFence = state.in === "fence" ? fenceLine(raw, state.fence) : "ends";
223
+ if (inFence !== "ends") {
224
+ lines.push({ text: raw, heading: null, fenced: true, offset });
225
+ if (inFence === "close") state = PROSE;
226
+ } else if (state.in === "comment") {
227
+ const close = raw.indexOf("-->");
228
+ if (close !== -1) emitAfterClose(raw, close);
229
+ } else if (BLOCK_COMMENT_OPEN.test(raw)) {
230
+ const close = text.indexOf("-->", offset + raw.indexOf("<!--") + 4);
231
+ if (close === -1) emit(raw);
232
+ else {
233
+ enterComment(raw);
234
+ if (close < offset + raw.length) emitAfterClose(raw, close - offset);
235
+ else state = COMMENT;
236
+ }
237
+ } else {
238
+ emit(raw);
239
+ }
240
+ offset += raw.length + 1;
241
+ }
242
+ return lines;
243
+ }
244
+
245
+ /** Offsets `[start, end)` of the lines inside fenced code. */
246
+ export function fencedRanges(text: string): [number, number][] {
247
+ return scanLinesWithoutComments(text)
248
+ .filter((line) => line.fenced)
249
+ .map((line) => [line.offset, line.offset + line.text.length]);
250
+ }
251
+
252
+ export function joinLines(lines: readonly ScannedLine[]): string {
253
+ const kept: string[] = [];
254
+ let afterBlank = false;
255
+ for (const line of lines) {
256
+ const blank = !line.fenced && BLANK.test(line.text);
257
+ if (!(blank && afterBlank)) kept.push(blank ? "" : line.text);
258
+ afterBlank = blank;
259
+ }
260
+ return trimBlankLines(kept.join("\n"));
261
+ }
262
+
263
+ /** Drops the blank lines around a text but no indentation: an indented fence opener keeps its meaning. */
264
+ export function trimBlankLines(text: string): string {
265
+ return text.replace(/^(?:[ \t]*\n)+/, "").trimEnd();
266
+ }
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { parse } from "yaml";
5
5
 
6
6
  import { FRAMEWORK_NAME } from "./framework";
7
+ import { defaultSecretPolicy, parseSecretsConfig, type SecretPolicy } from "./secret-patterns";
7
8
 
8
9
  /**
9
10
  * Predicates that are never projected: they are read live from the companion
@@ -14,12 +15,18 @@ export interface CompanionPolicy {
14
15
  /** Names of the Capabilities the companion has enabled. */
15
16
  enabledCapabilities: string[];
16
17
  gitAutoMode: boolean;
18
+ /**
19
+ * Secret guardrails. Unlike the other fields, a missing or broken config
20
+ * yields guardrails on: protection never fails open.
21
+ */
22
+ secrets: SecretPolicy;
17
23
  }
18
24
 
19
25
  export const emptyCompanionPolicy = (): CompanionPolicy => ({
20
26
  allowedAgents: [],
21
27
  enabledCapabilities: [],
22
28
  gitAutoMode: false,
29
+ secrets: defaultSecretPolicy(),
23
30
  });
24
31
 
25
32
  export function companionFrameworkConfigPath(companionPath: string): string {
@@ -42,7 +49,8 @@ function capabilityNames(value: unknown): string[] {
42
49
 
43
50
  /**
44
51
  * Synchronous so it composes into `readCompanionRuntimeContext`. A missing or
45
- * unparseable companion config yields an inert policy rather than throwing.
52
+ * unparseable companion config yields an inert policy rather than throwing,
53
+ * with secret guardrails active.
46
54
  */
47
55
  export function readCompanionPolicy(companionPath: string): CompanionPolicy {
48
56
  let parsed: unknown;
@@ -58,9 +66,15 @@ export function readCompanionPolicy(companionPath: string): CompanionPolicy {
58
66
  allowedAgents: stringList(config.allowedAgents),
59
67
  enabledCapabilities: capabilityNames(config.capabilities),
60
68
  gitAutoMode: config.git === "auto",
69
+ secrets: parseSecretsConfig(config.secrets),
61
70
  };
62
71
  }
63
72
 
73
+ /** The companion's live secret policy, read on every call. */
74
+ export function resolveSecretPolicy(companionPath: string): SecretPolicy {
75
+ return readCompanionPolicy(companionPath).secrets;
76
+ }
77
+
64
78
  export function isCapabilityEnabled(policy: CompanionPolicy, name: string): boolean {
65
79
  return policy.enabledCapabilities.includes(name);
66
80
  }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Marks the hook copies a Claude wrap inlines into a Working Repository's
3
+ * settings. A managed launch loads the bundled plugin's copy of the same hooks
4
+ * against the same environment, so nothing inside the process tells the two
5
+ * apart but this flag.
6
+ */
7
+ export const PROJECTED_FLAG = "--projected";
@@ -38,6 +38,7 @@ export function buildProjectedGuidance(
38
38
  companionPath: projection.companionPath,
39
39
  repository: { id: projection.repositoryId, path: projection.repositoryPath },
40
40
  capabilities: policy.enabledCapabilities.map((name) => ({ name })),
41
+ secretGuardrails: policy.secrets.guardrails,
41
42
  },
42
43
  /** The projected path, not the running install's: a wrap may be another mate's. */
43
44
  { wrapperBinPath: projection.wrapperBinPath },
@@ -62,6 +62,30 @@ export interface RenderedRuntimeDocument {
62
62
  readonly regions: readonly ManagedRegion[];
63
63
  }
64
64
 
65
+ /** What a working-target render returns: its warnings are data for the caller, never printed. */
66
+ export interface RenderedRuntimeDocuments {
67
+ documents: RenderedRuntimeDocument[];
68
+ warnings: string[];
69
+ }
70
+
71
+ function nonEmpty(region: ManagedRegion): boolean {
72
+ if (region.kind === "list") return region.values.length > 0;
73
+ /** A stripping map still heals what an older Mate left, with no entry of its own. */
74
+ if (region.kind === "map") {
75
+ return Object.keys(region.entries).length > 0 || region.stripPrefix !== undefined;
76
+ }
77
+ return true;
78
+ }
79
+
80
+ /** One rendered document of its non-empty regions, or none when every region is empty. */
81
+ export function renderedDocument(
82
+ documentPath: string,
83
+ regions: ManagedRegion[],
84
+ ): RenderedRuntimeDocument[] {
85
+ const kept = regions.filter(nonEmpty);
86
+ return kept.length > 0 ? [{ path: documentPath, regions: kept }] : [];
87
+ }
88
+
65
89
  /** An owned companion copy: the hash of what Mate last wrote there. */
66
90
  export interface CopyRecord {
67
91
  runtime: WrapRuntime;
@@ -185,14 +209,24 @@ export function copies(manifest: Manifest, runtime?: WrapRuntime): Record<string
185
209
  );
186
210
  }
187
211
 
212
+ /** Narrows link records to one link root, e.g. `.claude/skills/`. */
213
+ export interface LinkScope {
214
+ prefix?: string;
215
+ }
216
+
188
217
  /** Links Mate created, keyed by repo-relative POSIX path. */
189
- export function links(manifest: Manifest, runtime?: WrapRuntime): Record<string, LinkRecord> {
218
+ export function links(
219
+ manifest: Manifest,
220
+ runtime?: WrapRuntime,
221
+ scope: LinkScope = {},
222
+ ): Record<string, LinkRecord> {
190
223
  return Object.fromEntries(
191
224
  Object.entries(manifest.links).filter(
192
- ([, record]) =>
225
+ ([key, record]) =>
193
226
  isRecord(record) &&
194
227
  typeof record.target === "string" &&
195
- (!runtime || record.runtime === runtime),
228
+ (!runtime || record.runtime === runtime) &&
229
+ key.startsWith(scope.prefix ?? ""),
196
230
  ),
197
231
  );
198
232
  }
@@ -215,11 +249,23 @@ export function recordedMapKeys(
215
249
  documentPath: string,
216
250
  at: readonly string[],
217
251
  ): string[] {
218
- return (documentRegions(manifest, documentPath) ?? []).flatMap((region: unknown) =>
219
- isRecord(region) && region.kind === "map" && isAt(region.at, at) && isRecord(region.entries)
220
- ? Object.keys(region.entries)
221
- : [],
222
- );
252
+ return Object.keys(recordedMapEntries(manifest, documentPath, at));
253
+ }
254
+
255
+ /** The entries of the `map` regions recorded at `at` in one document, checked like {@link recordedMapKeys}. */
256
+ export function recordedMapEntries(
257
+ manifest: Manifest,
258
+ documentPath: string,
259
+ at: readonly string[],
260
+ ): Record<string, unknown> {
261
+ return Object.assign(
262
+ {},
263
+ ...(documentRegions(manifest, documentPath) ?? []).flatMap((region: unknown) =>
264
+ isRecord(region) && region.kind === "map" && isAt(region.at, at) && isRecord(region.entries)
265
+ ? [region.entries]
266
+ : [],
267
+ ),
268
+ ) as Record<string, unknown>;
223
269
  }
224
270
 
225
271
  /** Every value of the `list` regions recorded under `prefix` in one document. */
@@ -262,14 +308,15 @@ export function withoutDocuments(manifest: Manifest, documentPaths: readonly str
262
308
  };
263
309
  }
264
310
 
265
- /** Replaces one runtime's records, leaving every other record as it is. */
311
+ /** Replaces one runtime's records under `prefix`, leaving every other record as it is. */
266
312
  function replaceRuntimeRecords<T>(
267
313
  existing: Readonly<Record<string, T>>,
268
314
  runtime: WrapRuntime,
269
315
  records: Readonly<Record<string, T>>,
316
+ prefix = "",
270
317
  ): Record<string, T> {
271
318
  const kept = Object.entries(existing).filter(
272
- ([, record]) => !(isRecord(record) && record.runtime === runtime),
319
+ ([key, record]) => !(isRecord(record) && record.runtime === runtime && key.startsWith(prefix)),
273
320
  );
274
321
  return { ...Object.fromEntries(kept), ...records };
275
322
  }
@@ -286,8 +333,12 @@ export function withLinks(
286
333
  manifest: Manifest,
287
334
  runtime: WrapRuntime,
288
335
  records: Readonly<Record<string, LinkRecord>>,
336
+ scope: LinkScope = {},
289
337
  ): Manifest {
290
- return { ...manifest, links: replaceRuntimeRecords(manifest.links, runtime, records) };
338
+ return {
339
+ ...manifest,
340
+ links: replaceRuntimeRecords(manifest.links, runtime, records, scope.prefix),
341
+ };
291
342
  }
292
343
 
293
344
  export function manifestPath(repoPath: string): string {
@@ -0,0 +1,148 @@
1
+ import { resolveSecretPolicy } from "./policy";
2
+ import { globTargetsSecretFiles, isSecretFile } from "./secret-patterns";
3
+ import { describeSecretFindings, scanContent, type SecretFinding } from "./secret-scan";
4
+ import { HEREDOC_MARKER, stripHeredocBodies } from "./shell-heredoc";
5
+
6
+ /**
7
+ * The secret check both runtimes run before a tool call: the Claude PreToolUse
8
+ * hook and the OpenCode middleware translate their own tool payloads into one
9
+ * of these shapes, so the two refuse the same calls for the same reasons.
10
+ */
11
+ export type SecretToolCall =
12
+ | { kind: "read"; paths: string[] }
13
+ /** `content` is what the call would put into the file: a whole body or an edit's new text. */
14
+ | { kind: "write"; writes: { path: string; content: string }[] }
15
+ | { kind: "shell"; command: string }
16
+ /** A content search: refused when it names a secret file or a glob aimed at secret files. */
17
+ | { kind: "search"; paths: string[]; globs: string[] };
18
+
19
+ export interface SecretGuardScope {
20
+ companionPath: string;
21
+ /** Whether a target lands in the Companion Repository, whose writes are content-scanned. */
22
+ isCompanionPath: (filePath: string) => boolean;
23
+ }
24
+
25
+ export interface SecretGuardDeps {
26
+ scan?: (filePath: string, content: string) => Promise<SecretFinding[]>;
27
+ }
28
+
29
+ const SWITCH_HINT =
30
+ "Secret guardrails are on. The user can add an allow pattern or opt out under `secrets` in the companion's framework.yaml; do not work around this block.";
31
+
32
+ function secretFileRefusal(filePath: string): string {
33
+ return [
34
+ "Mate guardrail: secret files are off-limits to agents.",
35
+ ` target: ${filePath}`,
36
+ "Refer to the variable by name, read an example file such as .env.example, or ask the user to set the value.",
37
+ SWITCH_HINT,
38
+ ].join("\n");
39
+ }
40
+
41
+ const SHELL_SEPARATOR = /[\s;|&<>()`]/;
42
+
43
+ /**
44
+ * Shell words with quotes removed, redirect operators as separators and
45
+ * heredoc bodies dropped. A heuristic, not a parser: it exists to find file
46
+ * names a command spells out, and a quoted string stays one word, so a
47
+ * commit message that mentions `.env` is not a reference to the file.
48
+ */
49
+ export function shellWords(command: string): string[] {
50
+ const words: string[] = [];
51
+ for (const line of stripHeredocBodies(command).split("\n")) {
52
+ let word = "";
53
+ let quote: string | undefined;
54
+ let quoted = false;
55
+ const flush = () => {
56
+ if (word || quoted) words.push(word);
57
+ word = "";
58
+ quoted = false;
59
+ };
60
+ for (let i = 0; i < line.length; i++) {
61
+ const char = line[i];
62
+ if (quote) {
63
+ if (char === quote) quote = undefined;
64
+ else if (char === "\\" && quote === '"' && i + 1 < line.length) word += line[++i];
65
+ else word += char;
66
+ } else if (char === "'" || char === '"') {
67
+ quote = char;
68
+ quoted = true;
69
+ } else if (char === "\\" && i + 1 < line.length) {
70
+ word += line[++i];
71
+ } else if (char === "<" && line[i + 1] === "<") {
72
+ flush();
73
+ const marker = HEREDOC_MARKER.exec(line.slice(i));
74
+ i += marker ? marker[0].length - 1 : 1;
75
+ } else if (SHELL_SEPARATOR.test(char)) {
76
+ flush();
77
+ } else {
78
+ word += char;
79
+ }
80
+ }
81
+ flush();
82
+ }
83
+ return words.filter(Boolean);
84
+ }
85
+
86
+ /** A word, plus the value of a `--flag=path` or `NAME=path` word. */
87
+ function candidatePaths(word: string): string[] {
88
+ const equals = word.indexOf("=");
89
+ return equals > 0 ? [word, word.slice(equals + 1)] : [word];
90
+ }
91
+
92
+ /**
93
+ * Returns the refusal message for a call that touches a secret, or null. Reads
94
+ * the companion's `secrets` block live, so a toggle applies to the next call.
95
+ */
96
+ export async function checkSecretAccess(
97
+ call: SecretToolCall,
98
+ scope: SecretGuardScope,
99
+ deps: SecretGuardDeps = {},
100
+ ): Promise<string | null> {
101
+ const policy = resolveSecretPolicy(scope.companionPath);
102
+ if (!policy.guardrails) return null;
103
+
104
+ if (call.kind === "shell") {
105
+ const named = shellWords(call.command)
106
+ .flatMap(candidatePaths)
107
+ .find((word) => isSecretFile(word, policy));
108
+ return named ? secretFileRefusal(named) : null;
109
+ }
110
+
111
+ if (call.kind === "search") {
112
+ const named =
113
+ call.paths.find((filePath) => filePath && isSecretFile(filePath, policy)) ??
114
+ call.globs.find((glob) => globTargetsSecretFiles(glob, policy));
115
+ return named ? secretFileRefusal(named) : null;
116
+ }
117
+
118
+ const paths = call.kind === "read" ? call.paths : call.writes.map((write) => write.path);
119
+ const secretPath = paths.find((filePath) => filePath && isSecretFile(filePath, policy));
120
+ if (secretPath) return secretFileRefusal(secretPath);
121
+ if (call.kind === "read") return null;
122
+
123
+ const scan = deps.scan ?? scanContent;
124
+ for (const write of call.writes) {
125
+ if (!write.path || !write.content || !scope.isCompanionPath(write.path)) continue;
126
+ let findings: SecretFinding[];
127
+ try {
128
+ // oxlint-disable-next-line no-await-in-loop -- the first refusal wins
129
+ findings = await scan(write.path, write.content);
130
+ } catch {
131
+ return [
132
+ "Mate guardrail: content for the Companion Repository could not be scanned for secrets, so the write is blocked.",
133
+ ` target: ${write.path}`,
134
+ SWITCH_HINT,
135
+ ].join("\n");
136
+ }
137
+ if (findings.length > 0) {
138
+ return [
139
+ "Mate guardrail: this write would put a secret into the Companion Repository.",
140
+ ` target: ${write.path}`,
141
+ ...describeSecretFindings(findings).map((line) => ` finding: ${line}`),
142
+ "Replace the value with a placeholder or the variable name. For a deliberate fake, wrap it in secretlint-disable/secretlint-enable comments.",
143
+ SWITCH_HINT,
144
+ ].join("\n");
145
+ }
146
+ }
147
+ return null;
148
+ }