@uniqbit/mate-core 0.15.5 → 0.16.0-canary.1

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 (186) hide show
  1. package/claude-plugin/.claude-plugin/plugin.json +2 -2
  2. package/claude-plugin/hooks/hooks.json +3 -6
  3. package/claude-plugin/hooks/session-guidance.mjs +8 -0
  4. package/claude-plugin/hooks/ts-loader.mjs +19 -0
  5. package/package.json +7 -5
  6. package/src/cli/commands/artifact/artifact.ts +19 -3
  7. package/src/cli/commands/artifact/finish/command.ts +183 -57
  8. package/src/cli/commands/artifact/finish/engine.ts +80 -91
  9. package/src/cli/commands/artifact/finish/finisher.ts +26 -33
  10. package/src/cli/commands/artifact/finish/git.ts +34 -23
  11. package/src/cli/commands/artifact/finish/index.ts +9 -3
  12. package/src/cli/commands/artifact/finish/openspec.ts +225 -97
  13. package/src/cli/commands/artifact/pending/command.ts +175 -0
  14. package/src/cli/commands/artifact/pending/discovery.ts +249 -0
  15. package/src/cli/commands/artifact/pending/index.ts +17 -0
  16. package/src/cli/commands/cap/index-cmd.ts +9 -1
  17. package/src/cli/commands/cap/index.ts +2 -6
  18. package/src/cli/commands/cap/tokensave.ts +4 -4
  19. package/src/cli/commands/companion/companion.ts +5 -1
  20. package/src/cli/commands/companion/link.ts +2 -2
  21. package/src/cli/commands/companion/sync.ts +92 -0
  22. package/src/cli/commands/doctor.ts +0 -3
  23. package/src/cli/commands/launch/shared.ts +23 -5
  24. package/src/cli/commands/report/collector.ts +72 -78
  25. package/src/cli/commands/report/contract.ts +40 -1
  26. package/src/cli/commands/report/highlight.ts +27 -0
  27. package/src/cli/commands/report/index.ts +11 -18
  28. package/src/cli/commands/report/renderer.ts +199 -2
  29. package/src/cli/commands/report/types.ts +26 -1
  30. package/src/cli/commands/shared/companion-selection.ts +107 -10
  31. package/src/cli/commands/studio/areas.ts +68 -0
  32. package/src/cli/commands/studio/index.ts +69 -0
  33. package/src/cli/commands/studio/inventory.ts +55 -0
  34. package/src/cli/commands/studio/mate-inventory.ts +43 -0
  35. package/src/cli/commands/studio/openspec-cli.ts +198 -0
  36. package/src/cli/commands/studio/payload.ts +184 -0
  37. package/src/cli/commands/studio/routes.ts +2 -0
  38. package/src/cli/commands/studio/selection.ts +61 -0
  39. package/src/cli/commands/studio/server.ts +201 -0
  40. package/src/cli/commands/studio/snapshot.ts +63 -0
  41. package/src/cli/commands/studio/topology.ts +199 -0
  42. package/src/cli/commands/studio/views/client.ts +197 -0
  43. package/src/cli/commands/studio/views/companion-picker.tsx +91 -0
  44. package/src/cli/commands/studio/views/companion-selector.tsx +90 -0
  45. package/src/cli/commands/studio/views/dashboard/changes.tsx +107 -0
  46. package/src/cli/commands/studio/views/dashboard/index.tsx +19 -0
  47. package/src/cli/commands/studio/views/document.tsx +256 -0
  48. package/src/cli/commands/studio/views/error.tsx +20 -0
  49. package/src/cli/commands/studio/views/model.ts +34 -0
  50. package/src/cli/commands/studio/views/pairings.tsx +38 -0
  51. package/src/cli/commands/studio/views/skills/index.tsx +87 -0
  52. package/src/cli/commands/studio/views/specs/index.tsx +101 -0
  53. package/src/cli/commands/studio/views/styles.ts +389 -0
  54. package/src/cli/commands/studio/views/warnings.tsx +21 -0
  55. package/src/cli/commands/studio/views/workflow/index.tsx +21 -0
  56. package/src/cli/commands/studio/views/workflow/steps.ts +329 -0
  57. package/src/cli/commands/studio/views/workflow/transcript.tsx +190 -0
  58. package/src/cli/commands/unwrap.ts +70 -0
  59. package/src/cli/commands/wrap.ts +164 -0
  60. package/src/cli/main.ts +68 -19
  61. package/src/cli/parse-flags.ts +36 -11
  62. package/src/cli/usage.ts +12 -3
  63. package/src/framework.ts +1 -7
  64. package/src/hooks/session-banner.ts +64 -11
  65. package/src/hooks/session-guidance.ts +40 -0
  66. package/src/hooks/validate-artifact-path.ts +108 -35
  67. package/src/lib/fs-utils.ts +9 -0
  68. package/src/lib/install.ts +33 -0
  69. package/src/lib/orchestrator/adapters/base.ts +14 -125
  70. package/src/lib/orchestrator/adapters/claude.ts +0 -11
  71. package/src/lib/orchestrator/adapters/opencode.ts +2 -32
  72. package/src/lib/orchestrator/companion-git-sync.ts +94 -84
  73. package/src/lib/orchestrator/config-store.ts +2 -21
  74. package/src/lib/orchestrator/editor.ts +12 -22
  75. package/src/lib/orchestrator/framework-context.ts +17 -6
  76. package/src/lib/orchestrator/global-config-store.ts +1 -1
  77. package/src/lib/orchestrator/launcher.ts +97 -7
  78. package/src/lib/orchestrator/opencode-guidance.ts +4 -56
  79. package/src/lib/orchestrator/projection-claude-entry.ts +198 -0
  80. package/src/lib/orchestrator/projection-claude-skills.ts +120 -0
  81. package/src/lib/orchestrator/projection-companion-link.ts +62 -0
  82. package/src/lib/orchestrator/projection-entries.ts +377 -0
  83. package/src/lib/orchestrator/projection-record.ts +56 -0
  84. package/src/lib/orchestrator/projection-runtime-documents.ts +424 -0
  85. package/src/lib/orchestrator/projection-types.ts +169 -0
  86. package/src/lib/orchestrator/repo-local-registry.ts +37 -133
  87. package/src/lib/orchestrator/repo-local-store.ts +96 -0
  88. package/src/lib/orchestrator/setup-compatibilities.ts +1 -9
  89. package/src/lib/orchestrator/types.ts +1 -0
  90. package/src/lib/orchestrator/working-repo-projection.ts +366 -0
  91. package/src/lib/orchestrator/workspace-inventory.ts +1 -1
  92. package/src/lib/package-paths.ts +11 -1
  93. package/src/lib/public-npm.ts +2 -1
  94. package/src/lib/update-checker.ts +15 -9
  95. package/src/opencode/companion-hooks.ts +89 -245
  96. package/src/opencode/companion-policy.ts +35 -10
  97. package/src/opencode/index.ts +1 -0
  98. package/src/opencode/projected-guidance.ts +56 -0
  99. package/src/opencode/tui.tsx +13 -4
  100. package/src/playbooks/companion-guidance.ts +32 -116
  101. package/src/plugins.ts +0 -1
  102. package/src/runtime/companion-git-state.ts +156 -0
  103. package/src/runtime/companion-git.ts +203 -0
  104. package/src/runtime/companion-guidance.ts +222 -0
  105. package/src/runtime/companion-sync.ts +298 -0
  106. package/src/runtime/env-names.ts +30 -0
  107. package/src/runtime/env.ts +67 -35
  108. package/src/runtime/framework.ts +10 -0
  109. package/src/runtime/freshness.ts +58 -0
  110. package/src/runtime/index.ts +104 -0
  111. package/src/runtime/install.ts +30 -0
  112. package/src/runtime/policy.ts +66 -0
  113. package/src/runtime/projected-guidance.ts +45 -0
  114. package/src/runtime/projection.ts +224 -0
  115. package/src/runtime/repo-local.ts +64 -0
  116. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +56 -0
  117. package/src/templates/capabilities/openspec-cap/mate-minimal/templates/spec.md +48 -0
  118. package/src/templates/capabilities/openspec-cap/mate-minimal/templates/tasks.md +22 -0
  119. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +31 -90
  120. package/src/templates/capabilities/openspec-cap/mate-v1/templates/design.md +3 -0
  121. package/src/templates/capabilities/openspec-cap/mate-v1/templates/explore-brief.md +6 -6
  122. package/src/templates/capabilities/openspec-cap/mate-v1/templates/spec.md +3 -5
  123. package/src/templates/capabilities/openspec-cap/mate-v1/templates/tasks.md +3 -0
  124. package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +30 -0
  125. package/src/templates/capabilities/react-doctor/claude/hooks/react-doctor.sh +2 -2
  126. package/src/templates/mate-skills/agents/mate-artifact-publish/SKILL.md +185 -0
  127. package/src/templates/mate-skills/agents/mate-artifact-publish/references/openspec.md +227 -0
  128. package/src/templates/mate-skills/agents/mate-domain-modeling/SKILL.md +68 -0
  129. package/src/templates/mate-skills/agents/mate-domain-modeling/references/ADR-FORMAT.md +9 -0
  130. package/src/templates/mate-skills/agents/mate-domain-modeling/references/CONTEXT-FORMAT.md +18 -0
  131. package/src/templates/mate-skills/agents/mate-grill-me/SKILL.md +14 -0
  132. package/src/templates/mate-skills/agents/mate-grill-with-docs/SKILL.md +29 -0
  133. package/src/templates/mate-skills/agents/mate-grilling/SKILL.md +49 -0
  134. package/src/templates/mate-skills/agents/mate-interview-me/SKILL.md +156 -0
  135. package/src/templates/{capabilities/openspec-cap/mate-skills → mate-skills}/agents/mate-openspec-backfill/SKILL.md +4 -2
  136. package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +139 -0
  137. package/src/templates/mate-skills/agents/mate-simplify-code/SKILL.md +503 -0
  138. package/src/templates/mate-skills/claude/mate-artifact-publish/SKILL.md +185 -0
  139. package/src/templates/mate-skills/claude/mate-artifact-publish/references/openspec.md +227 -0
  140. package/src/templates/mate-skills/claude/mate-domain-modeling/SKILL.md +68 -0
  141. package/src/templates/mate-skills/claude/mate-domain-modeling/references/ADR-FORMAT.md +9 -0
  142. package/src/templates/mate-skills/claude/mate-domain-modeling/references/CONTEXT-FORMAT.md +18 -0
  143. package/src/templates/mate-skills/claude/mate-grill-me/SKILL.md +14 -0
  144. package/src/templates/mate-skills/claude/mate-grill-with-docs/SKILL.md +29 -0
  145. package/src/templates/mate-skills/claude/mate-grilling/SKILL.md +49 -0
  146. package/src/templates/mate-skills/claude/mate-interview-me/SKILL.md +156 -0
  147. package/src/templates/mate-skills/claude/mate-openspec-backfill/SKILL.md +67 -0
  148. package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +139 -0
  149. package/src/templates/mate-skills/claude/mate-simplify-code/SKILL.md +503 -0
  150. package/src/templates/report-assets/README.md +32 -0
  151. package/src/templates/report-assets/mermaid.LICENSE +21 -0
  152. package/src/templates/report-assets/mermaid.min.js +4376 -0
  153. package/src/templates/root/TEMPLATE_CLAUDE.md +1 -9
  154. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +1476 -197
  155. package/src/tools/setup/capabilities/graphify.ts +16 -7
  156. package/src/tools/setup/capabilities/openspec.ts +63 -56
  157. package/src/tools/setup/capabilities/tokensave.ts +116 -2
  158. package/src/tools/setup/engine.ts +34 -6
  159. package/src/tools/setup/mate.ts +42 -13
  160. package/src/tools/setup/plugin.ts +9 -0
  161. package/src/tools/setup/plugins/guidance.ts +11 -1
  162. package/src/tools/setup/providers/claude-format.ts +49 -4
  163. package/src/tools/setup/providers/claude-plugin-hooks.ts +117 -0
  164. package/src/tools/setup/providers/claude.ts +55 -220
  165. package/src/tools/setup/providers/opencode.ts +41 -14
  166. package/src/tools/setup/runtime-documents.ts +174 -0
  167. package/src/tools/setup/surface-target.ts +50 -0
  168. package/src/tools/setup/working-repo-cleanup.ts +33 -26
  169. package/src/tools/setup/working-repo-local-state.ts +21 -1
  170. package/src/tools/setup.ts +25 -3
  171. package/wrappers/bin/graphify +57 -8
  172. package/wrappers/bin/openspec +50 -3
  173. package/claude-plugin/hooks/artifact-finish-nudge.mjs +0 -8
  174. package/src/cli/commands/cap/headroom.ts +0 -52
  175. package/src/cli/commands/workspace/list.ts +0 -25
  176. package/src/cli/commands/workspace/materialize.ts +0 -46
  177. package/src/cli/commands/workspace/workspace.ts +0 -22
  178. package/src/hooks/artifact-finish-nudge.ts +0 -244
  179. package/src/lib/orchestrator/headroom/proxy.ts +0 -116
  180. package/src/lib/orchestrator/workspace-materialize.ts +0 -80
  181. package/src/templates/capabilities/openspec-cap/mate-skills/agents/mate-artifact-finish/SKILL.md +0 -51
  182. package/src/templates/capabilities/openspec-cap/mate-skills/agents/mate-artifact-finish/references/openspec.md +0 -134
  183. package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/SKILL.md +0 -58
  184. package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/references/openspec.md +0 -139
  185. package/src/tools/setup/capabilities/headroom.ts +0 -57
  186. /package/src/cli/commands/{workspace → companion}/open.ts +0 -0
