@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,164 @@
1
+ /**
2
+ * The one secret-file pattern set every guardrail layer reads: guidance,
3
+ * projected deny rules, hook and middleware enforcement, indexer exclusion and
4
+ * the publish scan. Keeping it in one module is what stops the layers from
5
+ * disagreeing about what a secret file is.
6
+ */
7
+
8
+ export const DEFAULT_SECRET_DENY_PATTERNS: readonly string[] = [
9
+ ".env",
10
+ ".env.*",
11
+ "*.pem",
12
+ "*.key",
13
+ "*.p12",
14
+ "*.pfx",
15
+ "id_rsa",
16
+ "id_ecdsa",
17
+ "id_ed25519",
18
+ ".netrc",
19
+ ".pgpass",
20
+ ".npmrc",
21
+ ];
22
+
23
+ /** Example files that document which variables exist; they never hold values. */
24
+ export const DEFAULT_SECRET_ALLOW_PATTERNS: readonly string[] = [
25
+ ".env.example",
26
+ ".env.sample",
27
+ ".env.template",
28
+ ".env.dist",
29
+ ];
30
+
31
+ export interface SecretPolicy {
32
+ /** False only when the companion explicitly opts out. */
33
+ guardrails: boolean;
34
+ /** Defaults plus the companion's extra `secrets.deny` patterns. */
35
+ deny: string[];
36
+ /** Defaults plus the companion's extra `secrets.allow` patterns. */
37
+ allow: string[];
38
+ }
39
+
40
+ export const defaultSecretPolicy = (): SecretPolicy => ({
41
+ guardrails: true,
42
+ deny: [...DEFAULT_SECRET_DENY_PATTERNS],
43
+ allow: [...DEFAULT_SECRET_ALLOW_PATTERNS],
44
+ });
45
+
46
+ function patternList(value: unknown): string[] {
47
+ if (!Array.isArray(value)) return [];
48
+ return value.filter((entry): entry is string => typeof entry === "string" && entry.trim() !== "");
49
+ }
50
+
51
+ /**
52
+ * Parses the `secrets` block of `framework.yaml`. Anything that is not an
53
+ * object yields the defaults with guardrails on, and only a literal `false`
54
+ * turns them off: a broken config never disables protection.
55
+ */
56
+ export function parseSecretsConfig(value: unknown): SecretPolicy {
57
+ const policy = defaultSecretPolicy();
58
+ if (!value || typeof value !== "object" || Array.isArray(value)) return policy;
59
+ const block = value as Record<string, unknown>;
60
+ return {
61
+ guardrails: block.guardrails !== false,
62
+ deny: [...policy.deny, ...patternList(block.deny)],
63
+ allow: [...policy.allow, ...patternList(block.allow)],
64
+ };
65
+ }
66
+
67
+ const compiled = new Map<string, RegExp>();
68
+
69
+ /** Gitignore-style subset: `*` and `?` stay inside a segment, `**` crosses them. */
70
+ function patternRegExp(pattern: string): RegExp {
71
+ const cached = compiled.get(pattern);
72
+ if (cached) return cached;
73
+ let source = "";
74
+ const body = pattern.replace(/^\/+/, "");
75
+ for (let i = 0; i < body.length; i++) {
76
+ const char = body[i];
77
+ if (char === "*" && body[i + 1] === "*") {
78
+ // `**/` matches zero or more whole segments; a bare `**` matches anything.
79
+ if (body[i + 2] === "/") {
80
+ source += "(?:.*/)?";
81
+ i += 2;
82
+ } else {
83
+ source += ".*";
84
+ i += 1;
85
+ }
86
+ } else if (char === "*") {
87
+ source += "[^/]*";
88
+ } else if (char === "?") {
89
+ source += "[^/]";
90
+ } else {
91
+ source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
92
+ }
93
+ }
94
+ // A slash-free pattern matches the basename; one with a slash matches the
95
+ // trailing segments of the path, since the repository root is not known here.
96
+ const regExp = body.includes("/") ? new RegExp(`(?:^|/)${source}$`) : new RegExp(`^${source}$`);
97
+ compiled.set(pattern, regExp);
98
+ return regExp;
99
+ }
100
+
101
+ function normalizePath(filePath: string): string {
102
+ return filePath.replaceAll("\\", "/").replace(/\/+$/, "");
103
+ }
104
+
105
+ export function matchesSecretPattern(filePath: string, patterns: readonly string[]): boolean {
106
+ const normalized = normalizePath(filePath);
107
+ if (!normalized) return false;
108
+ const basename = normalized.slice(normalized.lastIndexOf("/") + 1);
109
+ return patterns.some((pattern) => {
110
+ const regExp = patternRegExp(pattern);
111
+ return pattern.replace(/^\/+/, "").includes("/")
112
+ ? regExp.test(normalized)
113
+ : regExp.test(basename);
114
+ });
115
+ }
116
+
117
+ /** A path is a secret file when it matches a deny pattern and no allow pattern. */
118
+ export function isSecretFile(filePath: string, policy: SecretPolicy): boolean {
119
+ return (
120
+ matchesSecretPattern(filePath, policy.deny) && !matchesSecretPattern(filePath, policy.allow)
121
+ );
122
+ }
123
+
124
+ /**
125
+ * Narrower stand-ins for a default deny pattern that also matches an allowed
126
+ * file. Runtime deny rules are evaluated before allow rules, so `.env.*` there
127
+ * would deny `.env.example` too; these cover the common environment files.
128
+ */
129
+ const ALLOW_SAFE_EXPANSIONS: Readonly<Record<string, readonly string[]>> = {
130
+ ".env.*": [".env.local", ".env.development", ".env.production", ".env.test", ".env.*.local"],
131
+ };
132
+
133
+ /**
134
+ * The default deny patterns a runtime's own deny rules may carry: every
135
+ * default that can't match an allow-listed file, with an overlapping pattern
136
+ * replaced by its narrower stand-ins. Extra `secrets.deny` patterns are never
137
+ * projected: once removed from config, nothing would mark their rules as Mate's.
138
+ */
139
+ export function projectableSecretDenyPatterns(): string[] {
140
+ const overlapsAllow = (pattern: string) =>
141
+ DEFAULT_SECRET_ALLOW_PATTERNS.some((allowed) => matchesSecretPattern(allowed, [pattern]));
142
+ return DEFAULT_SECRET_DENY_PATTERNS.flatMap((pattern) =>
143
+ overlapsAllow(pattern) ? (ALLOW_SAFE_EXPANSIONS[pattern] ?? []) : [pattern],
144
+ ).filter((pattern) => !overlapsAllow(pattern));
145
+ }
146
+
147
+ /** Ordinary files a broad glob also matches; a glob matching one is not aimed at secrets. */
148
+ const ORDINARY_SAMPLES = ["index.ts", "README.md", "config.json"];
149
+
150
+ /**
151
+ * Whether a search glob is aimed at secret files: it matches a sample name of
152
+ * some deny pattern that the policy still treats as secret, and no ordinary
153
+ * file. `.env*` and `*.key` are; `**\/*` and `*.ts` are not. A heuristic for
154
+ * search tools, which take a glob rather than a path.
155
+ */
156
+ export function globTargetsSecretFiles(glob: string, policy: SecretPolicy): boolean {
157
+ const pattern = glob.trim();
158
+ if (!pattern) return false;
159
+ if (ORDINARY_SAMPLES.some((sample) => matchesSecretPattern(sample, [pattern]))) return false;
160
+ return policy.deny.some((deny) => {
161
+ const sample = deny.slice(deny.lastIndexOf("/") + 1).replace(/[*?]+/g, "x");
162
+ return isSecretFile(sample, policy) && matchesSecretPattern(sample, [pattern]);
163
+ });
164
+ }
@@ -0,0 +1,90 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import type { lintSource as LintSourceFn } from "@secretlint/core";
5
+ import type { SecretLintCoreConfig } from "@secretlint/types";
6
+
7
+ /** One secretlint finding. Deliberately carries no message: messages quote the value. */
8
+ export interface SecretFinding {
9
+ path: string;
10
+ line: number;
11
+ ruleId: string;
12
+ }
13
+
14
+ type LintSource = typeof LintSourceFn;
15
+
16
+ let engine: Promise<{ lintSource: LintSource; config: SecretLintCoreConfig }> | undefined;
17
+
18
+ /**
19
+ * Loaded on first use only, so hook calls that never scan don't pay for it.
20
+ * The config is fixed in memory: the recommended preset and nothing else. No
21
+ * `.secretlintrc` is ever read, so a repository can't weaken the scan.
22
+ */
23
+ function loadEngine() {
24
+ engine ??= (async () => {
25
+ const [{ lintSource }, { creator }] = await Promise.all([
26
+ import("@secretlint/core"),
27
+ import("@secretlint/secretlint-rule-preset-recommend"),
28
+ ]);
29
+ const config: SecretLintCoreConfig = {
30
+ rules: [{ id: "@secretlint/secretlint-rule-preset-recommend", rule: creator }],
31
+ };
32
+ return { lintSource, config };
33
+ })();
34
+ return engine;
35
+ }
36
+
37
+ /** Scans in-memory content. Throws when the engine fails, so callers can fail closed. */
38
+ export async function scanContent(filePath: string, content: string): Promise<SecretFinding[]> {
39
+ const { lintSource, config } = await loadEngine();
40
+ const result = await lintSource({
41
+ source: { content, filePath, contentType: "text" },
42
+ options: { config, maskSecrets: true, noPhysicFilePath: true },
43
+ });
44
+ return result.messages.map((message) => ({
45
+ path: filePath,
46
+ line: message.loc.start.line,
47
+ ruleId: message.ruleId,
48
+ }));
49
+ }
50
+
51
+ /** Every file under a path, in name order; a path that doesn't exist yields none. */
52
+ export async function listFiles(target: string): Promise<string[]> {
53
+ let stat;
54
+ try {
55
+ stat = await fs.stat(target);
56
+ } catch {
57
+ return [];
58
+ }
59
+ if (stat.isFile()) return [target];
60
+ if (!stat.isDirectory()) return [];
61
+ const entries = await fs.readdir(target, { withFileTypes: true });
62
+ const nested = await Promise.all(
63
+ entries
64
+ .toSorted((a, b) => a.name.localeCompare(b.name))
65
+ .map((entry) => listFiles(path.join(target, entry.name))),
66
+ );
67
+ return nested.flat();
68
+ }
69
+
70
+ /** Scans files and directories on disk; paths that don't exist are skipped. */
71
+ export async function scanFiles(targets: readonly string[]): Promise<SecretFinding[]> {
72
+ const findings: SecretFinding[] = [];
73
+ for (const target of targets) {
74
+ // oxlint-disable-next-line no-await-in-loop -- findings keep the caller's order
75
+ for (const file of await listFiles(target)) {
76
+ // oxlint-disable-next-line no-await-in-loop -- one file at a time bounds memory
77
+ const content = await fs.readFile(file, "utf8");
78
+ // oxlint-disable-next-line no-await-in-loop -- see above
79
+ findings.push(...(await scanContent(file, content)));
80
+ }
81
+ }
82
+ return findings;
83
+ }
84
+
85
+ /** One line per finding, for refusal messages: never the value. Line 0 means the whole file. */
86
+ export function describeSecretFindings(findings: readonly SecretFinding[]): string[] {
87
+ return findings.map(
88
+ (finding) => `${finding.path}${finding.line > 0 ? `:${finding.line}` : ""} (${finding.ruleId})`,
89
+ );
90
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The kind of session a prepare or a delivery plan answers for, shared by every
3
+ * Agent Runtime. `managed-repo`: a managed launch with a Working Repository; its
4
+ * prepare just reconciled the wrap, which the launcher asserts.
5
+ * `managed-companion`: a companion-scoped managed launch. `unmanaged`: a
6
+ * session with no launch environment.
7
+ */
8
+ export type SessionKind = "managed-repo" | "managed-companion" | "unmanaged";
9
+
10
+ /** A managed launch's kind: with a Working Repository, or companion-scoped. */
11
+ export function managedSessionKind(withRepository: boolean): SessionKind {
12
+ return withRepository ? "managed-repo" : "managed-companion";
13
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Where a heredoc starts, matched at a `<<` outside quotes: an optional `-`,
3
+ * then the terminator word, optionally quoted. Shared by the secret guard's
4
+ * shell tokenizer and the artifact guard's redirect scan, so both agree on
5
+ * what is command text and what is a body.
6
+ */
7
+ export const HEREDOC_MARKER = /^<<-?\s*(['"]?)([\w.-]+)\1/;
8
+
9
+ /** The terminators a command line opens, in order; quoted text is skipped. */
10
+ function heredocTerminators(line: string): string[] {
11
+ const terminators: string[] = [];
12
+ let quote: string | undefined;
13
+ for (let i = 0; i < line.length; i++) {
14
+ const char = line[i];
15
+ if (quote) {
16
+ if (char === quote) quote = undefined;
17
+ else if (char === "\\" && quote === '"') i++;
18
+ } else if (char === "'" || char === '"') {
19
+ quote = char;
20
+ } else if (char === "\\") {
21
+ i++;
22
+ } else if (char === "<" && line[i + 1] === "<") {
23
+ if (line[i + 2] === "<") {
24
+ i += 2;
25
+ continue;
26
+ }
27
+ const marker = HEREDOC_MARKER.exec(line.slice(i));
28
+ if (marker) {
29
+ terminators.push(marker[2]);
30
+ i += marker[0].length - 1;
31
+ } else {
32
+ i++;
33
+ }
34
+ }
35
+ }
36
+ return terminators;
37
+ }
38
+
39
+ /** A heredoc body and the line, in the stripped command, whose marker opened it. */
40
+ export interface HeredocBody {
41
+ line: number;
42
+ text: string;
43
+ }
44
+
45
+ /** The command's own lines and each heredoc body, split in one pass. */
46
+ function splitHeredocs(command: string): { kept: string[]; bodies: HeredocBody[] } {
47
+ const kept: string[] = [];
48
+ const bodies: HeredocBody[] = [];
49
+ const pending: string[] = [];
50
+ let body: string[] = [];
51
+ for (const line of command.split("\n")) {
52
+ if (pending.length > 0) {
53
+ if (line.trim() === pending[0]) {
54
+ pending.shift();
55
+ bodies.push({ line: kept.length - 1, text: body.join("\n") });
56
+ body = [];
57
+ } else {
58
+ body.push(line);
59
+ }
60
+ continue;
61
+ }
62
+ kept.push(line);
63
+ pending.push(...heredocTerminators(line));
64
+ }
65
+ if (pending.length > 0) bodies.push({ line: kept.length - 1, text: body.join("\n") });
66
+ return { kept, bodies };
67
+ }
68
+
69
+ /**
70
+ * The command with every heredoc body and terminator line removed. Marker
71
+ * lines stay: a redirect the command performs is spelled there, never in the
72
+ * body it writes. A heuristic, not a parser, like the tokenizer it mirrors.
73
+ */
74
+ export function stripHeredocBodies(command: string): string {
75
+ return splitHeredocs(command).kept.join("\n");
76
+ }
77
+
78
+ /** Every heredoc body in the command, in order; an unterminated one runs to the end. */
79
+ export function heredocBodies(command: string): HeredocBody[] {
80
+ return splitHeredocs(command).bodies;
81
+ }
@@ -9,6 +9,7 @@ import {
9
9
  copies,
10
10
  documents,
11
11
  links,
12
+ recordedMapEntries,
12
13
  manifestPath,
13
14
  parseManifest,
14
15
  parseManifestStrict,
@@ -21,13 +22,16 @@ import {
21
22
 
22
23
  /**
23
24
  * Wrap State: the one read model over the runtime document manifest for "which
24
- * runtimes are wrapped" and "which Claude hook groups does the wrap carry".
25
+ * runtimes are wrapped" and "which Claude hook groups and plugin folders does
26
+ * the wrap carry".
25
27
  * Mate's own plugin groups are not folded in; `isMateGuardHookGroup` owns those.
26
28
  */
27
29
  export interface WrapState {
28
30
  readonly runtimes: ReadonlySet<WrapRuntime>;
29
31
  readonly carriedClaudeHookGroups: readonly ClaudeHookGroup[];
30
32
  carriesClaudeHookGroup(group: ClaudeHookGroup): boolean;
33
+ /** Capability plugin folders the wrap enables through a `directory` marketplace, as recorded. */
34
+ readonly carriedClaudePluginDirectories: readonly string[];
31
35
  /** MCP server names the wrap declared in `.opencode/opencode.json`; user servers are not listed. */
32
36
  readonly openCodeMcpServers: readonly string[];
33
37
  }
@@ -43,10 +47,39 @@ function wrappedFor(manifest: Manifest, runtime: WrapRuntime): boolean {
43
47
  );
44
48
  }
45
49
 
46
- function isHookGroup(value: unknown): value is ClaudeHookGroup {
50
+ /**
51
+ * The folders of the recorded `directory` marketplaces whose plugin the wrap
52
+ * also enables: one without the other loads nothing.
53
+ */
54
+ function carriedPluginDirectories(manifest: Manifest): string[] {
55
+ const marketplaces = recordedMapEntries(manifest, CLAUDE_SETTINGS_DOCUMENT, [
56
+ "extraKnownMarketplaces",
57
+ ]);
58
+ const enabled = recordedMapEntries(manifest, CLAUDE_SETTINGS_DOCUMENT, ["enabledPlugins"]);
59
+ const enabledMarketplaces = new Set(
60
+ Object.entries(enabled)
61
+ .filter(([, value]) => value === true)
62
+ .map(([id]) => id.slice(id.lastIndexOf("@") + 1)),
63
+ );
64
+ return Object.entries(marketplaces).flatMap(([name, entry]) => {
65
+ const source = isRecord(entry) ? entry.source : undefined;
66
+ return enabledMarketplaces.has(name) &&
67
+ isRecord(source) &&
68
+ source.source === "directory" &&
69
+ typeof source.path === "string"
70
+ ? [source.path]
71
+ : [];
72
+ });
73
+ }
74
+
75
+ function isRecord(value: unknown): value is Record<string, unknown> {
47
76
  return typeof value === "object" && value !== null && !Array.isArray(value);
48
77
  }
49
78
 
79
+ function isHookGroup(value: unknown): value is ClaudeHookGroup {
80
+ return isRecord(value);
81
+ }
82
+
50
83
  export function wrapStateOf(manifest: Manifest): WrapState {
51
84
  const carried = recordedListValues(manifest, CLAUDE_SETTINGS_DOCUMENT, ["hooks"]).filter(
52
85
  isHookGroup,
@@ -56,6 +89,7 @@ export function wrapStateOf(manifest: Manifest): WrapState {
56
89
  runtimes: new Set(WRAP_RUNTIMES.filter((runtime) => wrappedFor(manifest, runtime))),
57
90
  carriedClaudeHookGroups: carried,
58
91
  carriesClaudeHookGroup: (group) => carriedKeys.has(hookGroupKey(group)),
92
+ carriedClaudePluginDirectories: carriedPluginDirectories(manifest),
59
93
  openCodeMcpServers: recordedMapKeys(
60
94
  manifest,
61
95
  OPENCODE_CONFIG_DOCUMENT,
@@ -1,5 +1,7 @@
1
+ // oxlint-disable no-await-in-loop
1
2
  import crypto from "node:crypto";
2
3
  import fs from "node:fs/promises";
4
+ import path from "node:path";
3
5
 
4
6
  /**
5
7
  * Writes via a sibling temp file + `fs.rename()` so an interrupted write cannot
@@ -11,17 +13,79 @@ export async function writeAtomic(
11
13
  target: string,
12
14
  contents: string | Buffer,
13
15
  mode?: number,
16
+ ): Promise<void> {
17
+ await replaceViaTemp(target, contents, mode, false);
18
+ }
19
+
20
+ /** `exactMode`: chmod past the umask, so a replaced file keeps the mode it had. */
21
+ async function replaceViaTemp(
22
+ target: string,
23
+ contents: string | Buffer,
24
+ mode: number | undefined,
25
+ exactMode: boolean,
14
26
  ): Promise<void> {
15
27
  const tempPath = `${target}.${crypto.randomUUID()}.tmp`;
16
28
  try {
29
+ /** Created with the mode, so private contents are never readable at a wider one. */
17
30
  await fs.writeFile(
18
31
  tempPath,
19
32
  contents,
20
33
  mode === undefined ? "utf8" : { encoding: "utf8", mode },
21
34
  );
35
+ if (exactMode && mode !== undefined) await fs.chmod(tempPath, mode);
22
36
  await fs.rename(tempPath, target);
23
37
  } catch (error) {
24
38
  await fs.rm(tempPath, { force: true }).catch(() => {});
25
39
  throw error;
26
40
  }
27
41
  }
42
+
43
+ const MAX_LINK_HOPS = 40;
44
+
45
+ /**
46
+ * Follows a symlink chain to its end, which may not exist yet: a dangling link names where to create.
47
+ * Link text is never normalized lexically: `..` after a directory symlink means the real parent.
48
+ */
49
+ async function resolveLinkEnd(filePath: string): Promise<string> {
50
+ let current = filePath;
51
+ for (let hop = 0; hop < MAX_LINK_HOPS; hop++) {
52
+ const parent = await fs.realpath(path.dirname(current));
53
+ const entry = path.join(parent, path.basename(current));
54
+ const link = await fs.readlink(entry).catch((error: NodeJS.ErrnoException) => {
55
+ if (error.code === "ENOENT" || error.code === "EINVAL") return null;
56
+ throw error;
57
+ });
58
+ if (link === null) return entry;
59
+ current = path.isAbsolute(link) ? link : `${parent}${path.sep}${link}`;
60
+ }
61
+ throw Object.assign(new Error(`too many levels of symbolic links: ${filePath}`), {
62
+ code: "ELOOP",
63
+ });
64
+ }
65
+
66
+ /**
67
+ * As `writeAtomic`, through a symlink: the link stays and its target is
68
+ * replaced, or created when the link dangles. An existing file keeps its mode.
69
+ * A read-only file is refused, as a plain write would refuse it. A hardlinked
70
+ * file is written in place: replacing it would split it from its other links.
71
+ */
72
+ export async function writeAtomicThroughLink(
73
+ filePath: string,
74
+ contents: string | Buffer,
75
+ ): Promise<void> {
76
+ const target = await resolveLinkEnd(filePath);
77
+ const existing = await fs.stat(target).catch(() => null);
78
+ if (existing && existing.nlink > 1) return fs.writeFile(target, contents, "utf8");
79
+ if (existing) await fs.access(target, fs.constants.W_OK);
80
+ await replaceViaTemp(target, contents, existing ? existing.mode & 0o7777 : undefined, true);
81
+ }
82
+
83
+ /**
84
+ * Deletes a file its writer left without content. A symlink stays: deleting it
85
+ * would leave the old text in its target, so the target is emptied instead.
86
+ */
87
+ export async function removeEmptiedFile(filePath: string): Promise<void> {
88
+ const entry = await fs.lstat(filePath);
89
+ if (entry.isSymbolicLink()) await writeAtomicThroughLink(filePath, "");
90
+ else await fs.unlink(filePath);
91
+ }
@@ -1,5 +1,5 @@
1
1
  name: mate-minimal
2
- version: 3
2
+ version: 4
3
3
  description: Mate's short OpenSpec workflow for one-Area, low-risk changes - specs → tasks
4
4
  artifacts:
5
5
  - id: specs
@@ -27,11 +27,13 @@ artifacts:
27
27
  include **Reason** and **Migration**.
28
28
 
29
29
  Every user story carries an **Area:** marker naming the Areas it binds, using values from
30
- the frontmatter scopes - always, even when there is only one Area.
30
+ the `scopes` in the spec's `mate:delta` comment - always, even when there is only one Area.
31
31
 
32
- **Obsidian**: Emit YAML frontmatter with `type: delta-spec`, the actual change name, the
33
- capability, the `openspec/change`, `openspec/spec`, and `openspec/delta` tags, and paired
34
- `scopes` metadata.
32
+ Start each spec file with a `<!-- mate:delta` comment on its very first line, not YAML
33
+ frontmatter. The comment holds YAML with the actual change name as `change`, the
34
+ `capability`, and paired `scopes` metadata, and closes with `-->` on its own line. Between
35
+ the comment and the first `## ` section, put nothing but blank lines and at most one `# `
36
+ title line, or `openspec validate --strict` reports the text archive would drop.
35
37
  requires: []
36
38
  - id: tasks
37
39
  generates: tasks.md
@@ -39,7 +41,7 @@ artifacts:
39
41
  template: tasks.md
40
42
  instruction: |
41
43
  Create a flat implementation checklist that satisfies the delta specs. Keep it short: this
42
- profile uses the shared `/openspec-explore` and `/openspec-propose` planning steps but
44
+ profile uses the shared `/mate-explore-change` and `/mate-propose-change` planning steps but
43
45
  intentionally writes no explore, proposal, or design artifact.
44
46
 
45
47
  Number tasks `N.M` and group them under `## N. <group>` headers so apply tracking can address them individually.
@@ -1,12 +1,10 @@
1
- ---
2
- type: delta-spec
1
+ <!-- mate:delta
3
2
  change: <change-name>
4
3
  capability: <capability>
5
- tags: [openspec/change, openspec/spec, openspec/delta]
6
4
  scopes:
7
5
  - repository: org/repository
8
6
  area: .
9
- ---
7
+ -->
10
8
 
11
9
  ## ADDED Requirements
12
10
 
@@ -16,7 +14,7 @@ As a <!-- role -->, I want <!-- capability -->, so that <!-- benefit -->.
16
14
  The system MUST support this behavior.
17
15
  **Area:** `.` <!-- replace with the Area from scopes -->
18
16
 
19
- <!-- Every requirement must bind an Area from the frontmatter scopes. -->
17
+ <!-- Every requirement must bind an Area from the `mate:delta` scopes. -->
20
18
 
21
19
  #### Acceptance Criteria
22
20
 
@@ -1,5 +1,5 @@
1
1
  name: mate-v1
2
- version: 9
2
+ version: 10
3
3
  description: Mate's complete OpenSpec workflow - proposal → specs → design → tasks
4
4
  artifacts:
5
5
  - id: proposal
@@ -74,7 +74,7 @@ artifacts:
74
74
  - **CRITICAL**: Scenarios MUST use exactly 4 hashtags (`####`). Using 3 hashtags or bullets will fail silently.
75
75
  - Every requirement MUST have at least one scenario.
76
76
 
77
- New capabilities only: after the frontmatter and backlink lines, start the delta spec body with a `## Purpose` section - one or two sentences (50+ characters, or `openspec validate --strict` reports it as too brief) describing what the capability is for. Archive copies it into the main spec it creates; without it the new main spec is left with a `TBD ... Update Purpose after archive.` placeholder to fill in by hand. Do NOT add `## Purpose` to a delta for an existing capability - that spec already has one and the delta's is ignored. To change an existing capability's Purpose - including a leftover `TBD` placeholder - edit `openspec/specs/<capability>/spec.md` directly.
77
+ New capabilities only: after the `mate:delta` comment and any `#` title, start the delta spec body with a `## Purpose` section - one or two sentences (50+ characters, or `openspec validate --strict` reports it as too brief) describing what the capability is for. Archive copies it into the main spec it creates; without it the new main spec is left with a `TBD ... Update Purpose after archive.` placeholder to fill in by hand. Do NOT add `## Purpose` to a delta for an existing capability - that spec already has one and the delta's is ignored. To change an existing capability's Purpose - including a leftover `TBD` placeholder - edit `openspec/specs/<capability>/spec.md` directly.
78
78
 
79
79
  MODIFIED requirements workflow:
80
80
  1. Locate the existing requirement in `openspec/specs/<capability>/spec.md`.
@@ -84,7 +84,7 @@ artifacts:
84
84
 
85
85
  Common pitfall: Using MODIFIED with partial content loses detail at archive time. If adding new concerns without changing existing behavior, use ADDED instead.
86
86
 
87
- Example (frontmatter scopes cover `acme` and `packages/api`; every requirement carries an `**Area:**` marker, as always — the first binds both, the second one):
87
+ Example (scope metadata covers `acme` and `packages/api`; every requirement carries an `**Area:**` marker, as always — the first binds both, the second one):
88
88
 
89
89
  ```
90
90
  ## ADDED Requirements
@@ -107,21 +107,19 @@ artifacts:
107
107
 
108
108
  Specs should be testable - each scenario is a potential test case.
109
109
 
110
- **Obsidian**: Emit this YAML frontmatter at the very top of each spec file. Replace `<change-name>`, `<repository>`, `<area>`, and `<capability>` with actual values. Each `scopes` entry MUST preserve its repository/Area pairing:
110
+ **Obsidian**: Start each spec file with this `mate:delta` comment as its very first line, not YAML frontmatter. Replace `<change-name>`, `<repository>`, `<area>`, and `<capability>` with actual values. Each `scopes` entry MUST preserve its repository/Area pairing:
111
111
 
112
112
  ```
113
- ---
114
- type: delta-spec
113
+ <!-- mate:delta
115
114
  change: <change-name>
116
115
  capability: <capability>
117
- tags: [openspec/change, openspec/spec, openspec/delta]
118
116
  scopes:
119
117
  - repository: org/repository
120
118
  area: .
121
- ---
119
+ -->
122
120
  ```
123
121
 
124
- After the file's `#` title line, add a backlink on the next line: `← [[proposal]]`. If modifying an existing main spec, add on the line after: `Applies to: [[openspec/specs/<capability>/spec]]`.
122
+ Between the comment and the first `## ` section, put nothing but blank lines and at most one `# ` title line. OpenSpec skips the comment and the title, but `openspec validate --strict` reports any other text there, because archive drops it, so add no backlink or cross-reference lines.
125
123
 
126
124
  Canonical-spec shape, delta-to-canonical projection, and migration rules live in
127
125
  `openspec/mate-conventions.yaml`. Follow that reference when creating or repairing
@@ -1,12 +1,10 @@
1
- ---
2
- type: delta-spec
1
+ <!-- mate:delta
3
2
  change: <change-name>
4
3
  capability: <capability>
5
- tags: [openspec/change, openspec/spec, openspec/delta]
6
4
  scopes:
7
5
  - repository: org/repository
8
6
  area: .
9
- ---
7
+ -->
10
8
 
11
9
  ## Purpose
12
10
 
@@ -17,7 +15,7 @@ scopes:
17
15
  ### Requirement: <!-- requirement name -->
18
16
 
19
17
  <!-- Write the normative requirement. EVERY requirement carries an **Area:** marker from the
20
- frontmatter scopes, for example **Area:** `packages/ui`. No **Repository:** marker is ever used.
18
+ `mate:delta` scopes, for example **Area:** `packages/ui`. No **Repository:** marker is ever used.
21
19
  See openspec/mate-conventions.yaml for shared scope and canonical-spec rules. -->
22
20
 
23
21
  #### Scenario: <!-- scenario name -->