@uniqbit/mate-core 0.15.0-canary.1 → 0.15.0-canary.3

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 (144) hide show
  1. package/package.json +25 -4
  2. package/src/cli/commands/artifact/artifact.ts +22 -0
  3. package/src/cli/commands/artifact/extract-repo-name.ts +35 -0
  4. package/src/cli/commands/artifact/finish/command.ts +160 -0
  5. package/src/cli/commands/artifact/finish/engine.ts +261 -0
  6. package/src/cli/commands/artifact/finish/finisher.ts +66 -0
  7. package/src/cli/commands/artifact/finish/git.ts +201 -0
  8. package/src/cli/commands/artifact/finish/index.ts +4 -0
  9. package/src/cli/commands/artifact/finish/openspec.ts +196 -0
  10. package/src/cli/commands/artifact/finish/registry.ts +20 -0
  11. package/src/cli/commands/cap/graphify.ts +189 -0
  12. package/src/cli/commands/cap/headroom.ts +52 -0
  13. package/src/cli/commands/cap/index-cmd.ts +188 -0
  14. package/src/cli/commands/cap/index.ts +49 -0
  15. package/src/cli/commands/cap/openspec.ts +92 -0
  16. package/src/cli/commands/cap/tokensave.ts +71 -0
  17. package/src/cli/commands/companion/companion.ts +33 -0
  18. package/src/cli/commands/companion/link.ts +297 -0
  19. package/src/cli/commands/companion/list.ts +31 -0
  20. package/src/cli/commands/companion/tui.ts +105 -0
  21. package/src/cli/commands/config.ts +31 -0
  22. package/src/cli/commands/doctor.ts +280 -0
  23. package/src/cli/commands/install.ts +97 -0
  24. package/src/cli/commands/launch/claude.ts +15 -0
  25. package/src/cli/commands/launch/opencode.ts +15 -0
  26. package/src/cli/commands/launch/shared.ts +155 -0
  27. package/src/cli/commands/report/collector.ts +294 -0
  28. package/src/cli/commands/report/formatter.ts +17 -0
  29. package/src/cli/commands/report/index.ts +156 -0
  30. package/src/cli/commands/report/renderer.ts +108 -0
  31. package/src/cli/commands/report/types.ts +42 -0
  32. package/src/cli/commands/setup.ts +218 -0
  33. package/src/cli/commands/shared/companion-selection.ts +45 -0
  34. package/src/cli/commands/status.ts +21 -0
  35. package/src/cli/commands/update.ts +184 -0
  36. package/src/cli/commands/workspace/open.ts +63 -0
  37. package/src/cli/companion-link-wizard.tsx +422 -0
  38. package/src/cli/companion-selector.tsx +137 -0
  39. package/src/cli/confirm.ts +5 -0
  40. package/src/cli/install-plan.tsx +220 -0
  41. package/src/cli/launch-selector.tsx +320 -0
  42. package/src/cli/main.ts +119 -0
  43. package/src/cli/parse-flags.ts +83 -0
  44. package/src/cli/plugin-commands.ts +104 -0
  45. package/src/cli/repo-list-table.tsx +100 -0
  46. package/src/cli/setup-selector.tsx +660 -0
  47. package/src/cli/usage.ts +33 -0
  48. package/src/cli/wizard-key-input.ts +39 -0
  49. package/src/create-mate.ts +44 -0
  50. package/src/distribution.ts +56 -0
  51. package/src/framework.ts +19 -0
  52. package/src/index.ts +36 -14
  53. package/src/lib/ci-env.ts +13 -0
  54. package/src/lib/components/confirm-prompt.tsx +170 -0
  55. package/src/lib/components/select-menu.tsx +33 -0
  56. package/src/lib/components/spinner.tsx +17 -0
  57. package/src/lib/components/startup-progress.tsx +115 -0
  58. package/src/lib/components/text-input-display.tsx +20 -0
  59. package/src/lib/components/theme.ts +2 -0
  60. package/src/lib/components/wizard-footer.tsx +13 -0
  61. package/src/lib/components/wizard-header.tsx +29 -0
  62. package/src/lib/fs-utils.ts +26 -0
  63. package/src/lib/install.ts +345 -0
  64. package/src/lib/opencode-plugin-package.ts +96 -0
  65. package/src/lib/orchestrator/adapters/base.ts +231 -0
  66. package/src/lib/orchestrator/adapters/claude.ts +60 -0
  67. package/src/lib/orchestrator/adapters/opencode.ts +233 -0
  68. package/src/lib/orchestrator/capabilities.ts +13 -0
  69. package/src/lib/orchestrator/companion-git-sync.ts +431 -0
  70. package/src/lib/orchestrator/companion-resolver.ts +131 -0
  71. package/src/lib/orchestrator/companion-store.ts +99 -0
  72. package/src/lib/orchestrator/config-store.ts +55 -0
  73. package/src/lib/orchestrator/editor.ts +210 -0
  74. package/src/lib/orchestrator/engine-guard.ts +38 -0
  75. package/src/lib/orchestrator/framework-context.ts +186 -0
  76. package/src/lib/orchestrator/global-config-store.ts +58 -0
  77. package/src/lib/orchestrator/headroom/proxy.ts +116 -0
  78. package/src/lib/orchestrator/launcher.ts +133 -0
  79. package/src/lib/orchestrator/migration.ts +41 -0
  80. package/src/lib/orchestrator/opencode-guidance.ts +59 -0
  81. package/src/lib/orchestrator/repo-local-registry.ts +280 -0
  82. package/src/lib/orchestrator/setup-compatibilities.ts +341 -0
  83. package/src/lib/orchestrator/setup-preflight.ts +196 -0
  84. package/src/lib/orchestrator/tool.ts +5 -0
  85. package/src/lib/orchestrator/types.ts +108 -0
  86. package/src/lib/orchestrator/working-repo-store.ts +31 -0
  87. package/src/lib/orchestrator/yaml-file-store.ts +29 -0
  88. package/src/lib/package-paths.ts +28 -0
  89. package/src/lib/public-npm.ts +133 -0
  90. package/src/lib/update-checker.ts +89 -0
  91. package/src/opencode/companion-hooks.ts +372 -0
  92. package/src/opencode/companion-policy.ts +177 -0
  93. package/src/opencode/index.ts +9 -0
  94. package/src/opencode/tui.tsx +73 -0
  95. package/src/playbooks/.gitkeep +0 -0
  96. package/src/playbooks/companion-guidance.ts +144 -0
  97. package/src/plugins.ts +16 -0
  98. package/src/runtime/index.ts +16 -0
  99. package/src/templates/capabilities/openspec-cap/claude/hooks/mate-openspec-artifact-finish.sh +236 -0
  100. package/src/templates/capabilities/openspec-cap/mate-skills/mate-openspec-artifact-finish/SKILL.md +51 -0
  101. package/src/templates/capabilities/openspec-cap/mate-skills/mate-openspec-artifact-finish/references/openspec.md +134 -0
  102. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +275 -0
  103. package/src/templates/capabilities/openspec-cap/mate-v1/templates/design.md +26 -0
  104. package/src/templates/capabilities/openspec-cap/mate-v1/templates/explore-brief.md +25 -0
  105. package/src/templates/capabilities/openspec-cap/mate-v1/templates/proposal.md +32 -0
  106. package/src/templates/capabilities/openspec-cap/mate-v1/templates/spec.md +25 -0
  107. package/src/templates/capabilities/openspec-cap/mate-v1/templates/tasks.md +16 -0
  108. package/src/templates/capabilities/react-doctor/claude/hooks/react-doctor.sh +144 -0
  109. package/src/templates/capabilities/react-doctor/skill/SKILL.md +51 -0
  110. package/src/templates/capabilities/react-doctor/skill/references/explain.md +72 -0
  111. package/src/templates/providers/claude/.claude/hooks/mate-session-banner +28 -0
  112. package/src/templates/providers/claude/.claude/hooks/validate-artifact-path +219 -0
  113. package/src/templates/providers/opencode/opencode.json +15 -0
  114. package/src/templates/providers/opencode/tui.json +3 -0
  115. package/src/templates/root/TEMPLATE_AGENTS.md +14 -0
  116. package/src/templates/root/TEMPLATE_CLAUDE.md +16 -0
  117. package/src/tools/setup/capabilities/graphify.ts +538 -0
  118. package/src/tools/setup/capabilities/headroom.ts +152 -0
  119. package/src/tools/setup/capabilities/openspec.ts +399 -0
  120. package/src/tools/setup/capabilities/react-doctor.ts +98 -0
  121. package/src/tools/setup/capabilities/tokensave-shared.ts +1 -0
  122. package/src/tools/setup/capabilities/tokensave.ts +354 -0
  123. package/src/tools/setup/context-services.ts +325 -0
  124. package/src/tools/setup/engine.ts +154 -0
  125. package/src/tools/setup/git-utils.ts +30 -0
  126. package/src/tools/setup/install-contract.ts +20 -0
  127. package/src/tools/setup/package-managers/bun.ts +67 -0
  128. package/src/tools/setup/package-managers/uv.ts +152 -0
  129. package/src/tools/setup/plugin.ts +118 -0
  130. package/src/tools/setup/plugins/gitignore.ts +92 -0
  131. package/src/tools/setup/plugins/guidance.ts +125 -0
  132. package/src/tools/setup/policy.ts +117 -0
  133. package/src/tools/setup/providers/claude.ts +701 -0
  134. package/src/tools/setup/providers/opencode.ts +419 -0
  135. package/src/tools/setup/providers/utils.ts +13 -0
  136. package/src/tools/setup/registry.ts +38 -0
  137. package/src/tools/setup/utils.ts +100 -0
  138. package/src/tools/setup.ts +207 -0
  139. package/wrappers/bin/graphify +76 -0
  140. package/wrappers/bin/openspec +48 -0
  141. package/src/env.test.ts +0 -92
  142. package/src/guidance.test.ts +0 -117
  143. /package/src/{env.ts → runtime/env.ts} +0 -0
  144. /package/src/{guidance.ts → runtime/guidance.ts} +0 -0