@@ -1,6 +1,53 @@
1
1
  #!/usr/bin/env bash
2
- [ -n "$MATE_ARTIFACT_PATH" ] || { echo "openspec: must be run via mate claude or mate opencode" >&2; exit 1; }
3
2
  _this_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
3
+
4
+ # Mate context: the launch environment first, the Working Repository's
5
+ # Projection Root second. Projected values stay in private shell variables and
6
+ # no MATE_-prefixed variable is exported: the projection carries paths and
7
+ # never predicates, so a partial MATE_* environment would read to any child as
8
+ # authoritative with every predicate disabled.
9
+ _mate_projection_file() {
10
+ local dir="$PWD"
11
+ while :; do
12
+ if [ -f "$dir/.mate/config/registry.yaml" ]; then
13
+ [ -f "$dir/.mate/projection.env" ] && printf '%s' "$dir/.mate/projection.env"
14
+ return
15
+ fi
16
+ [ "$dir" = "/" ] && return
17
+ dir="$(dirname "$dir")"
18
+ done
19
+ }
20
+
21
+ _mate_projected() {
22
+ sed -n "s/^$2='\\(.*\\)'\$/\\1/p" "$1" | head -n 1 | sed "s/'\\\\''/'/g"
23
+ }
24
+
25
+ _mate_same_dir() {
26
+ local left="$1" right="$2"
27
+ [ -d "$left" ] && left="$(cd "$left" && pwd)"
28
+ [ -d "$right" ] && right="$(cd "$right" && pwd)"
29
+ [ "$left" = "$right" ]
30
+ }
31
+
32
+ _companion="$MATE_ARTIFACT_PATH"
33
+ if [ -z "$_companion" ]; then
34
+ _projection="$(_mate_projection_file)"
35
+ if [ -n "$_projection" ]; then
36
+ _companion="$(_mate_projected "$_projection" MATE_ARTIFACT_PATH)"
37
+ _projected_wrapper_dir="$(_mate_projected "$_projection" MATE_WRAPPER_BIN_PATH)"
38
+ if [ -n "$_companion" ] && ! _mate_same_dir "$_projected_wrapper_dir" "$_this_dir"; then
39
+ {
40
+ echo "openspec: this repository was wrapped by a different mate install."
41
+ echo " projected wrappers: $_projected_wrapper_dir"
42
+ echo " running wrapper: $_this_dir"
43
+ echo " re-run \`mate wrap\` with this install."
44
+ } >&2
45
+ exit 1
46
+ fi
47
+ fi
48
+ fi
49
+ [ -n "$_companion" ] || { echo "openspec: no mate context here — run \`mate wrap\` in this repository" >&2; exit 1; }
50
+
4
51
  _real=""
