@uniqbit/mate-core 0.15.5 → 0.16.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 (183) 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 +6 -4
  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/companion/companion.ts +5 -1
  19. package/src/cli/commands/companion/link.ts +2 -2
  20. package/src/cli/commands/companion/sync.ts +92 -0
  21. package/src/cli/commands/doctor.ts +0 -3
  22. package/src/cli/commands/launch/shared.ts +23 -5
  23. package/src/cli/commands/report/collector.ts +72 -78
  24. package/src/cli/commands/report/contract.ts +40 -1
  25. package/src/cli/commands/report/highlight.ts +27 -0
  26. package/src/cli/commands/report/index.ts +11 -18
  27. package/src/cli/commands/report/renderer.ts +199 -2
  28. package/src/cli/commands/report/types.ts +26 -1
  29. package/src/cli/commands/shared/companion-selection.ts +107 -10
  30. package/src/cli/commands/studio/areas.ts +68 -0
  31. package/src/cli/commands/studio/index.ts +69 -0
  32. package/src/cli/commands/studio/inventory.ts +55 -0
  33. package/src/cli/commands/studio/mate-inventory.ts +43 -0
  34. package/src/cli/commands/studio/openspec-cli.ts +198 -0
  35. package/src/cli/commands/studio/payload.ts +184 -0
  36. package/src/cli/commands/studio/routes.ts +2 -0
  37. package/src/cli/commands/studio/selection.ts +61 -0
  38. package/src/cli/commands/studio/server.ts +201 -0
  39. package/src/cli/commands/studio/snapshot.ts +63 -0
  40. package/src/cli/commands/studio/topology.ts +199 -0
  41. package/src/cli/commands/studio/views/client.ts +197 -0
  42. package/src/cli/commands/studio/views/companion-picker.tsx +91 -0
  43. package/src/cli/commands/studio/views/companion-selector.tsx +90 -0
  44. package/src/cli/commands/studio/views/dashboard/changes.tsx +107 -0
  45. package/src/cli/commands/studio/views/dashboard/index.tsx +19 -0
  46. package/src/cli/commands/studio/views/document.tsx +256 -0
  47. package/src/cli/commands/studio/views/error.tsx +20 -0
  48. package/src/cli/commands/studio/views/model.ts +34 -0
  49. package/src/cli/commands/studio/views/pairings.tsx +38 -0
  50. package/src/cli/commands/studio/views/skills/index.tsx +87 -0
  51. package/src/cli/commands/studio/views/specs/index.tsx +101 -0
  52. package/src/cli/commands/studio/views/styles.ts +389 -0
  53. package/src/cli/commands/studio/views/warnings.tsx +21 -0
  54. package/src/cli/commands/studio/views/workflow/index.tsx +21 -0
  55. package/src/cli/commands/studio/views/workflow/steps.ts +329 -0
  56. package/src/cli/commands/studio/views/workflow/transcript.tsx +190 -0
  57. package/src/cli/commands/unwrap.ts +70 -0
  58. package/src/cli/commands/wrap.ts +164 -0
  59. package/src/cli/main.ts +68 -19
  60. package/src/cli/parse-flags.ts +36 -11
  61. package/src/cli/usage.ts +12 -3
  62. package/src/framework.ts +1 -7
  63. package/src/hooks/session-banner.ts +64 -11
  64. package/src/hooks/session-guidance.ts +40 -0
  65. package/src/hooks/validate-artifact-path.ts +108 -35
  66. package/src/lib/fs-utils.ts +9 -0
  67. package/src/lib/install.ts +33 -0
  68. package/src/lib/orchestrator/adapters/base.ts +14 -125
  69. package/src/lib/orchestrator/adapters/claude.ts +0 -11
  70. package/src/lib/orchestrator/adapters/opencode.ts +2 -32
  71. package/src/lib/orchestrator/companion-git-sync.ts +94 -84
  72. package/src/lib/orchestrator/config-store.ts +2 -21
  73. package/src/lib/orchestrator/editor.ts +12 -22
  74. package/src/lib/orchestrator/framework-context.ts +17 -6
  75. package/src/lib/orchestrator/global-config-store.ts +1 -1
  76. package/src/lib/orchestrator/launcher.ts +97 -7
  77. package/src/lib/orchestrator/opencode-guidance.ts +4 -56
  78. package/src/lib/orchestrator/projection-claude-entry.ts +198 -0
  79. package/src/lib/orchestrator/projection-claude-skills.ts +120 -0
  80. package/src/lib/orchestrator/projection-companion-link.ts +62 -0
  81. package/src/lib/orchestrator/projection-entries.ts +377 -0
  82. package/src/lib/orchestrator/projection-record.ts +56 -0
  83. package/src/lib/orchestrator/projection-runtime-documents.ts +424 -0
  84. package/src/lib/orchestrator/projection-types.ts +169 -0
  85. package/src/lib/orchestrator/repo-local-registry.ts +37 -133
  86. package/src/lib/orchestrator/repo-local-store.ts +96 -0
  87. package/src/lib/orchestrator/setup-compatibilities.ts +1 -9
  88. package/src/lib/orchestrator/types.ts +1 -0
  89. package/src/lib/orchestrator/working-repo-projection.ts +366 -0
  90. package/src/lib/orchestrator/workspace-inventory.ts +1 -1
  91. package/src/lib/package-paths.ts +11 -1
  92. package/src/opencode/companion-hooks.ts +89 -245
  93. package/src/opencode/companion-policy.ts +35 -10
  94. package/src/opencode/index.ts +1 -0
  95. package/src/opencode/projected-guidance.ts +56 -0
  96. package/src/opencode/tui.tsx +13 -4
  97. package/src/playbooks/companion-guidance.ts +32 -116
  98. package/src/plugins.ts +0 -1
  99. package/src/runtime/companion-git-state.ts +156 -0
  100. package/src/runtime/companion-git.ts +203 -0
  101. package/src/runtime/companion-guidance.ts +222 -0
  102. package/src/runtime/companion-sync.ts +298 -0
  103. package/src/runtime/env-names.ts +30 -0
  104. package/src/runtime/env.ts +67 -35
  105. package/src/runtime/framework.ts +10 -0
  106. package/src/runtime/freshness.ts +58 -0
  107. package/src/runtime/index.ts +104 -0
  108. package/src/runtime/install.ts +30 -0
  109. package/src/runtime/policy.ts +66 -0
  110. package/src/runtime/projected-guidance.ts +45 -0
  111. package/src/runtime/projection.ts +224 -0
  112. package/src/runtime/repo-local.ts +64 -0
  113. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +56 -0
  114. package/src/templates/capabilities/openspec-cap/mate-minimal/templates/spec.md +48 -0
  115. package/src/templates/capabilities/openspec-cap/mate-minimal/templates/tasks.md +22 -0
  116. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +31 -90
  117. package/src/templates/capabilities/openspec-cap/mate-v1/templates/design.md +3 -0
  118. package/src/templates/capabilities/openspec-cap/mate-v1/templates/explore-brief.md +6 -6
  119. package/src/templates/capabilities/openspec-cap/mate-v1/templates/spec.md +3 -5
  120. package/src/templates/capabilities/openspec-cap/mate-v1/templates/tasks.md +3 -0
  121. package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +30 -0
  122. package/src/templates/capabilities/react-doctor/claude/hooks/react-doctor.sh +2 -2
  123. package/src/templates/mate-skills/agents/mate-artifact-publish/SKILL.md +185 -0
  124. package/src/templates/mate-skills/agents/mate-artifact-publish/references/openspec.md +227 -0
  125. package/src/templates/mate-skills/agents/mate-domain-modeling/SKILL.md +68 -0
  126. package/src/templates/mate-skills/agents/mate-domain-modeling/references/ADR-FORMAT.md +9 -0
  127. package/src/templates/mate-skills/agents/mate-domain-modeling/references/CONTEXT-FORMAT.md +18 -0
  128. package/src/templates/mate-skills/agents/mate-grill-me/SKILL.md +14 -0
  129. package/src/templates/mate-skills/agents/mate-grill-with-docs/SKILL.md +29 -0
  130. package/src/templates/mate-skills/agents/mate-grilling/SKILL.md +49 -0
  131. package/src/templates/mate-skills/agents/mate-interview-me/SKILL.md +147 -0
  132. package/src/templates/{capabilities/openspec-cap/mate-skills → mate-skills}/agents/mate-openspec-backfill/SKILL.md +4 -2
  133. package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +139 -0
  134. package/src/templates/mate-skills/agents/mate-simplify-code/SKILL.md +503 -0
  135. package/src/templates/mate-skills/claude/mate-artifact-publish/SKILL.md +185 -0
  136. package/src/templates/mate-skills/claude/mate-artifact-publish/references/openspec.md +227 -0
  137. package/src/templates/mate-skills/claude/mate-domain-modeling/SKILL.md +68 -0
  138. package/src/templates/mate-skills/claude/mate-domain-modeling/references/ADR-FORMAT.md +9 -0
  139. package/src/templates/mate-skills/claude/mate-domain-modeling/references/CONTEXT-FORMAT.md +18 -0
  140. package/src/templates/mate-skills/claude/mate-grill-me/SKILL.md +14 -0
  141. package/src/templates/mate-skills/claude/mate-grill-with-docs/SKILL.md +29 -0
  142. package/src/templates/mate-skills/claude/mate-grilling/SKILL.md +49 -0
  143. package/src/templates/mate-skills/claude/mate-interview-me/SKILL.md +147 -0
  144. package/src/templates/mate-skills/claude/mate-openspec-backfill/SKILL.md +67 -0
  145. package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +139 -0
  146. package/src/templates/mate-skills/claude/mate-simplify-code/SKILL.md +503 -0
  147. package/src/templates/report-assets/README.md +32 -0
  148. package/src/templates/report-assets/mermaid.LICENSE +21 -0
  149. package/src/templates/report-assets/mermaid.min.js +4376 -0
  150. package/src/templates/root/TEMPLATE_CLAUDE.md +1 -9
  151. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +1476 -197
  152. package/src/tools/setup/capabilities/graphify.ts +16 -7
  153. package/src/tools/setup/capabilities/openspec.ts +63 -56
  154. package/src/tools/setup/capabilities/tokensave.ts +47 -0
  155. package/src/tools/setup/engine.ts +34 -6
  156. package/src/tools/setup/mate.ts +42 -13
  157. package/src/tools/setup/plugin.ts +9 -0
  158. package/src/tools/setup/plugins/guidance.ts +11 -1
  159. package/src/tools/setup/providers/claude-format.ts +49 -4
  160. package/src/tools/setup/providers/claude-plugin-hooks.ts +117 -0
  161. package/src/tools/setup/providers/claude.ts +55 -220
  162. package/src/tools/setup/providers/opencode.ts +41 -14
  163. package/src/tools/setup/runtime-documents.ts +174 -0
  164. package/src/tools/setup/surface-target.ts +50 -0
  165. package/src/tools/setup/working-repo-cleanup.ts +33 -26
  166. package/src/tools/setup/working-repo-local-state.ts +21 -1
  167. package/src/tools/setup.ts +25 -3
  168. package/wrappers/bin/graphify +57 -8
  169. package/wrappers/bin/openspec +50 -3
  170. package/claude-plugin/hooks/artifact-finish-nudge.mjs +0 -8
  171. package/src/cli/commands/cap/headroom.ts +0 -52
  172. package/src/cli/commands/workspace/list.ts +0 -25
  173. package/src/cli/commands/workspace/materialize.ts +0 -46
  174. package/src/cli/commands/workspace/workspace.ts +0 -22
  175. package/src/hooks/artifact-finish-nudge.ts +0 -244
  176. package/src/lib/orchestrator/headroom/proxy.ts +0 -116
  177. package/src/lib/orchestrator/workspace-materialize.ts +0 -80
  178. package/src/templates/capabilities/openspec-cap/mate-skills/agents/mate-artifact-finish/SKILL.md +0 -51
  179. package/src/templates/capabilities/openspec-cap/mate-skills/agents/mate-artifact-finish/references/openspec.md +0 -134
  180. package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/SKILL.md +0 -58
  181. package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/references/openspec.md +0 -139
  182. package/src/tools/setup/capabilities/headroom.ts +0 -57
  183. /package/src/cli/commands/{workspace → companion}/open.ts +0 -0
