@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,63 @@
1
+ /** Stdin and stdout of a Claude hook shim. */
2
+
3
+ export type HookEnv = Record<string, string | undefined>;
4
+
5
+ /** One run of a hook shim: its environment, directory, shim arguments and stdin payload. */
6
+ export interface HookCall {
7
+ env: HookEnv;
8
+ cwd: string;
9
+ argv: readonly string[];
10
+ input: unknown;
11
+ }
12
+
13
+ export interface HookOutcome {
14
+ exitCode: number;
15
+ stdout: string;
16
+ }
17
+
18
+ /** A hook's JSON output line, or nothing. Always exit zero: a hook must never block the session. */
19
+ export function hookOutcome(output: object | null): HookOutcome {
20
+ return { exitCode: 0, stdout: output ? `${JSON.stringify(output)}\n` : "" };
21
+ }
22
+
23
+ async function readStdin(): Promise<string> {
24
+ if (process.stdin.isTTY) return "";
25
+ const chunks: Buffer[] = [];
26
+ for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
27
+ return Buffer.concat(chunks).toString("utf8");
28
+ }
29
+
30
+ /** The hook payload on stdin; input that is not JSON reads as `null`, no event. */
31
+ async function readHookInput(): Promise<unknown> {
32
+ const raw = await readStdin();
33
+ try {
34
+ return JSON.parse(raw) as unknown;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * A hook shim's whole run: read the payload, build the outcome, write it.
42
+ * `onError` turns a failure in either step into an outcome, so the shim still exits zero.
43
+ */
44
+ export async function runHook(
45
+ build: (input: unknown) => HookOutcome | Promise<HookOutcome>,
46
+ onError: (error: unknown) => HookOutcome,
47
+ ): Promise<number> {
48
+ let outcome: HookOutcome;
49
+ try {
50
+ outcome = await build(await readHookInput());
51
+ } catch (error) {
52
+ outcome = onError(error);
53
+ }
54
+ writeHookStdout(outcome.stdout);
55
+ return outcome.exitCode;
56
+ }
57
+
58
+ /** Swallows stdout errors: a parent that stopped reading would otherwise end the write in an uncaught `EPIPE`. */
59
+ function writeHookStdout(stdout: string): void {
60
+ if (!stdout) return;
61
+ process.stdout.on("error", () => {});
62
+ process.stdout.write(stdout);
63
+ }
@@ -1,5 +1,8 @@
1
- // Show the active Mate companion paths in Claude Code, and repair the
2
- // companion's Git state before the session reads it.
1
+ /**
2
+ * Show the active Mate companion paths in Claude Code, and repair the
3
+ * companion's Git state before the session reads it.
4
+ */
5
+ import { errorMessage } from "../runtime/error-message";
3
6
  import { unmanagedClaudeDelivery } from "../runtime/claude-delivery-plan";
4
7
  import {
5
8
  syncCompanionUnattended,
@@ -7,24 +10,11 @@ import {
7
10
  type UnattendedSyncOptions,
8
11
  } from "../runtime/companion-sync";
9
12
  import { hasLaunchEnvironment, resolveCompanionRuntime } from "../runtime/env";
13
+ import { FRAMEWORK_NAME } from "../runtime/framework";
10
14
  import { projectionFreshness, projectionStalenessLines } from "../runtime/freshness";
11
15
  import { mateVersion } from "../runtime/install";
12
- import type { HookEnv } from "./validate-artifact-path";
13
-
14
- export interface BannerOutcome {
15
- exitCode: number;
16
- stdout: string;
17
- }
18
-
19
- /**
20
- * Marks the copy a wrap inlines into the Working Repository's settings. A
21
- * managed launch loads the plugin's own copy *and* that document, and both
22
- * registrations resolve to the same command against the same environment, so
23
- * nothing inside the process tells them apart. The flag is what keeps the
24
- * banner from printing twice in exactly the session that is configured
25
- * correctly — the projected copy defers, the launch's own copy prints.
26
- */
27
- export const PROJECTED_BANNER_FLAG = "--projected";
16
+ import { PROJECTED_FLAG } from "../runtime/projected-flag";
17
+ import { hookOutcome, runHook, type HookEnv, type HookOutcome } from "./hook-io";
28
18
 
29
19
  /**
30
20
  * Resolves in-process from either source — no subprocess in either branch.
@@ -36,13 +26,11 @@ export async function buildBanner(
36
26
  cwd: string = process.cwd(),
37
27
  argv: readonly string[] = [],
38
28
  syncOptions: UnattendedSyncOptions = {},
39
- ): Promise<BannerOutcome> {
40
- if (argv.includes(PROJECTED_BANNER_FLAG) && hasLaunchEnvironment(env)) {
41
- return { exitCode: 0, stdout: "" };
42
- }
29
+ ): Promise<HookOutcome> {
30
+ if (argv.includes(PROJECTED_FLAG) && hasLaunchEnvironment(env)) return hookOutcome(null);
43
31
 
44
32
  const { context, projection } = resolveCompanionRuntime(env, cwd);
45
- if (!context.repositoryPath || !context.companionPath) return { exitCode: 0, stdout: "" };
33
+ if (!context.repositoryPath || !context.companionPath) return hookOutcome(null);
46
34
 
47
35
  /**
48
36
  * Repaired before the session reads the companion, so its first reads are
@@ -78,12 +66,16 @@ export async function buildBanner(
78
66
  /** `systemMessage` only: the model must never be told to run the command. */
79
67
  for (const note of gitNotes) lines.push(` ${note}`);
80
68
 
81
- return { exitCode: 0, stdout: JSON.stringify({ systemMessage: lines.join("\n") }) + "\n" };
69
+ return hookOutcome({ systemMessage: lines.join("\n") });
82
70
  }
83
71
 
84
- // Plugin-shim entry.
85
- export async function run(argv: readonly string[] = process.argv.slice(2)): Promise<number> {
86
- const outcome = await buildBanner(process.env, process.cwd(), argv);
87
- if (outcome.stdout) process.stdout.write(outcome.stdout);
88
- return outcome.exitCode;
72
+ /** Plugin-shim entry; exits zero even when the banner fails. */
73
+ export function run(argv: readonly string[] = process.argv.slice(2)): Promise<number> {
74
+ return runHook(
75
+ () => buildBanner(process.env, process.cwd(), argv),
76
+ (error) =>
77
+ hookOutcome({
78
+ systemMessage: `${FRAMEWORK_NAME}: session banner failed: ${errorMessage(error)}`,
79
+ }),
80
+ );
89
81
  }
@@ -1,11 +1,6 @@
1
1
  // Deliver the companion guidance to a Claude session Mate did not launch.
2
2
  import { buildProjectedGuidance } from "../runtime/projected-guidance";
3
- import type { HookEnv } from "./validate-artifact-path";
4
-
5
- export interface GuidanceOutcome {
6
- exitCode: number;
7
- stdout: string;
8
- }
3
+ import { hookOutcome, type HookEnv, type HookOutcome } from "./hook-io";
9
4
 
10
5
  /**
11
6
  * `SessionStart.additionalContext` is the file-based form of the managed
@@ -17,19 +12,16 @@ export interface GuidanceOutcome {
17
12
  * Emits nothing rather than a note when no companion resolves: an unwrapped
18
13
  * repository must read exactly as it did before the hook existed.
19
14
  */
20
- export function buildSessionGuidance(env: HookEnv, cwd: string = process.cwd()): GuidanceOutcome {
15
+ export function buildSessionGuidance(env: HookEnv, cwd: string = process.cwd()): HookOutcome {
21
16
  const guidance = buildProjectedGuidance(env, cwd);
22
- if (!guidance) return { exitCode: 0, stdout: "" };
17
+ if (!guidance) return hookOutcome(null);
23
18
 
24
- return {
25
- exitCode: 0,
26
- stdout: `${JSON.stringify({
27
- hookSpecificOutput: {
28
- hookEventName: "SessionStart",
29
- additionalContext: guidance,
30
- },
31
- })}\n`,
32
- };
19
+ return hookOutcome({
20
+ hookSpecificOutput: {
21
+ hookEventName: "SessionStart",
22
+ additionalContext: guidance,
23
+ },
24
+ });
33
25
  }
34
26
 
35
27
  // Plugin-shim entry.
@@ -1,8 +1,11 @@
1
1
  /** Prepares a plain Claude session in a Claude-wrapped repository as `mate claude` would. */
2
2
  import fs from "node:fs";
3
3
 
4
+ import { errorMessage } from "../runtime/error-message";
4
5
  import { unmanagedClaudeDelivery } from "../runtime/claude-delivery-plan";
5
6
  import { hasLaunchEnvironment } from "../runtime/env";
7
+ import { FRAMEWORK_NAME } from "../runtime/framework";
8
+ import { PROJECTED_FLAG } from "../runtime/projected-flag";
6
9
  import { resolveProjection } from "../runtime/projection";
7
10
  import { runtimeDirectoryPath, runtimeMateBin } from "../runtime/runtime-directory";
8
11
  import {
@@ -12,12 +15,8 @@ import {
12
15
  type PrepareRun,
13
16
  type PrepareRunner,
14
17
  } from "../runtime/session-prepare-runner";
15
- import type { HookEnv } from "./validate-artifact-path";
16
-
17
- export interface SessionPrepareHookOutcome {
18
- exitCode: number;
19
- stdout: string;
20
- }
18
+ import { instructionHookPayload, type InstructionHookDeps } from "./companion-instructions";
19
+ import { hookOutcome, runHook, type HookCall, type HookEnv, type HookOutcome } from "./hook-io";
21
20
 
22
21
  export interface SessionPrepareHookDeps {
23
22
  /** The runtime-directory launcher; `null` until a `mate` run wrote it. */
@@ -25,6 +24,8 @@ export interface SessionPrepareHookDeps {
25
24
  runner: (mateBin: string, repoPath: string) => PrepareRunner;
26
25
  startSync: (mateBin: string, repoPath: string) => void;
27
26
  timeoutMs: number;
27
+ /** The main thread's instruction read, after the prepare. */
28
+ instructions?: InstructionHookDeps;
28
29
  }
29
30
 
30
31
  export const sessionPrepareHookDeps: SessionPrepareHookDeps = {
@@ -37,12 +38,9 @@ export const sessionPrepareHookDeps: SessionPrepareHookDeps = {
37
38
  timeoutMs: PREPARE_TIMEOUT_MS,
38
39
  };
39
40
 
40
- const INERT: SessionPrepareHookOutcome = { exitCode: 0, stdout: "" };
41
-
42
41
  /** `systemMessage` only: the model must never be told to repair the companion. */
43
- function operatorMessage(lines: string[]): SessionPrepareHookOutcome {
44
- if (lines.length === 0) return INERT;
45
- return { exitCode: 0, stdout: `${JSON.stringify({ systemMessage: lines.join("\n") })}\n` };
42
+ function operatorMessage(lines: string[]): HookOutcome {
43
+ return hookOutcome(lines.length > 0 ? { systemMessage: lines.join("\n") } : null);
46
44
  }
47
45
 
48
46
  async function runLocalPhase(
@@ -54,10 +52,18 @@ async function runLocalPhase(
54
52
  try {
55
53
  return await pending;
56
54
  } catch (error) {
57
- return { ok: false, notes: [error instanceof Error ? error.message : String(error)] };
55
+ return { ok: false, notes: [errorMessage(error)] };
58
56
  }
59
57
  }
60
58
 
59
+ /** The local phase's notes, and the Git sync it leaves for the caller to start. */
60
+ interface Prepared {
61
+ notes: string[];
62
+ startSync: () => void;
63
+ }
64
+
65
+ const NO_SYNC = (): void => {};
66
+
61
67
  /**
62
68
  * Inert under a launch environment or where the plan gives it nothing to own.
63
69
  * Awaits the local phase within a bound, then starts Git sync detached. Always
@@ -67,35 +73,116 @@ export async function runSessionPrepareHook(
67
73
  env: HookEnv,
68
74
  cwd: string = process.cwd(),
69
75
  deps: SessionPrepareHookDeps = sessionPrepareHookDeps,
70
- ): Promise<SessionPrepareHookOutcome> {
71
- if (hasLaunchEnvironment(env)) return INERT;
72
- const projectDir = env.CLAUDE_PROJECT_DIR || cwd;
76
+ ): Promise<HookOutcome> {
77
+ const prepared = await guardedPrepare(env, cwd, deps);
78
+ return operatorMessage([...prepared.notes, ...startSync(prepared)]);
79
+ }
80
+
81
+ /** The folder the prepare runs in, and the main thread's instructions are read from. */
82
+ function prepareDirectory(env: HookEnv, cwd: string): string {
83
+ return env.CLAUDE_PROJECT_DIR || cwd;
84
+ }
85
+
86
+ function prepareFailure(error: unknown): string {
87
+ return `${FRAMEWORK_NAME}: session prepare failed: ${errorMessage(error)}`;
88
+ }
89
+
90
+ /** A prepare that throws becomes a note, so the instructions after it still go out. */
91
+ async function guardedPrepare(
92
+ env: HookEnv,
93
+ cwd: string,
94
+ deps: SessionPrepareHookDeps,
95
+ ): Promise<Prepared> {
96
+ try {
97
+ return await prepare(env, cwd, deps);
98
+ } catch (error) {
99
+ return { notes: [prepareFailure(error)], startSync: NO_SYNC };
100
+ }
101
+ }
102
+
103
+ /** A sync that fails to start becomes a note. */
104
+ function startSync(prepared: Prepared): string[] {
105
+ try {
106
+ prepared.startSync();
107
+ return [];
108
+ } catch (error) {
109
+ return [prepareFailure(error)];
110
+ }
111
+ }
112
+
113
+ async function prepare(env: HookEnv, cwd: string, deps: SessionPrepareHookDeps): Promise<Prepared> {
114
+ const nothing = { notes: [], startSync: NO_SYNC };
115
+ if (hasLaunchEnvironment(env)) return nothing;
116
+ const projectDir = prepareDirectory(env, cwd);
73
117
  const projection = resolveProjection(projectDir);
74
- if (!projection) return INERT;
118
+ if (!projection) return nothing;
75
119
  const plan = unmanagedClaudeDelivery(projection.repoRoot);
76
120
  const reconciles = plan.reconcile === "prepare-hook";
77
121
  const syncs = plan.gitSync === "prepare-hook";
78
- if (!reconciles && !syncs) return INERT;
122
+ if (!reconciles && !syncs) return nothing;
79
123
 
80
124
  const mateBin = deps.mateBin();
81
125
  if (!mateBin) {
82
- return operatorMessage([
83
- `mate: session prepare skipped: ${runtimeDirectoryPath()} is missing; run \`mate\` once to repair it.`,
84
- ]);
126
+ return {
127
+ notes: [
128
+ `${FRAMEWORK_NAME}: session prepare skipped: ${runtimeDirectoryPath()} is missing; run \`${FRAMEWORK_NAME}\` once to repair it.`,
129
+ ],
130
+ startSync: NO_SYNC,
131
+ };
85
132
  }
86
133
 
87
134
  const local = reconciles
88
135
  ? await runLocalPhase(deps, mateBin, projectDir)
89
136
  : { ok: true, notes: [] };
90
- if (syncs) deps.startSync(mateBin, projectDir);
91
-
92
137
  const verdict = local.ok ? "warning" : "failed";
93
- return operatorMessage(local.notes.map((note) => `mate: session prepare ${verdict}: ${note}`));
138
+ return {
139
+ notes: local.notes.map((note) => `${FRAMEWORK_NAME}: session prepare ${verdict}: ${note}`),
140
+ startSync: syncs ? () => deps.startSync(mateBin, projectDir) : NO_SYNC,
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Legacy rule: wraps before `--projected` registered this copy bare, and only
146
+ * this hook re-wraps them. So a bare copy counts as the wrap's unless
147
+ * `CLAUDE_PLUGIN_ROOT`, set for plugin hooks only, marks it as the plugin's.
148
+ */
149
+ function isWrapCopy({ argv, env }: HookCall): boolean {
150
+ return argv.includes(PROJECTED_FLAG) || !env.CLAUDE_PLUGIN_ROOT;
151
+ }
152
+
153
+ /**
154
+ * The prepare, the main thread's Companion Instructions, then the Git sync, in
155
+ * one hook: Claude Code runs an event's hooks in parallel, and the prepare and
156
+ * a fast-forward both rewrite the files the instructions are read from. Only
157
+ * the wrap's copy runs any. Input that is not a JSON object is no event.
158
+ */
159
+ export async function runSessionStartHook(
160
+ call: HookCall,
161
+ deps: SessionPrepareHookDeps = sessionPrepareHookDeps,
162
+ ): Promise<HookOutcome> {
163
+ const { env, cwd, input } = call;
164
+ if (!input || typeof input !== "object" || Array.isArray(input)) return hookOutcome(null);
165
+ if (!isWrapCopy(call)) return hookOutcome(null);
166
+ const prepared = await guardedPrepare(env, cwd, deps);
167
+ const { hookSpecificOutput, systemMessage } =
168
+ instructionHookPayload(
169
+ { env, cwd, input, projected: true, directory: prepareDirectory(env, cwd) },
170
+ deps.instructions,
171
+ ) ?? {};
172
+ const notes = [...prepared.notes, ...startSync(prepared)];
173
+ const messages = systemMessage ? [...notes, systemMessage] : notes;
174
+ if (!hookSpecificOutput) return operatorMessage(messages);
175
+ return hookOutcome({
176
+ hookSpecificOutput,
177
+ ...(messages.length > 0 ? { systemMessage: messages.join("\n") } : {}),
178
+ });
94
179
  }
95
180
 
96
- /** Plugin-shim entry. */
97
- export async function run(): Promise<number> {
98
- const outcome = await runSessionPrepareHook(process.env);
99
- if (outcome.stdout) process.stdout.write(outcome.stdout);
100
- return outcome.exitCode;
181
+ /** Plugin-shim entry; exits zero even when reading its input fails. */
182
+ export function run(argv: readonly string[] = process.argv.slice(2)): Promise<number> {
183
+ return runHook(
184
+ (input) => runSessionStartHook({ env: process.env, cwd: process.cwd(), argv, input }),
185
+ (error) =>
186
+ operatorMessage([`${FRAMEWORK_NAME}: session start hook failed: ${errorMessage(error)}`]),
187
+ );
101
188
  }
@@ -12,12 +12,15 @@ import fs from "node:fs";
12
12
  import os from "node:os";
13
13
  import path from "node:path";
14
14
 
15
+ import { artifactRefusal, resolveArtifactTarget } from "../runtime/artifact-refusal";
16
+ import { checkCommitAttribution } from "../runtime/commit-attribution-guard";
15
17
  import { gitEnvironment } from "../runtime/companion-git";
16
18
  import { companionForkRefusal } from "../runtime/companion-sync";
17
19
  import { readCompanionRuntimeContext } from "../runtime/env";
18
20
  import { companionLinkPath } from "../runtime/repo-local";
19
-
20
- export type HookEnv = Record<string, string | undefined>;
21
+ import { checkSecretAccess, type SecretToolCall } from "../runtime/secret-guard";
22
+ import { stripHeredocBodies } from "../runtime/shell-heredoc";
23
+ import type { HookEnv } from "./hook-io";
21
24
 
22
25
  export interface HookOutcome {
23
26
  exitCode: number;
@@ -44,8 +47,9 @@ const ARTIFACT_PATH_MARKERS = [
44
47
  "/docs/prd/",
45
48
  ];
46
49
 
47
- const MD_REDIRECT_PATTERN = />{1,2}\s*([^\s;|&<>]+\.md)\b/g;
48
- const MD_TEE_PATTERN = /\btee\s+(?:-a\s+)?([^\s;|&<>]+\.md)\b/g;
50
+ /** `[ \t]`, not `\s`: a redirect or `tee` names its target on its own line. */
51
+ const MD_REDIRECT_PATTERN = />{1,2}[ \t]*([^\s;|&<>]+\.md)\b/g;
52
+ const MD_TEE_PATTERN = /\btee[ \t]+(?:-a[ \t]+)?([^\s;|&<>]+\.md)\b/g;
49
53
 
50
54
  export function artifactLikePath(filePath: string): boolean {
51
55
  if (!filePath) return false;
@@ -118,11 +122,6 @@ function claudeConfigRoot(env: HookEnv): string {
118
122
  return path.normalize(path.join(env.HOME || os.homedir(), ".claude"));
119
123
  }
120
124
 
121
- function normalizePath(filePath: string, ctx: GuardContext): string {
122
- if (path.isAbsolute(filePath)) return path.normalize(filePath);
123
- return path.normalize(path.join(ctx.repoRoot, filePath));
124
- }
125
-
126
125
  function isUnder(target: string, root: string): boolean {
127
126
  const relative = path.relative(path.normalize(root), path.normalize(target));
128
127
  return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
@@ -181,17 +180,14 @@ function isProductDocumentationPath(target: string, ctx: GuardContext): boolean
181
180
  return false;
182
181
  }
183
182
 
184
- function blockWrite(target: string, companion: string): HookOutcome {
185
- const lines = [
186
- "Mate guardrail: artifact writes must go to the companion framework path.",
187
- ` target: ${target}`,
188
- ` companion: ${companion}`,
189
- "Use an absolute path under MATE_ARTIFACT_PATH instead.",
190
- ];
191
- if (path.basename(target) === "CLAUDE.md") {
192
- lines.push(`Note: CLAUDE.md already lives in the companion repo at ${companion}/CLAUDE.md.`);
193
- }
194
- return { exitCode: 2, stderr: lines.join("\n") + "\n" };
183
+ function blockWrite(target: string, resolved: string, ctx: GuardContext): HookOutcome {
184
+ const refusal = artifactRefusal({
185
+ target,
186
+ resolved,
187
+ companion: ctx.companion,
188
+ repoRoot: ctx.repoRoot,
189
+ });
190
+ return { exitCode: 2, stderr: `${refusal}\n` };
195
191
  }
196
192
 
197
193
  function checkFilePath(
@@ -201,7 +197,7 @@ function checkFilePath(
201
197
  ): HookOutcome | null {
202
198
  if (!filePath) return null;
203
199
 
204
- const normalized = normalizePath(filePath, ctx);
200
+ const normalized = resolveArtifactTarget(filePath, ctx.repoRoot);
205
201
  if (isCompanionPath(normalized, ctx)) return null;
206
202
 
207
203
  if (isUnder(normalized, claudeConfigRoot(ctx.env))) return null;
@@ -219,7 +215,7 @@ function checkFilePath(
219
215
  return null;
220
216
  }
221
217
 
222
- return blockWrite(filePath, ctx.companion);
218
+ return blockWrite(filePath, normalized, ctx);
223
219
  }
224
220
 
225
221
  /**
@@ -234,17 +230,19 @@ async function checkForkedCompanion(
234
230
  ctx: GuardContext,
235
231
  ): Promise<HookOutcome | null> {
236
232
  if (!filePath || !artifactLikePath(filePath)) return null;
237
- if (!isCompanionPath(normalizePath(filePath, ctx), ctx)) return null;
233
+ if (!isCompanionPath(resolveArtifactTarget(filePath, ctx.repoRoot), ctx)) return null;
238
234
 
239
235
  const refusal = await companionForkRefusal(ctx.env, ctx.companion);
240
236
  return refusal ? { exitCode: 2, stderr: `${refusal}\n` } : null;
241
237
  }
242
238
 
239
+ /** Heredoc bodies are what a command writes, not what it runs: they are not scanned. */
243
240
  export function commandMatches(command: string): string[] {
244
241
  const targets: string[] = [];
242
+ const commandText = stripHeredocBodies(command);
245
243
  for (const pattern of [MD_REDIRECT_PATTERN, MD_TEE_PATTERN]) {
246
244
  const matcher = new RegExp(pattern.source, pattern.flags);
247
- for (const match of command.matchAll(matcher)) {
245
+ for (const match of commandText.matchAll(matcher)) {
248
246
  const target = match[1].trim().replace(/^["']+|["']+$/g, "");
249
247
  if (target) targets.push(target);
250
248
  }
@@ -252,6 +250,53 @@ export function commandMatches(command: string): string[] {
252
250
  return targets;
253
251
  }
254
252
 
253
+ /** Claude tool payloads in the shape the shared secret guard reads. */
254
+ function secretToolCall(
255
+ toolName: string,
256
+ toolInput: Record<string, unknown>,
257
+ ctx: GuardContext,
258
+ ): SecretToolCall | null {
259
+ const text = (value: unknown) => (typeof value === "string" ? value : "");
260
+ const target = (value: unknown) =>
261
+ text(value) ? resolveArtifactTarget(text(value), ctx.repoRoot) : "";
262
+ switch (toolName) {
263
+ case "Read":
264
+ return { kind: "read", paths: [target(toolInput.file_path)] };
265
+ case "Write":
266
+ return {
267
+ kind: "write",
268
+ writes: [{ path: target(toolInput.file_path), content: text(toolInput.content) }],
269
+ };
270
+ case "Edit":
271
+ return {
272
+ kind: "write",
273
+ writes: [{ path: target(toolInput.file_path), content: text(toolInput.new_string) }],
274
+ };
275
+ case "MultiEdit": {
276
+ const edits = Array.isArray(toolInput.edits) ? toolInput.edits : [];
277
+ const content = edits
278
+ .map((edit) => (edit && typeof edit === "object" ? text(edit.new_string) : ""))
279
+ .join("\n");
280
+ return { kind: "write", writes: [{ path: target(toolInput.file_path), content }] };
281
+ }
282
+ case "NotebookEdit":
283
+ return {
284
+ kind: "write",
285
+ writes: [{ path: target(toolInput.notebook_path), content: text(toolInput.new_source) }],
286
+ };
287
+ case "Bash":
288
+ return { kind: "shell", command: text(toolInput.command) };
289
+ case "Grep":
290
+ return {
291
+ kind: "search",
292
+ paths: [target(toolInput.path)],
293
+ globs: [text(toolInput.glob)],
294
+ };
295
+ default:
296
+ return null;
297
+ }
298
+ }
299
+
255
300
  /**
256
301
  * Fails open only when no Mate context resolves from either source; in a
257
302
  * wrapped Working Repository the guard runs without a launch.
@@ -273,6 +318,16 @@ export async function evaluate(
273
318
  ? (input.tool_input as Record<string, unknown>)
274
319
  : {};
275
320
 
321
+ /** Secrets first: a secret refusal wins over every artifact-placement verdict. */
322
+ const secretCall = secretToolCall(toolName, toolInput, ctx);
323
+ if (secretCall) {
324
+ const refusal = await checkSecretAccess(secretCall, {
325
+ companionPath: ctx.companion,
326
+ isCompanionPath: (target) => isCompanionPath(target, ctx),
327
+ });
328
+ if (refusal) return { exitCode: 2, stderr: `${refusal}\n` };
329
+ }
330
+
276
331
  if (toolName === "Write" || toolName === "Edit" || toolName === "MultiEdit") {
277
332
  const filePath = String(toolInput.file_path ?? "");
278
333
  /** The path refusal wins when both apply: its message is not replaced. */
@@ -282,6 +337,9 @@ export async function evaluate(
282
337
 
283
338
  if (toolName === "Bash") {
284
339
  const command = String(toolInput.command ?? "");
340
+ const attribution = checkCommitAttribution(command);
341
+ if (attribution) return { exitCode: 2, stderr: `${attribution}\n` };
342
+
285
343
  const targets = commandMatches(command);
286
344
  for (const target of targets) {
287
345
  const outcome = checkFilePath(target, ctx);
package/src/index.ts CHANGED
@@ -39,13 +39,17 @@ export type {
39
39
  McpService,
40
40
  Plugin,
41
41
  PluginCliCommand,
42
+ PluginDirectoryContribution,
42
43
  PluginPolicy,
43
44
  PluginRegistration,
44
45
  ProviderHosting,
45
46
  ProviderPlugin,
47
+ RuntimeContributions,
48
+ RuntimeContributionsByRuntime,
46
49
  SetupContext,
47
50
  TemplatesService,
48
51
  } from "./tools/setup/plugin";
52
+ export type { SessionKind } from "./runtime/session-kind";
49
53
  export type {
50
54
  FrameworkType,
51
55
  FrameworkConfig,