5
52
  IFS=:
6
53
  for _dir in $PATH; do
@@ -22,7 +69,7 @@ if [ "$command" = "templates" ]; then
22
69
  done
23
70
  if [ "$has_schema" -eq 0 ]; then
24
71
  _schema=""
25
- for _cfg in "$MATE_ARTIFACT_PATH/openspec/config.yaml" "$MATE_ARTIFACT_PATH/openspec/config.yml"; do
72
+ for _cfg in "$_companion/openspec/config.yaml" "$_companion/openspec/config.yml"; do
26
73
  [ -f "$_cfg" ] || continue
27
74
  while IFS= read -r _line; do
28
75
  case "$_line" in
@@ -45,4 +92,4 @@ if [ "$command" = "templates" ]; then
45
92
  fi
46
93
  fi
47
94
  fi
48
- exec env -C "$MATE_ARTIFACT_PATH" "$_real" "$@"
95
+ exec env -C "$_companion" "$_real" "$@"
@@ -1,8 +0,0 @@
1
- #!/usr/bin/env node
2
- // Thin shim: hook logic lives in src/hooks/artifact-finish-nudge.ts, loaded
3
- // through ts-loader.mjs because the installed plugin sits inside node_modules
4
- // where node's native type stripping is disabled.
5
- import "./ts-loader.mjs";
6
-
7
- const { run } = await import("../../src/hooks/artifact-finish-nudge.ts");
8
- process.exitCode = await run();
@@ -1,52 +0,0 @@
1
- import { spawnSync } from "node:child_process";
2
-
3
- import { resolveForCapability } from "../../../lib/orchestrator/framework-context";
4
- import type { CapabilityConfig } from "../../../lib/orchestrator/types";
5
- import { ensureUnambiguousCompanion } from "../shared/companion-selection";
6
-
7
- export interface HeadroomCapDeps {
8
- ensureUnambiguousCompanion?: (cwd: string) => Promise<boolean>;
9
- }
10
-
11
- /**
12
- * @command mate cap headroom [...args]
13
- * @description Forwards `[...args]` to the `headroom` CLI, run from the
14
- * companion directory. Requires the `headroom` capability to be enabled in
15
- * `.mate/config/framework.yaml`.
16
- * @remarks The child process's exit code is propagated via `process.exitCode`.
17
- */
18
- export async function runHeadroomCapCommand(
19
- args: string[],
20
- deps: HeadroomCapDeps = {},
21
- ): Promise<void> {
22
- const ensureCompanion = deps.ensureUnambiguousCompanion ?? ensureUnambiguousCompanion;
23
- if (!(await ensureCompanion(process.cwd()))) {
24
- process.exitCode = 1;
25
- return;
26
- }
27
-
28
- const { companionPath, configStore } = await resolveForCapability(process.cwd());
29
- const config = await configStore.load();
30
-
31
- const capability = (config.capabilities ?? []).find(
32
- (entry: CapabilityConfig) => entry.name === "headroom",
33
- );
34
-
35
- if (!capability) {
36
- process.stderr.write("headroom capability is not enabled in .mate/config/framework.yaml\n");
37
- process.exitCode = 1;
38
- return;
39
- }
40
-
41
- const result = spawnSync("headroom", args, { cwd: companionPath, stdio: "inherit" });
42
-
43
- if (result.error) {
44
- process.stderr.write(`Failed to run headroom: ${result.error.message}\n`);
45
- process.exitCode = 1;
46
- return;
47
- }
48
-
49
- if (result.status !== null) {
50
- process.exitCode = result.status;
51
- }
52
- }
@@ -1,25 +0,0 @@
1
- import { collectWorkspaceInventory } from "../../../lib/orchestrator/workspace-inventory";
2
- import { writeJsonStdout } from "../../write-json-stdout";
3
-
4
- export const workspaceListCommandDeps = {
5
- collectWorkspaceInventory: () => collectWorkspaceInventory(),
6
- };
7
-
8
- /**
9
- * @command mate workspace list
10
- * @description Prints the aggregate inventory of registered companions and
11
- * their linked working-repository pairings as one JSON document. Runs
12
- * without a linked working repository or any active companion context.
13
- * @flags
14
- * - `--json` — required; this command only supports JSON output.
15
- */
16
- export async function runWorkspaceListCommand(argv: string[] = []): Promise<void> {
17
- if (!argv.includes("--json")) {
18
- process.stderr.write("mate: `workspace list` requires --json.\n");
19
- process.exitCode = 1;
20
- return;
21
- }
22
-
23
- const inventory = await workspaceListCommandDeps.collectWorkspaceInventory();
24
- await writeJsonStdout(inventory);
25
- }
@@ -1,46 +0,0 @@
1
- import { materializeWorkspace } from "../../../lib/orchestrator/workspace-materialize";
2
- import { writeJsonStdout } from "../../write-json-stdout";
3
-
4
- export const workspaceMaterializeCommandDeps = {
5
- materializeWorkspace: (repositoryId: string, companionPath: string) =>
6
- materializeWorkspace({ repositoryId, companionPath }),
7
- };
8
-
9
- function readFlag(argv: string[], name: string): string | undefined {
10
- const index = argv.indexOf(name);
11
- return index >= 0 ? argv[index + 1] : undefined;
12
- }
13
-
14
- /**
15
- * @command mate workspace materialize
16
- * @description Validates an explicit repository/companion pairing, writes
17
- * the generated `.mate/workspace.code-workspace` file, and prints its path
18
- * and folder order as one JSON document. Never spawns or focuses an editor.
19
- * @flags
20
- * - `--repository <id>` — required; the linked repository id to pair.
21
- * - `--companion <path>` — required; the companion path that must link it.
22
- * - `--json` — required; this command only supports JSON output.
23
- */
24
- export async function runWorkspaceMaterializeCommand(argv: string[] = []): Promise<void> {
25
- const repositoryId = readFlag(argv, "--repository");
26
- const companionPath = readFlag(argv, "--companion");
27
-
28
- if (!repositoryId || !companionPath || !argv.includes("--json")) {
29
- process.stderr.write(
30
- "mate: `workspace materialize` requires --repository <id>, --companion <path>, and --json.\n",
31
- );
32
- process.exitCode = 1;
33
- return;
34
- }
35
-
36
- try {
37
- const result = await workspaceMaterializeCommandDeps.materializeWorkspace(
38
- repositoryId,
39
- companionPath,
40
- );
41
- await writeJsonStdout(result);
42
- } catch (error) {
43
- process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
44
- process.exitCode = 1;
45
- }
46
- }
@@ -1,22 +0,0 @@
1
- import { usage } from "../../usage";
2
- import { runWorkspaceListCommand } from "./list";
3
- import { runWorkspaceMaterializeCommand } from "./materialize";
4
-
5
- /** Dispatches `mate workspace <subcommand>`. Distinct from `mate companion open`. */
6
- export async function runWorkspaceCommand(
7
- subcommand: string | undefined,
8
- argv: string[],
9
- ): Promise<void> {
10
- switch (subcommand) {
11
- case "list":
12
- await runWorkspaceListCommand(argv);
13
- return;
14
- case "materialize":
15
- await runWorkspaceMaterializeCommand(argv);
16
- return;
17
- default:
18
- console.error(`Unknown workspace command: ${subcommand ?? ""}`);
19
- console.error(usage());
20
- process.exitCode = 1;
21
- }
22
- }
@@ -1,244 +0,0 @@
1
- // Nudge the agent into the mate-artifact-finish workflow after a Bash command
2
- // visibly archived an OpenSpec change. Stateless PostToolUse hook: never denies
3
- // or blocks; emits additionalContext only on apparent success.
4
- import type { HookEnv } from "./validate-artifact-path";
5
-
6
- export interface NudgeOutcome {
7
- exitCode: number;
8
- stdout: string;
9
- }
10
-
11
- const ARCHIVE_PATH_PATTERN =
12
- /(?:^|[\s/"'`,(])openspec\/changes\/archive\/(\d{4}-\d{2}-\d{2}-[^/\s"'`,)]+)/;
13
-
14
- // The nudge only applies when the openspec capability is enabled and OpenSpec
15
- // git auto mode is on; the plugin registers the hook unconditionally, so the
16
- // gate is evaluated here from launch-injected environment before any parsing.
17
- export function isGateEnabled(env: HookEnv): boolean {
18
- return env.MATE_OPENSPEC_ENABLED === "1" && env.MATE_GIT_AUTO_MODE === "1";
19
- }
20
-
21
- export function shellSplit(command: string): string[] {
22
- const parts: string[] = [];
23
- let current = "";
24
- let quote: string | null = null;
25
- let escaping = false;
26
- for (let index = 0; index < command.length; index += 1) {
27
- const char = command[index];
28
- if (escaping) {
29
- current += char;
30
- escaping = false;
31
- continue;
32
- }
33
- if (char === "\\" && quote !== "'") {
34
- const next = command[index + 1] || "";
35
- if (next === "\\" || /\s/.test(next) || next === "'" || next === '"') {
36
- escaping = true;
37
- continue;
38
- }
39
- }
40
- if ((char === "'" || char === '"') && !quote) {
41
- quote = char;
42
- continue;
43
- }
44
- if (quote === char) {
45
- quote = null;
46
- continue;
47
- }
48
- const next = command[index + 1] || "";
49
- const previous = command[index - 1] || "";
50
- const isControlOperator =
51
- !quote &&
52
- (char === ";" ||
53
- char === "|" ||
54
- (char === "&" && next !== ">" && previous !== ">" && previous !== "<"));
55
- if (isControlOperator) {
56
- if (current) parts.push(current);
57
- current = "";
58
- if ((char === "|" || char === "&") && next === char) {
59
- parts.push(char + next);
60
- index += 1;
61
- } else {
62
- parts.push(char);
63
- }
64
- continue;
65
- }
66
- if (!quote && /\s/.test(char)) {
67
- if (current) parts.push(current);
68
- current = "";
69
- continue;
70
- }
71
- current += char;
72
- }
73
- if (current) parts.push(current);
74
- return parts;
75
- }
76
-
77
- function extractChangeFromPath(value: string): string | null {
78
- const match = value.replaceAll("\\", "/").match(ARCHIVE_PATH_PATTERN);
79
- return match ? match[1].replace(/^\d{4}-\d{2}-\d{2}-/, "") : null;
80
- }
81
-
82
- // Best-effort shell variable substitution, NOT a shell interpreter: a change
83
- // name is often built up through variables (DEST="...archive/$TARGET"; mv a
84
- // "$DEST") rather than appearing as a literal path next to mv/archive. This
85
- // resolves $NAME/${NAME} references against preceding literal NAME=value
86
- // assignment tokens in the same command string; unresolved references are
87
- // left as-is, which keeps prior (silent) behavior for anything it can't follow.
88
- const ASSIGNMENT_PATTERN = /^([A-Za-z_][A-Za-z0-9_]*)=([\s\S]*)$/;
89
- const VARIABLE_REFERENCE_PATTERN = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g;
90
-
91
- function substituteVariables(value: string, vars: Map<string, string>): string {
92
- return value.replace(VARIABLE_REFERENCE_PATTERN, (match, braced: string, bare: string) => {
93
- const name = braced ?? bare;
94
- return vars.has(name) ? vars.get(name)! : match;
95
- });
96
- }
97
-
98
- // Assignments are collected in left-to-right token order so a later
99
- // assignment (e.g. DEST) can reference an earlier one (e.g. TARGET) the same
100
- // way the shell would resolve it at that point in the script.
101
- function collectVariableAssignments(parts: string[]): Map<string, string> {
102
- const vars = new Map<string, string>();
103
- for (const part of parts) {
104
- const match = part.match(ASSIGNMENT_PATTERN);
105
- if (!match) continue;
106
- const [, name, rawValue] = match;
107
- vars.set(name, substituteVariables(rawValue, vars));
108
- }
109
- return vars;
110
- }
111
-
112
- // Shell syntax tokens are never change-name positionals: separators end the
113
- // archive invocation, redirections are skipped (a bare operator also consumes
114
- // its target token).
115
- const SEPARATOR_PATTERN = /^(\|\|?|&&?|;)$/;
116
- const REDIRECTION_PATTERN = /^(\d*(>>?|<)|&>>?)/;
117
- const BARE_REDIRECTION_PATTERN = /^(\d*(>>?|<)|&>>?)$/;
118
-
119
- export function extractArchiveCommand(command: string): string | null {
120
- const parts = shellSplit(command);
121
- const index = parts.findIndex(
122
- (part, position) =>
123
- part === "archive" &&
124
- position > 0 &&
125
- (parts[position - 1] === "openspec" || parts[position - 1].endsWith("/openspec")),
126
- );
127
- if (index < 0) return null;
128
- const vars = collectVariableAssignments(parts);
129
- for (let position = index + 1; position < parts.length; position += 1) {
130
- const part = parts[position];
131
- if (!part || part === "--") continue;
132
- if (SEPARATOR_PATTERN.test(part)) return null;
133
- if (REDIRECTION_PATTERN.test(part)) {
134
- if (BARE_REDIRECTION_PATTERN.test(part)) position += 1;
135
- continue;
136
- }
137
- if (part.startsWith("-")) {
138
- if (part === "--store") position += 1;
139
- continue;
140
- }
141
- return substituteVariables(part, vars);
142
- }
143
- return null;
144
- }
145
-
146
- const MOVE_COMMANDS = new Set([
147
- "mv",
148
- "move",
149
- "move-item",
150
- "cmd",
151
- "cmd.exe",
152
- "powershell",
153
- "powershell.exe",
154
- "pwsh",
155
- "pwsh.exe",
156
- ]);
157
-
158
- function isPython(part: string): boolean {
159
- return /^python(?:\d(?:\.\d+)*)?(?:\.exe)?$/i.test(part.split(/[\\/]/).pop() || part);
160
- }
161
-
162
- export function extractMoveCommand(command: string): string | null {
163
- const parts = shellSplit(command);
164
- const vars = collectVariableAssignments(parts);
165
- for (let index = 0; index < parts.length; index += 1) {
166
- if (MOVE_COMMANDS.has(parts[index].toLowerCase())) {
167
- for (const part of parts.slice(index + 1)) {
168
- const change = extractChangeFromPath(substituteVariables(part, vars));
169
- if (change) return change;
170
- }
171
- }
172
- if (isPython(parts[index]) && /(?:shutil\.move|os\.rename|\.rename\s*\()/.test(command)) {
173
- return extractChangeFromPath(substituteVariables(command, vars));
174
- }
175
- }
176
- return null;
177
- }
178
-
179
- // Nudge only on apparent success: stay silent on a clear failure indication,
180
- // nudge when success is indicated or indeterminate.
181
- export function isClearFailure(response: unknown): boolean {
182
- if (!response || typeof response !== "object") return false;
183
- const value = response as Record<string, unknown>;
184
- if (value.success === false || value.is_error === true) return true;
185
- if (typeof value.error === "string" && value.error) return true;
186
- if (value.interrupted === true) return true;
187
- const exitCode = value.exit_code ?? value.exitCode;
188
- return typeof exitCode === "number" && exitCode !== 0;
189
- }
190
-
191
- export function evaluate(payload: unknown, env: HookEnv): NudgeOutcome {
192
- const silent: NudgeOutcome = { exitCode: 0, stdout: "" };
193
- if (!isGateEnabled(env)) return silent;
194
-
195
- const input = payload && typeof payload === "object" ? (payload as Record<string, unknown>) : {};
196
- if (input.hook_event_name !== "PostToolUse" || input.tool_name !== "Bash") return silent;
197
-
198
- const toolInput =
199
- input.tool_input && typeof input.tool_input === "object"
200
- ? (input.tool_input as Record<string, unknown>)
201
- : {};
202
- const command = typeof toolInput.command === "string" ? toolInput.command : "";
203
- const change = extractArchiveCommand(command) || extractMoveCommand(command);
204
- if (!change) return silent;
205
- if (isClearFailure(input.tool_response)) return silent;
206
-
207
- const context =
208
- `OpenSpec change ${change} was just archived. Invoke the mate-artifact-finish ` +
209
- `skill, then run \`mate artifact finish "${change}" --json\` to complete the ` +
210
- "finish workflow (commit, tag, and push).";
211
- return {
212
- exitCode: 0,
213
- stdout: JSON.stringify({
214
- hookSpecificOutput: {
215
- hookEventName: "PostToolUse",
216
- additionalContext: context,
217
- },
218
- }),
219
- };
220
- }
221
-
222
- async function readStdin(): Promise<string> {
223
- const chunks: Buffer[] = [];
224
- for await (const chunk of process.stdin) {
225
- chunks.push(Buffer.from(chunk));
226
- }
227
- return Buffer.concat(chunks).toString("utf8");
228
- }
229
-
230
- // Plugin-shim entry. The gate check precedes stdin parsing so a disabled gate
231
- // costs one env read.
232
- export async function run(): Promise<number> {
233
- if (!isGateEnabled(process.env)) return 0;
234
-
235
- let payload: unknown = {};
236
- try {
237
- payload = JSON.parse(await readStdin());
238
- } catch {
239
- payload = {};
240
- }
241
- const outcome = evaluate(payload, process.env);
242
- if (outcome.stdout) process.stdout.write(outcome.stdout);
243
- return outcome.exitCode;
244
- }
@@ -1,116 +0,0 @@
1
- import { spawn } from "node:child_process";
2
- import { closeSync, existsSync, openSync, readFileSync } from "node:fs";
3
- import http from "node:http";
4
- import os from "node:os";
5
- import path from "node:path";
6
-
7
- export interface SpawnProxyOpts {
8
- port: number;
9
- }
10
-
11
- export interface WaitForProxyReachableOptions {
12
- attempts?: number;
13
- delayMs?: number;
14
- probe?: (port: number) => Promise<boolean>;
15
- sleep?: (ms: number) => Promise<void>;
16
- }
17
-
18
- export function resolveHeadroomPort(): number {
19
- const portStr = process.env.HEADROOM_PORT;
20
- if (portStr) {
21
- const parsed = parseInt(portStr, 10);
22
- if (!isNaN(parsed) && parsed > 0) return parsed;
23
- }
24
- return 8787;
25
- }
26
-
27
- export function isProxyReachable(port: number): Promise<boolean> {
28
- const readyFile = process.env.MATE_E2E_HEADROOM_PROXY_READY_FILE;
29
- if (readyFile && existsSync(readyFile)) {
30
- return Promise.resolve(true);
31
- }
32
-
33
- return new Promise((resolve) => {
34
- const req = http.get({ hostname: "127.0.0.1", port, path: "/livez", timeout: 2000 }, (res) => {
35
- const statusCode = res.statusCode ?? 0;
36
- resolve(statusCode >= 200 && statusCode < 300);
37
- res.resume();
38
- });
39
- req.on("error", () => resolve(false));
40
- req.on("timeout", () => {
41
- req.destroy();
42
- resolve(false);
43
- });
44
- });
45
- }
46
-
47
- export async function waitForProxyReachable(
48
- port: number,
49
- options: WaitForProxyReachableOptions = {},
50
- ): Promise<boolean> {
51
- const attempts = options.attempts ?? 20;
52
- const delayMs = options.delayMs ?? 250;
53
- const probe = options.probe ?? isProxyReachable;
54
- const sleep =
55
- options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
56
-
57
- for (let attempt = 0; attempt < attempts; attempt += 1) {
58
- if (await probe(port)) {
59
- return true;
60
- }
61
-
62
- if (attempt < attempts - 1) {
63
- await sleep(delayMs);
64
- }
65
- }
66
-
67
- return false;
68
- }
69
-
70
- // Global map to store proxy stderr for error reporting. Keyed by port number.
71
- const proxyStderr = new Map<number, string>();
72
- const proxyStderrLogPath = new Map<number, string>();
73
-
74
- export function spawnProxyBackground(opts: SpawnProxyOpts, spawnFn: typeof spawn = spawn): void {
75
- // Persistent memory is intentionally not enabled: headroom treats memory as
76
- // opt-in (`--memory`), so a bare `proxy --port` runs compression-only with no
77
- // memory DB writes.
78
- const args = ["proxy", "--port", String(opts.port)];
79
-
80
- let stderrBuffer = "";
81
- const stderrLogPath = path.join(os.tmpdir(), `mate-headroom-proxy-${opts.port}.stderr.log`);
82
- proxyStderrLogPath.set(opts.port, stderrLogPath);
83
- const stderrFd = openSync(stderrLogPath, "w");
84
- const child = spawnFn("headroom", args, {
85
- detached: true,
86
- stdio: ["ignore", "ignore", stderrFd],
87
- env: { ...process.env, HEADROOM_OUTPUT_SHAPER: "0", HEADROOM_TELEMETRY: "off" },
88
- });
89
- closeSync(stderrFd);
90
-
91
- // Tests may inject a mocked child.stderr; real detached launches use a log file.
92
- if (child.stderr) {
93
- child.stderr.on("data", (chunk) => {
94
- stderrBuffer += chunk.toString();
95
- });
96
- child.stderr.on("end", () => {
97
- proxyStderr.set(opts.port, stderrBuffer);
98
- });
99
- }
100
-
101
- child.unref();
102
- }
103
-
104
- export function getProxyStderr(port: number): string {
105
- const buffered = proxyStderr.get(port);
106
- if (buffered) return buffered;
107
-
108
- const logPath = proxyStderrLogPath.get(port);
109
- if (!logPath || !existsSync(logPath)) return "";
110
-
111
- try {
112
- return readFileSync(logPath, "utf8");
113
- } catch {
114
- return "";
115
- }
116
- }
@@ -1,80 +0,0 @@
1
- import path from "node:path";
2
-
3
- import { readCompanionRegistry } from "./companion-registry-reader";
4
- import { pathIsDirectory } from "./repo-local-registry";
5
- import { MateError } from "./types";
6
- import { writeWorkspaceDocument } from "./editor";
7
-
8
- /** Envelope version for `mate workspace materialize --json`. */
9
- export const MATERIALIZED_WORKSPACE_SCHEMA_VERSION = 1;
10
-
11
- export interface MaterializedWorkspaceV1 {
12
- schemaVersion: 1;
13
- workspacePath: string;
14
- folders: [string, string];
15
- }
16
-
17
- export class WorkspacePairingNotFoundError extends MateError {
18
- constructor(repositoryId: string, companionPath: string) {
19
- super(`mate: repository "${repositoryId}" is not linked by companion: ${companionPath}`);
20
- }
21
- }
22
-
23
- export class WorkspaceRootUnavailableError extends MateError {
24
- constructor(
25
- readonly root: "companion" | "repository",
26
- readonly rootPath: string,
27
- ) {
28
- super(`mate: ${root} path does not exist: ${rootPath}`);
29
- }
30
- }
31
-
32
- export interface MaterializeWorkspaceRequest {
33
- repositoryId: string;
34
- companionPath: string;
35
- }
36
-
37
- export interface MaterializeWorkspaceDeps {
38
- isDirectory: (candidatePath: string) => Promise<boolean>;
39
- readCompanionRegistry: typeof readCompanionRegistry;
40
- writeWorkspaceDocument: typeof writeWorkspaceDocument;
41
- }
42
-
43
- export function defaultMaterializeWorkspaceDeps(): MaterializeWorkspaceDeps {
44
- return { isDirectory: pathIsDirectory, readCompanionRegistry, writeWorkspaceDocument };
45
- }
46
-
47
- /**
48
- * Resolves an explicit companion-path/repository-id pairing, validates both
49
- * roots exist, and writes the shared workspace document — without touching
50
- * the registry or spawning an editor. Distinct from `injectEditorFolder`,
51
- * which additionally launches/reloads an editor CLI.
52
- */
53
- export async function materializeWorkspace(
54
- request: MaterializeWorkspaceRequest,
55
- deps: MaterializeWorkspaceDeps = defaultMaterializeWorkspaceDeps(),
56
- ): Promise<MaterializedWorkspaceV1> {
57
- const companionPath = path.resolve(request.companionPath);
58
-
59
- if (!(await deps.isDirectory(companionPath))) {
60
- throw new WorkspaceRootUnavailableError("companion", companionPath);
61
- }
62
-
63
- const { repos } = await deps.readCompanionRegistry(companionPath).catch(() => ({ repos: [] }));
64
- const repository = repos.find((repo) => repo.id === request.repositoryId);
65
- if (!repository) {
66
- throw new WorkspacePairingNotFoundError(request.repositoryId, companionPath);
67
- }
68
-
69
- const repositoryPath = path.resolve(repository.path);
70
- if (!(await deps.isDirectory(repositoryPath))) {
71
- throw new WorkspaceRootUnavailableError("repository", repositoryPath);
72
- }
73
-
74
- const { workspacePath, folders } = await deps.writeWorkspaceDocument(
75
- companionPath,
76
- repositoryPath,
77
- );
78
-
79
- return { schemaVersion: MATERIALIZED_WORKSPACE_SCHEMA_VERSION, workspacePath, folders };
80
- }