@@ -3,10 +3,8 @@ import type { GitOps } from "./git";
3
3
 
4
4
  /** Pipeline step a {@link FinishResult} refers to (generic across artifact kinds). */
5
5
  export type FinishStep =
6
- | "validate"
7
- | "complete-guard"
8
- | "dirty-guard"
9
- | "produce"
6
+ | "resolve"
7
+ | "branch-guard"
10
8
  | "cap-sync"
11
9
  | "commit"
12
10
  | "sync-remote"
@@ -17,7 +15,7 @@ export type FinishStep =
17
15
  export type FinishStatus = "ok" | "conflict" | "error" | "skipped";
18
16
 
19
17
  /**
20
- * Machine-readable result emitted with `--json`. The finish skill parses this to
18
+ * Machine-readable result emitted with `--json`. The publish skill parses this to
21
19
  * decide whether to hand a rebase conflict to a human, or to drive the remaining
22
20
  * tag + push after resolving one.
23
21
  */
@@ -26,7 +24,7 @@ export interface FinishResult {
26
24
  name: string;
27
25
  anchorName: string | null;
28
26
  tag: string | null;
29
- /** True when the artifact was already produced and the engine skipped that step. */
27
+ /** True when a prior publication already committed or tagged this anchor. */
30
28
  resumed: boolean;
31
29
  step: FinishStep;
32
30
  status: FinishStatus;
@@ -37,7 +35,6 @@ export interface FinishResult {
37
35
 
38
36
  export interface EngineOptions {
39
37
  name: string;
40
- force: boolean;
41
38
  noPush: boolean;
42
39
  }
43
40
 
@@ -49,12 +46,23 @@ export interface EngineDeps {
49
46
  }
50
47
 
51
48
  /**
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.
49
+ * Runs the fixed publication pipeline for a resolved {@link ArtifactFinisher}:
50
+ * resolve → branch guard → cap sync → scoped commit → sync-remote → tag → push. The
51
+ * git, remote-sync, and conflict-handoff machinery is identical for every artifact
52
+ * kind — only the finisher's resolution and cap sync vary.
53
+ *
54
+ * Isolation is a clean abort, not a rollback: the resolved artifact is durable input
55
+ * the pipeline never produced, so no step resets, restores, or deletes a path.
57
56
  */
57
+ /** Lowest free `<base>.N` (N from 2), probed against the tags that exist now. */
58
+ async function nextFreeTag(git: GitOps, base: string): Promise<string> {
59
+ for (let suffix = 2; suffix <= 1000; suffix += 1) {
60
+ const candidate = `${base}.${suffix}`;
61
+ if (!(await git.tagExists(candidate))) return candidate;
62
+ }
63
+ throw new Error(`exhausted ${base}.2 through ${base}.1000 without a free tag name`);
64
+ }
65
+
58
66
  export async function runFinishEngine(
59
67
  finisher: ArtifactFinisher,
60
68
  options: EngineOptions,
@@ -68,7 +76,7 @@ export async function runFinishEngine(
68
76
  anchorName: null,
69
77
  tag: null,
70
78
  resumed: false,
71
- step: "validate",
79
+ step: "resolve",
72
80
  status: "ok",
73
81
  conflictedPaths: [],
74
82
  local: { committed: false, tagged: false, pushed: false },
@@ -93,101 +101,73 @@ export async function runFinishEngine(
93
101
  process.exitCode = 1;
94
102
  };
95
103
 
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
- }
104
+ // Resolve the already-produced artifact. Nothing is mutated, so a refusal here — an
105
+ // unarchived target, or a name matching two archives — leaves the companion untouched.
106
+ result.step = "resolve";
107
+ const resolution = await finisher.resolve(options.name);
108
+ if (!resolution.ok) {
109
+ fail("resolve", resolution.message);
110
+ return result;
119
111
  }
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;
112
+ const resolved: Produced = resolution.resolved;
113
+ result.anchorName = resolved.anchorName;
114
+ const baseTag = `${resolved.tagNamespace ?? finisher.type}/${resolved.anchorName}`;
115
+ // Provisional: a `suffix` publication only learns its final name at the tag
116
+ // step, once the remote sync has revealed tags created elsewhere. Early exits
117
+ // therefore report the tag this run would have created, not one that exists.
118
+ result.tag = baseTag;
119
+
120
+ // Branch guard — before cap sync, so a refusal mutates nothing at all. It applies to
121
+ // --no-push too: the local tag it creates is the anchor a later push would publish.
122
+ result.step = "branch-guard";
123
+ const [expectedBranch, currentBranch] = await Promise.all([
124
+ git.defaultBranch(),
125
+ git.currentBranch(),
126
+ ]);
127
+ if (currentBranch === null) {
128
+ fail(
129
+ "branch-guard",
130
+ `mate: publication requires the companion's default branch (${expectedBranch}); HEAD is detached.`,
131
+ );
132
+ return result;
133
+ }
134
+ if (currentBranch !== expectedBranch) {
135
+ fail(
136
+ "branch-guard",
137
+ `mate: refusing to publish from ${currentBranch}; the companion's default branch is ${expectedBranch}.`,
138
+ );
139
+ return result;
157
140
  }
158
- result.anchorName = produced.anchorName;
159
- const tagName = `${finisher.type}/${produced.anchorName}`;
160
- result.tag = tagName;
161
141
 
162
142
  // Capability sync (only openspec-derived outputs are refreshed; commit stays scoped).
163
143
  if (finisher.capSync) {
164
144
  result.step = "cap-sync";
165
145
  if (!(await finisher.capSync())) {
166
- // Post-produce failure: retain the produced artifact — it is the resume state.
167
146
  fail(
168
147
  "cap-sync",
169
- "mate: cap sync failed; the produced artifact was retained — re-run `mate artifact finish` to resume.",
148
+ "mate: cap sync failed; the resolved archive was left in place — re-run `mate artifact publish` to retry.",
170
149
  );
171
150
  return result;
172
151
  }
173
152
  }
174
153
 
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.
154
+ // Commit — stage only the finisher's scoped paths. Nothing to stage means a prior
155
+ // publication already committed this anchor, which is a resume rather than an error.
177
156
  result.step = "commit";
178
157
  try {
179
- await git.add(produced.commitPaths);
180
- if (await git.hasStagedChanges(produced.commitPaths)) {
158
+ await git.add(resolved.commitPaths);
159
+ if (await git.hasStagedChanges(resolved.commitPaths)) {
181
160
  await git.commit(
182
- `chore(${finisher.type}): finish ${produced.anchorName}`,
183
- produced.commitPaths,
161
+ resolved.commitSubject ?? `chore(${finisher.type}): finish ${resolved.anchorName}`,
162
+ resolved.commitPaths,
184
163
  );
164
+ } else {
165
+ result.resumed = true;
185
166
  }
186
167
  } catch (err) {
187
- // Post-produce failure: retain the produced artifact — it is the resume state.
188
168
  fail(
189
169
  "commit",
190
- `mate: commit failed: ${String(err)}; the produced artifact was retained — re-run \`mate artifact finish\` to resume.`,
170
+ `mate: commit failed: ${String(err)}; the resolved archive was left in place — re-run \`mate artifact publish\` to retry.`,
191
171
  );
192
172
  return result;
193
173
  }
@@ -219,12 +199,21 @@ export async function runFinishEngine(
219
199
  }
220
200
  }
221
201
 
222
- // Tag the (rebased) finish commit — idempotent if a prior finish already tagged it.
202
+ // Tag the (rebased) publication commit — idempotent if a prior run already tagged
203
+ // it. A `suffix` publication that produced a fresh commit takes the next free name
204
+ // instead, so its commit gets an anchor rather than trailing someone else's tag.
223
205
  result.step = "tag";
206
+ let tagName = baseTag;
224
207
  try {
225
- if (!(await git.tagExists(tagName))) {
226
- await git.tag(tagName, `Finish ${produced.anchorName}`);
208
+ if (!(await git.tagExists(baseTag))) {
209
+ await git.tag(tagName, `Publish ${resolved.anchorName}`);
210
+ } else if (resolved.tagCollision === "suffix" && !result.resumed) {
211
+ tagName = await nextFreeTag(git, baseTag);
212
+ await git.tag(tagName, `Publish ${resolved.anchorName}`);
213
+ } else {
214
+ result.resumed = true;
227
215
  }
216
+ result.tag = tagName;
228
217
  } catch (err) {
229
218
  // Post-commit failure: retain the commit, do not roll back.
230
219
  fail("tag", `mate: tag failed: ${String(err)}`);
@@ -235,7 +224,7 @@ export async function runFinishEngine(
235
224
  if (options.noPush) {
236
225
  result.step = "done";
237
226
  result.status = "skipped";
238
- result.message = `Finished ${produced.anchorName} locally (commit + tag ${tagName}); not pushed (--no-push).`;
227
+ result.message = `Published ${resolved.anchorName} locally (commit + tag ${tagName}); not pushed (--no-push).`;
239
228
  emit();
240
229
  return result;
241
230
  }
@@ -255,7 +244,7 @@ export async function runFinishEngine(
255
244
 
256
245
  result.step = "done";
257
246
  result.status = "ok";
258
- result.message = `Finished ${produced.anchorName}: ${resuming ? "resumed, " : ""}committed, tagged ${tagName}, and pushed.`;
247
+ result.message = `Published ${resolved.anchorName}: ${result.resumed ? "resumed, " : ""}committed, tagged ${tagName}, and pushed.`;
259
248
  emit();
260
249
  return result;
261
250
  }
@@ -7,38 +7,38 @@ export interface FinishContext {
7
7
  }
8
8
 
9
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.
10
+ * The committable result of resolving a publication target: what anchor the tag is
11
+ * derived from and which paths the publication commit is scoped to.
12
12
  */
13
13
  export interface Produced {
14
14
  /** Dated/immutable anchor the tag mirrors, e.g. `2026-07-14-my-change`. */
15
15
  anchorName: string;
16
- /** Pathspecs the finish commit stages — and nothing outside them. */
16
+ /** Pathspecs the publication commit stages — and nothing outside them. */
17
17
  commitPaths: string[];
18
+ /** Tag namespace preceding the anchor; defaults to the finisher's type. */
19
+ tagNamespace?: string;
20
+ /** Whole commit subject; defaults to `chore(<type>): finish <anchorName>`. */
21
+ commitSubject?: string;
22
+ /**
23
+ * Tag behaviour when the name is taken and this run produced a fresh commit.
24
+ * `reuse` (default) leaves the existing tag and pushes; `suffix` takes the
25
+ * lowest free `.N` so the fresh commit gets an anchor of its own. A resumed
26
+ * run reuses under either value: its commit is the tagged one already.
27
+ */
28
+ tagCollision?: "reuse" | "suffix";
18
29
  }
19
30
 
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
- }
31
+ /** Outcome of {@link ArtifactFinisher.resolve}; the failure carries the user-facing refusal. */
32
+ export type ResolveResult = { ok: true; resolved: Produced } | { ok: false; message: string };
36
33
 
37
34
  /**
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).
35
+ * The variable, per-artifact-kind half of `mate artifact publish`. The engine
36
+ * ({@link ../engine}) owns everything type-agnostic — branch guard, commit,
37
+ * remote-sync, conflict handoff, tag, push. A finisher supplies only what differs
38
+ * between artifact kinds (openspec changes today; ADRs, etc. later).
39
+ *
40
+ * Producing the artifact is not part of the interface: the archive-equivalent step
41
+ * for every artifact kind is its own workflow, and publishing is terminal over it.
42
42
  */
43
43
  export interface ArtifactFinisher {
44
44
  /** Selector key, e.g. `openspec`. */
@@ -47,18 +47,11 @@ export interface ArtifactFinisher {
47
47
  readonly disabledReason: string;
48
48
  /** Gate: is this finisher usable in the resolved capability set? */
49
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
50
  /**
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.
51
+ * Resolve a target — a dated anchor or an unambiguous artifact name — to the already
52
+ * produced outputs. Mutates nothing a failure would have to undo.
58
53
  */
59
- detectProduced(name: string): Promise<Produced | null>;
60
- /** The terminal transform that yields committable outputs (openspec archive; …). */
61
- produce(name: string): Promise<ProduceResult>;
54
+ resolve(target: string): Promise<ResolveResult>;
62
55
  /** Optional capability sync scoped to this finisher; resolves false on failure. */
63
56
  capSync?(): Promise<boolean>;
64
57
  }
@@ -12,15 +12,21 @@ export interface PushResult {
12
12
  error: string;
13
13
  }
14
14
 
15
+ export type WorkingTreeChange = "new" | "modified";
16
+
15
17
  /**
16
18
  * Git operations the finish engine needs, injectable for deterministic tests. All
17
19
  * operations run against the companion working tree.
18
20
  */
19
21
  export interface GitOps {
20
- /** Full SHA of the current HEAD (recorded pre-finish for rollback). */
21
- headRef(): Promise<string>;
22
+ /** Checked-out branch name, or null in a detached HEAD. */
23
+ currentBranch(): Promise<string | null>;
24
+ /** Branch publication is allowed on: remote HEAD, then `init.defaultBranch`, then `main`. */
25
+ defaultBranch(): Promise<string>;
22
26
  /** Paths with uncommitted changes (porcelain), for scope-aware guards. */
23
27
  changedPaths(): Promise<string[]>;
28
+ /** Git change kind by path, for consumers that need new versus modified metadata. */
29
+ changedPathKinds?(): Promise<Record<string, WorkingTreeChange>>;
24
30
  /** Paths already present in the index. */
25
31
  stagedPaths(): Promise<string[]>;
26
32
  /** Stage the given pathspecs. */
@@ -29,8 +35,6 @@ export interface GitOps {
29
35
  hasStagedChanges(paths?: string[]): Promise<boolean>;
30
36
  /** Commit only the supplied pathspecs, leaving unrelated staged work untouched. */
31
37
  commit(message: string, paths?: string[]): Promise<void>;
32
- /** Restore only produced artifact paths to a recorded ref. */
33
- restorePaths(ref: string, paths: string[]): Promise<void>;
34
38
  hasUpstream(): Promise<boolean>;
35
39
  fetch(): Promise<void>;
36
40
  rebaseOntoUpstream(): Promise<RebaseResult>;
@@ -98,6 +102,11 @@ function parsePorcelainPath(line: string): string {
98
102
  return arrow === -1 ? body : body.slice(arrow + 4);
99
103
  }
100
104
 
105
+ function parsePorcelainKind(line: string): WorkingTreeChange {
106
+ const status = line.slice(0, 2);
107
+ return status.includes("?") || status.includes("A") ? "new" : "modified";
108
+ }
109
+
101
110
  export function defaultGitOps(
102
111
  companionPath: string,
103
112
  workingRepoPath = process.env.MATE_REPO_PATH,
@@ -112,14 +121,33 @@ export function defaultGitOps(
112
121
  return res;
113
122
  };
114
123
  return {
115
- async headRef() {
116
- return execOrThrow(["rev-parse", "HEAD"]).out;
124
+ async currentBranch() {
125
+ const res = exec(["symbolic-ref", "--quiet", "--short", "HEAD"]);
126
+ return res.status === 0 && res.out.length > 0 ? res.out : null;
127
+ },
128
+ async defaultBranch() {
129
+ // A configured remote HEAD is the only answer the remote itself asserts; the
130
+ // rest are local conventions, narrowing to git's own default last.
131
+ const remoteHead = exec(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]);
132
+ if (remoteHead.status === 0 && remoteHead.out.length > 0) {
133
+ return remoteHead.out.replace(/^origin\//, "");
134
+ }
135
+ const configured = exec(["config", "--get", "init.defaultBranch"]);
136
+ if (configured.status === 0 && configured.out.length > 0) return configured.out;
137
+ return "main";
117
138
  },
118
139
  async changedPaths() {
119
140
  const out = exec(["status", "--porcelain"]).out;
120
141
  if (out.length === 0) return [];
121
142
  return out.split("\n").map(parsePorcelainPath);
122
143
  },
144
+ async changedPathKinds() {
145
+ const out = exec(["status", "--porcelain"]).out;
146
+ if (out.length === 0) return {};
147
+ return Object.fromEntries(
148
+ out.split("\n").map((line) => [parsePorcelainPath(line), parsePorcelainKind(line)]),
149
+ );
150
+ },
123
151
  async stagedPaths() {
124
152
  const out = exec(["diff", "--cached", "--name-only"]).out;
125
153
  return out.length === 0 ? [] : out.split("\n");
@@ -153,23 +181,6 @@ export function defaultGitOps(
153
181
  if (paths && paths.length > 0 && known.length === 0) return;
154
182
  execOrThrow(["commit", "-m", message, ...pathspec]);
155
183
  },
156
- async restorePaths(ref, paths) {
157
- const tracked = paths.filter(
158
- (filePath) => exec(["ls-files", "--cached", "--", filePath]).out.length > 0,
159
- );
160
- if (tracked.length > 0) {
161
- execOrThrow(["restore", "--source", ref, "--staged", "--worktree", "--", ...tracked]);
162
- }
163
-
164
- // `git restore` does not remove newly-created untracked archive files.
165
- const untracked = paths.filter(
166
- (filePath) =>
167
- exec(["ls-files", "--others", "--exclude-standard", "--", filePath]).out.length > 0,
168
- );
169
- if (untracked.length > 0) {
170
- execOrThrow(["clean", "-fd", "--", ...untracked]);
171
- }
172
- },
173
184
  async hasUpstream() {
174
185
  return exec(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]).status === 0;
175
186
  },
@@ -1,4 +1,10 @@
1
- export { runArtifactFinishCommand } from "./command";
2
- export type { FinishCommandDeps } from "./command";
1
+ export { runArtifactPublishCommand } from "./command";
2
+ export type { PublishCommandDeps } from "./command";
3
3
  export type { FinishResult, FinishStep, FinishStatus } from "./engine";
4
- export type { ArtifactFinisher, FinishContext, FinisherFactory, Produced } from "./finisher";
4
+ export type {
5
+ ArtifactFinisher,
6
+ FinishContext,
7
+ FinisherFactory,
8
+ Produced,
9
+ ResolveResult,
10
+ } from "./finisher";