package/package.json CHANGED
@@ -1,13 +1,19 @@
1
1
  {
2
2
  "name": "@uniqbit/mate-core",
3
- "version": "0.15.0-canary.1",
3
+ "version": "0.15.0-canary.3",
4
4
  "license": "MIT",
5
5
  "files": [
6
- "src/"
6
+ "src/",
7
+ "wrappers/",
8
+ "!src/**/*.test.ts",
9
+ "!src/**/*.test.tsx"
7
10
  ],
8
11
  "type": "module",
9
12
  "exports": {
10
- ".": "./src/index.ts"
13
+ ".": "./src/index.ts",
14
+ "./plugins": "./src/plugins.ts",
15
+ "./runtime": "./src/runtime/index.ts",
16
+ "./opencode": "./src/opencode/index.ts"
11
17
  },
12
18
  "publishConfig": {
13
19
  "access": "public",
@@ -15,13 +21,28 @@
15
21
  },
16
22
  "scripts": {
17
23
  "test": "CI=1 bun test",
24
+ "typecheck": "bunx tsc --noEmit -p ../../tsconfig.json",
18
25
  "lint": "oxlint src/",
19
26
  "lint:fix": "oxlint src/ --fix",
20
27
  "format": "oxfmt src/",
21
28
  "format:check": "oxfmt src/ --check"
22
29
  },
30
+ "dependencies": {
31
+ "@opencode-ai/plugin": "^1.18.3",
32
+ "@opentui/core": "^0.4.5",
33
+ "@opentui/keymap": "^0.4.5",
34
+ "@opentui/solid": "^0.4.5",
35
+ "ink": "^7.1.0",
36
+ "react": "^19.2.7",
37
+ "react-doctor": "0.8.1",
38
+ "semver": "^7.8.5",
39
+ "yaml": "^2.9.0"
40
+ },
23
41
  "devDependencies": {
24
- "@types/bun": "^1.3.14"
42
+ "@types/bun": "^1.3.14",
43
+ "@types/node": "^26.0.1",
44
+ "@types/react": "^19.2.17",
45
+ "@types/semver": "^7.7.1"
25
46
  },
26
47
  "engines": {
27
48
  "node": "24.x"
@@ -0,0 +1,22 @@
1
+ import { usage } from "../../usage";
2
+ import { runArtifactFinishCommand } from "./finish";
3
+
4
+ /**
5
+ * @command mate artifact <subcommand>
6
+ * @description Dispatches to the supported `artifact` subcommands. Prints usage
7
+ * and sets a non-zero exit code for an unrecognized subcommand.
8
+ */
9
+ export async function runArtifactCommand(
10
+ subcommand: string | undefined,
11
+ argv: string[],
12
+ ): Promise<void> {
13
+ switch (subcommand) {
14
+ case "finish":
15
+ await runArtifactFinishCommand(argv);
16
+ return;
17
+ default:
18
+ console.error(`Unknown artifact command: ${subcommand ?? ""}`);
19
+ console.error(usage());
20
+ process.exitCode = 1;
21
+ }
22
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Extract repository name from a git URL.
3
+ * Handles SSH (git@host:user/repo.git and ssh://git@host:port/user/repo.git),
4
+ * HTTPS (https://host/user/repo.git),
5
+ * and URLs without .git suffix.
6
+ */
7
+ export function extractRepoName(url: string): string | null {
8
+ if (!url || !url.trim()) {
9
+ return null;
10
+ }
11
+
12
+ const cleaned = url.trim();
13
+ let repositoryPath: string;
14
+
15
+ if (/^[a-z][a-z\d+.-]*:\/\//i.test(cleaned)) {
16
+ try {
17
+ repositoryPath = new URL(cleaned).pathname;
18
+ } catch {
19
+ return null;
20
+ }
21
+ } else if (/^[^/\s@]+@[^/\s:]+:.+/.test(cleaned)) {
22
+ // Handle scp-style SSH URLs: git@github.com:user/repo.git
23
+ repositoryPath = cleaned.slice(cleaned.indexOf(":") + 1);
24
+ } else {
25
+ repositoryPath = cleaned.replace(/^https?:\/\//i, "");
26
+ }
27
+
28
+ const lastPart = repositoryPath.split("/").filter(Boolean).at(-1);
29
+ if (!lastPart) {
30
+ return null;
31
+ }
32
+
33
+ // Strip .git suffix
34
+ return lastPart.replace(/\.git$/, "");
35
+ }
@@ -0,0 +1,160 @@
1
+ import { resolveForCapability } from "../../../../lib/orchestrator/framework-context";
2
+ import type { LaunchContext } from "../../../../lib/orchestrator/framework-context";
3
+ import type { CapabilityConfig } from "../../../../lib/orchestrator/types";
4
+ import { WorkingRepoRequiredError } from "../../../../lib/orchestrator/types";
5
+ import { parseFlags } from "../../../parse-flags";
6
+ import { ensureUnambiguousCompanion } from "../../shared/companion-selection";
7
+ import { runFinishEngine } from "./engine";
8
+ import type { FinishContext, FinisherFactory } from "./finisher";
9
+ import { defaultGitOps, type GitOps } from "./git";
10
+ import { DEFAULT_FINISHER_TYPE, knownFinisherTypes, selectFinisher } from "./registry";
11
+
12
+ export interface FinishCommandDeps {
13
+ ensureUnambiguousCompanion?: (cwd: string) => Promise<boolean>;
14
+ resolveContext?: (cwd: string) => Promise<LaunchContext>;
15
+ loadCapabilities?: (context: LaunchContext) => Promise<CapabilityConfig[]>;
16
+ selectFinisher?: (type: string) => FinisherFactory | undefined;
17
+ git?: (companionPath: string, workingRepoPath?: string) => GitOps;
18
+ stdout?: (line: string) => void;
19
+ stderr?: (line: string) => void;
20
+ }
21
+
22
+ const VALUE_FLAGS = new Set(["--type"]);
23
+
24
+ /** First non-flag token, skipping the value of any value-taking flag (e.g. `--type x`). */
25
+ function positionalName(argv: string[]): string | undefined {
26
+ for (let index = 0; index < argv.length; index += 1) {
27
+ const token = argv[index];
28
+ if (VALUE_FLAGS.has(token)) {
29
+ index += 1;
30
+ continue;
31
+ }
32
+ if (token.startsWith("--")) continue;
33
+ return token;
34
+ }
35
+ return undefined;
36
+ }
37
+
38
+ async function defaultLoadCapabilities(context: LaunchContext): Promise<CapabilityConfig[]> {
39
+ const config = await context.configStore.load();
40
+ return config.capabilities ?? [];
41
+ }
42
+
43
+ /**
44
+ * @command mate artifact finish <name>
45
+ * @description Finishes and archives a completed artifact/change named `<name>`.
46
+ * Resolves the launch context for the current working repo, loads the enabled
47
+ * capabilities, selects a {@link FinisherFactory} for `--type` (defaulting to
48
+ * `DEFAULT_FINISHER_TYPE`, e.g. an OpenSpec change), and — if that finisher is
49
+ * enabled for this repo's capabilities — runs {@link runFinishEngine} to
50
+ * validate, produce, and commit/push the finish artifacts via {@link GitOps}.
51
+ * @flags
52
+ * - `<name>` — required positional: the change/artifact name to finish.
53
+ * - `--type <type>` — finisher type to use; see `knownFinisherTypes()`.
54
+ * - `--force` — bypass the completion guard; unrelated companion work is always preserved.
55
+ * - `--no-push` — commit locally but skip pushing.
56
+ * - `--json` — emit machine-readable JSON result instead of human-readable text.
57
+ * @remarks No-ops (with a message on stderr) when the selected finisher is
58
+ * disabled for the repo's configured capabilities — nothing is validated,
59
+ * produced, or mutated in that case.
60
+ */
61
+ export async function runArtifactFinishCommand(
62
+ argv: string[],
63
+ deps: FinishCommandDeps = {},
64
+ ): Promise<void> {
65
+ const emitOut = deps.stdout ?? ((line: string) => process.stdout.write(`${line}\n`));
66
+ const emitErr = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
67
+
68
+ const flags = parseFlags(argv);
69
+ const force = flags.force === true;
70
+ const noPush = flags["no-push"] === true;
71
+ const json = flags.json === true;
72
+ const type = typeof flags.type === "string" ? flags.type : DEFAULT_FINISHER_TYPE;
73
+ const name = positionalName(argv);
74
+
75
+ if (!name) {
76
+ emitErr("mate: artifact finish requires a change name.");
77
+ process.exitCode = 1;
78
+ return;
79
+ }
80
+
81
+ const selectFinisherFn = deps.selectFinisher ?? selectFinisher;
82
+ const finisherFactory = selectFinisherFn(type);
83
+ if (!finisherFactory) {
84
+ emitErr(
85
+ `mate: unknown artifact type "${type}". Known types: ${knownFinisherTypes().join(", ")}.`,
86
+ );
87
+ process.exitCode = 1;
88
+ return;
89
+ }
90
+
91
+ const ensureCompanion = deps.ensureUnambiguousCompanion ?? ensureUnambiguousCompanion;
92
+ if (!(await ensureCompanion(process.cwd()))) {
93
+ process.exitCode = 1;
94
+ return;
95
+ }
96
+
97
+ const resolveContext = deps.resolveContext ?? ((cwd: string) => resolveForCapability(cwd));
98
+ let context: LaunchContext;
99
+ try {
100
+ context = await resolveContext(process.cwd());
101
+ } catch (err) {
102
+ if (err instanceof WorkingRepoRequiredError) {
103
+ emitErr(err.message);
104
+ process.exitCode = 1;
105
+ return;
106
+ }
107
+ throw err;
108
+ }
109
+
110
+ const loadCapabilities = deps.loadCapabilities ?? defaultLoadCapabilities;
111
+ const capabilities = await loadCapabilities(context);
112
+
113
+ const finishContext: FinishContext = {
114
+ companionPath: context.companionPath,
115
+ repositoryId: context.repositoryId,
116
+ repository: context.repository,
117
+ };
118
+ const finisher = finisherFactory(finishContext);
119
+ if (!finisher.isEnabled(capabilities)) {
120
+ // No-op with message: nothing was validated, produced, or mutated.
121
+ emitErr(finisher.disabledReason);
122
+ return;
123
+ }
124
+
125
+ let git: GitOps;
126
+ try {
127
+ git = (deps.git ?? defaultGitOps)(
128
+ context.companionPath,
129
+ context.repository?.path ?? process.env.MATE_REPO_PATH,
130
+ );
131
+ } catch (err) {
132
+ const message = `mate: finish Git guard rejected the target: ${String(err)}`;
133
+ if (json) {
134
+ emitOut(
135
+ JSON.stringify({
136
+ type,
137
+ name,
138
+ anchorName: null,
139
+ tag: null,
140
+ resumed: false,
141
+ step: "commit",
142
+ status: "error",
143
+ conflictedPaths: [],
144
+ local: { committed: false, tagged: false, pushed: false },
145
+ message,
146
+ }),
147
+ );
148
+ } else {
149
+ emitErr(message);
150
+ }
151
+ process.exitCode = 1;
152
+ return;
153
+ }
154
+
155
+ await runFinishEngine(
156
+ finisher,
157
+ { name, force, noPush },
158
+ { git, json, stdout: emitOut, stderr: emitErr },
159
+ );
160
+ }
@@ -0,0 +1,261 @@
1
+ import type { ArtifactFinisher, Produced } from "./finisher";
2
+ import type { GitOps } from "./git";
3
+
4
+ /** Pipeline step a {@link FinishResult} refers to (generic across artifact kinds). */
5
+ export type FinishStep =
6
+ | "validate"
7
+ | "complete-guard"
8
+ | "dirty-guard"
9
+ | "produce"
10
+ | "cap-sync"
11
+ | "commit"
12
+ | "sync-remote"
13
+ | "tag"
14
+ | "push"
15
+ | "done";
16
+
17
+ export type FinishStatus = "ok" | "conflict" | "error" | "skipped";
18
+
19
+ /**
20
+ * Machine-readable result emitted with `--json`. The finish skill parses this to
21
+ * decide whether to hand a rebase conflict to a human, or to drive the remaining
22
+ * tag + push after resolving one.
23
+ */
24
+ export interface FinishResult {
25
+ type: string;
26
+ name: string;
27
+ anchorName: string | null;
28
+ tag: string | null;
29
+ /** True when the artifact was already produced and the engine skipped that step. */
30
+ resumed: boolean;
31
+ step: FinishStep;
32
+ status: FinishStatus;
33
+ conflictedPaths: string[];
34
+ local: { committed: boolean; tagged: boolean; pushed: boolean };
35
+ message: string;
36
+ }
37
+
38
+ export interface EngineOptions {
39
+ name: string;
40
+ force: boolean;
41
+ noPush: boolean;
42
+ }
43
+
44
+ export interface EngineDeps {
45
+ git: GitOps;
46
+ json: boolean;
47
+ stdout: (line: string) => void;
48
+ stderr: (line: string) => void;
49
+ }
50
+
51
+ /**
52
+ * Runs the fixed finish pipeline for a resolved {@link ArtifactFinisher}:
53
+ * (validate → guards) → produce* → cap sync → scoped commit → sync-remote → tag → push,
54
+ * where produce is skipped when the artifact is already produced (resume). The git,
55
+ * remote-sync, conflict-handoff, and rollback machinery is identical for every
56
+ * artifact kind — only the finisher's steps vary.
57
+ */
58
+ export async function runFinishEngine(
59
+ finisher: ArtifactFinisher,
60
+ options: EngineOptions,
61
+ deps: EngineDeps,
62
+ ): Promise<FinishResult> {
63
+ const { git } = deps;
64
+
65
+ const result: FinishResult = {
66
+ type: finisher.type,
67
+ name: options.name,
68
+ anchorName: null,
69
+ tag: null,
70
+ resumed: false,
71
+ step: "validate",
72
+ status: "ok",
73
+ conflictedPaths: [],
74
+ local: { committed: false, tagged: false, pushed: false },
75
+ message: "",
76
+ };
77
+
78
+ const emit = (): void => {
79
+ if (deps.json) {
80
+ deps.stdout(JSON.stringify(result));
81
+ } else if (result.status === "ok" || result.status === "skipped") {
82
+ deps.stdout(result.message);
83
+ } else {
84
+ deps.stderr(result.message);
85
+ }
86
+ };
87
+
88
+ const fail = (step: FinishStep, message: string, status: FinishStatus = "error"): void => {
89
+ result.step = step;
90
+ result.status = status;
91
+ result.message = message;
92
+ emit();
93
+ process.exitCode = 1;
94
+ };
95
+
96
+ // Resumable detection drives which guards apply and whether we produce.
97
+ const existing = await finisher.detectProduced(options.name);
98
+ const resuming = existing !== null;
99
+ result.resumed = resuming;
100
+
101
+ // Produce-time guards apply only to a fresh finish. An already-produced artifact
102
+ // was validated at produce time and is no longer "active" to validate against.
103
+ if (!resuming) {
104
+ const validation = await finisher.validate(options.name);
105
+ if (!validation.valid) {
106
+ fail("validate", `mate: ${options.name} failed validation:\n${validation.errors.join("\n")}`);
107
+ return result;
108
+ }
109
+ if (!options.force) {
110
+ const completeness = await finisher.isComplete(options.name);
111
+ if (!completeness.complete) {
112
+ fail(
113
+ "complete-guard",
114
+ `mate: ${options.name} is not complete (${completeness.remaining} of ${completeness.total} tasks remaining). Use --force to override.`,
115
+ );
116
+ return result;
117
+ }
118
+ }
119
+ }
120
+
121
+ const preFinishHead = await git.headRef();
122
+
123
+ // Never reset the whole companion: unrelated staged, unstaged, and untracked work
124
+ // belongs to the developer. Restore only paths returned by the finisher, and only for
125
+ // a FAILED produce with partial output. A successful produce may have MOVED data that
126
+ // exists nowhere else (an active change dir that was never committed), so post-produce
127
+ // failures must retain the produced paths — they are the resume state, not garbage.
128
+ const rollback = async (paths: string[] | undefined): Promise<void> => {
129
+ if (resuming || !paths || paths.length === 0) return;
130
+ try {
131
+ await git.restorePaths(preFinishHead, paths);
132
+ } catch {
133
+ // Best-effort; the failing-step message already surfaced the root cause.
134
+ }
135
+ };
136
+
137
+ // Produce (skipped when resuming).
138
+ let produced: Produced;
139
+ if (resuming) {
140
+ produced = existing!;
141
+ } else {
142
+ result.step = "produce";
143
+ const outcome = await finisher.produce(options.name);
144
+ if (!outcome.ok || !outcome.produced) {
145
+ await rollback(outcome.produced?.commitPaths);
146
+ fail(
147
+ "produce",
148
+ `mate: ${finisher.type} produce failed: ${outcome.message}${
149
+ outcome.produced
150
+ ? " Produced paths were restored."
151
+ : " Any partial output was retained for inspection."
152
+ }`,
153
+ );
154
+ return result;
155
+ }
156
+ produced = outcome.produced;
157
+ }
158
+ result.anchorName = produced.anchorName;
159
+ const tagName = `${finisher.type}/${produced.anchorName}`;
160
+ result.tag = tagName;
161
+
162
+ // Capability sync (only openspec-derived outputs are refreshed; commit stays scoped).
163
+ if (finisher.capSync) {
164
+ result.step = "cap-sync";
165
+ if (!(await finisher.capSync())) {
166
+ // Post-produce failure: retain the produced artifact — it is the resume state.
167
+ fail(
168
+ "cap-sync",
169
+ "mate: cap sync failed; the produced artifact was retained — re-run `mate artifact finish` to resume.",
170
+ );
171
+ return result;
172
+ }
173
+ }
174
+
175
+ // Commit — stage only the finisher's scoped paths. When resuming a prior finish that
176
+ // already committed, there is nothing to stage; treat that as the commit existing.
177
+ result.step = "commit";
178
+ try {
179
+ await git.add(produced.commitPaths);
180
+ if (await git.hasStagedChanges(produced.commitPaths)) {
181
+ await git.commit(
182
+ `chore(${finisher.type}): finish ${produced.anchorName}`,
183
+ produced.commitPaths,
184
+ );
185
+ }
186
+ } catch (err) {
187
+ // Post-produce failure: retain the produced artifact — it is the resume state.
188
+ fail(
189
+ "commit",
190
+ `mate: commit failed: ${String(err)}; the produced artifact was retained — re-run \`mate artifact finish\` to resume.`,
191
+ );
192
+ return result;
193
+ }
194
+ result.local.committed = true;
195
+
196
+ // Sync with remote before tagging (skipped with --no-push — no remote target).
197
+ if (!options.noPush) {
198
+ result.step = "sync-remote";
199
+ if (await git.hasUpstream()) {
200
+ try {
201
+ await git.fetch();
202
+ } catch (err) {
203
+ // Post-commit failure: retain the commit, do not roll back.
204
+ fail("sync-remote", `mate: fetch failed: ${String(err)}`);
205
+ return result;
206
+ }
207
+ const rebase = await git.rebaseOntoUpstream();
208
+ if (!rebase.ok) {
209
+ // Deliberate stop-for-handoff: commit exists, no tag, not pushed. Never auto-resolve.
210
+ result.step = "sync-remote";
211
+ result.status = "conflict";
212
+ result.conflictedPaths = rebase.conflictedPaths;
213
+ result.message =
214
+ "mate: rebase onto remote conflicted; resolve the conflict, complete the rebase, then finish tag + push.";
215
+ emit();
216
+ process.exitCode = 1;
217
+ return result;
218
+ }
219
+ }
220
+ }
221
+
222
+ // Tag the (rebased) finish commit — idempotent if a prior finish already tagged it.
223
+ result.step = "tag";
224
+ try {
225
+ if (!(await git.tagExists(tagName))) {
226
+ await git.tag(tagName, `Finish ${produced.anchorName}`);
227
+ }
228
+ } catch (err) {
229
+ // Post-commit failure: retain the commit, do not roll back.
230
+ fail("tag", `mate: tag failed: ${String(err)}`);
231
+ return result;
232
+ }
233
+ result.local.tagged = true;
234
+
235
+ if (options.noPush) {
236
+ result.step = "done";
237
+ result.status = "skipped";
238
+ result.message = `Finished ${produced.anchorName} locally (commit + tag ${tagName}); not pushed (--no-push).`;
239
+ emit();
240
+ return result;
241
+ }
242
+
243
+ // Push branch + tag together.
244
+ result.step = "push";
245
+ const push = await git.push();
246
+ if (!push.ok) {
247
+ // Post-tag push failure: retain commit + tag for retry, do not roll back.
248
+ result.status = "error";
249
+ result.message = `mate: push failed (commit and tag ${tagName} retained locally): ${push.error}`;
250
+ emit();
251
+ process.exitCode = 1;
252
+ return result;
253
+ }
254
+ result.local.pushed = true;
255
+
256
+ result.step = "done";
257
+ result.status = "ok";
258
+ result.message = `Finished ${produced.anchorName}: ${resuming ? "resumed, " : ""}committed, tagged ${tagName}, and pushed.`;
259
+ emit();
260
+ return result;
261
+ }
@@ -0,0 +1,66 @@
1
+ import type { CapabilityConfig, LinkedRepository } from "../../../../lib/orchestrator/types";
2
+
3
+ export interface FinishContext {
4
+ companionPath: string;
5
+ repositoryId: string;
6
+ repository?: LinkedRepository;
7
+ }
8
+
9
+ /**
10
+ * The committable result of a finisher's terminal transform: what anchor the tag is
11
+ * derived from and which paths the finish commit is scoped to.
12
+ */
13
+ export interface Produced {
14
+ /** Dated/immutable anchor the tag mirrors, e.g. `2026-07-14-my-change`. */
15
+ anchorName: string;
16
+ /** Pathspecs the finish commit stages — and nothing outside them. */
17
+ commitPaths: string[];
18
+ }
19
+
20
+ export interface ValidateResult {
21
+ valid: boolean;
22
+ errors: string[];
23
+ }
24
+
25
+ export interface CompleteResult {
26
+ complete: boolean;
27
+ total: number;
28
+ remaining: number;
29
+ }
30
+
31
+ export interface ProduceResult {
32
+ ok: boolean;
33
+ produced: Produced | null;
34
+ message: string;
35
+ }
36
+
37
+ /**
38
+ * The variable, per-artifact-kind half of `mate artifact finish`. The engine
39
+ * ({@link ../engine}) owns everything type-agnostic — scoped rollback,
40
+ * commit, remote-sync, conflict handoff, tag, push. A finisher supplies only what
41
+ * differs between artifact kinds (openspec changes today; ADRs, etc. later).
42
+ */
43
+ export interface ArtifactFinisher {
44
+ /** Selector key, e.g. `openspec`. */
45
+ readonly type: string;
46
+ /** Message shown when {@link isEnabled} is false (the no-op-with-message case). */
47
+ readonly disabledReason: string;
48
+ /** Gate: is this finisher usable in the resolved capability set? */
49
+ isEnabled(capabilities: CapabilityConfig[]): boolean;
50
+ /** Never-bypassable guard. Not run when resuming an already-produced artifact. */
51
+ validate(name: string): Promise<ValidateResult>;
52
+ /** `--force`-overridable guard. Not run when resuming. */
53
+ isComplete(name: string): Promise<CompleteResult>;
54
+ /**
55
+ * Resumable detection: return the already-produced artifact (developer ran the
56
+ * transform by hand, or a prior finish half-completed) so the engine skips
57
+ * {@link produce} and continues to commit → tag → push, or null if not yet produced.
58
+ */
59
+ detectProduced(name: string): Promise<Produced | null>;
60
+ /** The terminal transform that yields committable outputs (openspec archive; …). */
61
+ produce(name: string): Promise<ProduceResult>;
62
+ /** Optional capability sync scoped to this finisher; resolves false on failure. */
63
+ capSync?(): Promise<boolean>;
64
+ }
65
+
66
+ export type FinisherFactory = (context: FinishContext) => ArtifactFinisher;