@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
@@ -1,4 +1,3 @@
1
- import { spawnSync } from "node:child_process";
2
1
  import fs from "node:fs/promises";
3
2
  import path from "node:path";
4
3
 
@@ -6,34 +5,38 @@ import { parse } from "yaml";
6
5
 
7
6
  import { hasOpenspecCapability } from "../../../../lib/orchestrator/capabilities";
8
7
  import { runIndexCapCommand } from "../../cap/index-cmd";
9
- import type { ArtifactFinisher, FinishContext } from "./finisher";
10
-
11
- function run(
12
- companionPath: string,
13
- args: string[],
14
- ): { status: number; stdout: string; stderr: string } {
15
- const result = spawnSync(args[0], args.slice(1), {
16
- cwd: companionPath,
17
- encoding: "utf8",
18
- stdio: ["ignore", "pipe", "pipe"],
19
- });
20
- return {
21
- status: result.status ?? 1,
22
- stdout: result.stdout ?? "",
23
- stderr: result.stderr ?? "",
24
- };
25
- }
8
+ import { discoverArchives, SPECS_RELATIVE_DIR, unattributedSpecs } from "../pending/discovery";
9
+ import type { ArtifactFinisher, FinishContext, Produced, ResolveResult } from "./finisher";
10
+ import type { GitOps } from "./git";
26
11
 
27
12
  function escapeRegExp(value: string): string {
28
13
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
29
14
  }
30
15
 
16
+ async function pathExists(candidate: string): Promise<boolean> {
17
+ try {
18
+ await fs.stat(candidate);
19
+ return true;
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ /** A dated archive anchor; group 1 is the change name the archive was created from. */
26
+ const ANCHOR_PATTERN = /^(\d{4}-\d{2}-\d{2})-(.+)$/;
27
+
28
+ /**
29
+ * The publication commit's exact scope. The active-change path is included only when it
30
+ * is gone from disk — that absence IS the deletion archiving produced. A path that still
31
+ * exists belongs to a same-name change started after archiving, which is unrelated work.
32
+ */
31
33
  async function commitPathsForArchive(
32
34
  companionPath: string,
33
35
  name: string,
34
36
  anchorName: string,
35
37
  ): Promise<string[]> {
36
38
  const archiveRelative = path.posix.join("openspec", "changes", "archive", anchorName);
39
+ const activeRelative = path.posix.join("openspec", "changes", name);
37
40
  const deltaSpecsDir = path.join(companionPath, archiveRelative, "specs");
38
41
  const canonicalSpecs: string[] = [];
39
42
  const collectDeltaSpecFiles = async (directory: string, relative = ""): Promise<void> => {
@@ -53,7 +56,7 @@ async function commitPathsForArchive(
53
56
  // Changes without delta specs still commit their active deletion and archive.
54
57
  }
55
58
  return [
56
- path.posix.join("openspec", "changes", name),
59
+ ...((await pathExists(path.join(companionPath, activeRelative))) ? [] : [activeRelative]),
57
60
  archiveRelative,
58
61
  ...canonicalSpecs.toSorted(),
59
62
  ];
@@ -108,7 +111,7 @@ function projectDeltaScopes(scopes: ScopePair[]): ProjectionResult {
108
111
  };
109
112
  }
110
113
 
111
- async function archivedChangeUsesMateV1(
114
+ async function archivedChangeUsesMateSchema(
112
115
  companionPath: string,
113
116
  anchorName: string,
114
117
  ): Promise<boolean> {
@@ -124,7 +127,7 @@ async function archivedChangeUsesMateV1(
124
127
  const parsed = parse(await fs.readFile(metadataPath, "utf8")) as unknown;
125
128
  if (!parsed || typeof parsed !== "object") return false;
126
129
  const schema = (parsed as Record<string, unknown>).schema;
127
- return typeof schema === "string" && schema.trim() === "mate-v1";
130
+ return typeof schema === "string" && ["mate-v1", "mate-minimal"].includes(schema.trim());
128
131
  } catch {
129
132
  return false;
130
133
  }
@@ -148,18 +151,20 @@ function renderCanonicalFrontmatter(
148
151
  }
149
152
 
150
153
  /**
151
- * Prepends canonical frontmatter to main specs born during this archive.
154
+ * Repairs `openspec archive` output: prepends canonical frontmatter to main specs the
155
+ * archive run created bare. Publish does not archive, so this runs the first time publish
156
+ * resolves that archive, however the archive was made.
152
157
  *
153
158
  * `openspec archive` rebuilds a brand-new main spec from a skeleton with no frontmatter slot,
154
159
  * so scope metadata dies exactly once, at spec birth; existing specs keep theirs because only
155
- * requirement blocks are spliced. Best-effort by design — the archive already succeeded, so a
156
- * failure here warns rather than stranding the change archived-but-unfinished.
160
+ * requirement blocks are spliced. Idempotent, and best-effort by design — the archive is
161
+ * durable input, so a failure here warns rather than refusing the publication.
157
162
  */
158
163
  async function reconcileMainSpecFrontmatter(
159
164
  companionPath: string,
160
165
  anchorName: string,
161
166
  ): Promise<string[]> {
162
- if (!(await archivedChangeUsesMateV1(companionPath, anchorName))) return [];
167
+ if (!(await archivedChangeUsesMateSchema(companionPath, anchorName))) return [];
163
168
 
164
169
  const deltaSpecsDir = path.join(
165
170
  companionPath,
@@ -253,16 +258,171 @@ async function capSync(context: FinishContext): Promise<boolean> {
253
258
  }
254
259
  }
255
260
 
261
+ async function resolved(
262
+ companionPath: string,
263
+ name: string,
264
+ anchorName: string,
265
+ ): Promise<ResolveResult> {
266
+ const commitPaths = await commitPathsForArchive(companionPath, name, anchorName);
267
+ await reconcileMainSpecFrontmatter(companionPath, anchorName);
268
+ return { ok: true, resolved: { anchorName, commitPaths } };
269
+ }
270
+
271
+ /** Tag namespace segregating spec publications from dated change anchors. */
272
+ export const SPEC_TAG_NAMESPACE = "openspec/specs";
273
+ /** Spec ids named individually before the remainder collapses into a count. */
274
+ const SPEC_LABEL_LIMIT = 3;
275
+
276
+ /** `openspec/specs/<id>/...` -> `<id>`: the capability directory is the spec's identity. */
277
+ function specId(candidate: string): string {
278
+ return candidate.slice(`${SPECS_RELATIVE_DIR}/`.length).split("/")[0];
279
+ }
280
+
281
+ /** Unique ids in path order, so anchor and subject always name the same specs. */
282
+ function specIds(paths: string[]): string[] {
283
+ return [...new Set(paths.map(specId))];
284
+ }
285
+
256
286
  /**
257
- * The openspec artifact finisher: validate/complete guards over the change, `openspec
258
- * archive` as the terminal transform, an openspec-scoped cap sync, and a commit scoped
259
- * to `openspec/`. Resumable detection reads the dated `openspec/changes/archive/` folder
260
- * openspec actually created so the tag is never a computed date.
287
+ * The spec ids a publication ships, as `<a>+<b>+<c>+<n>-more`. A dated anchor alone
288
+ * says when a publication ran but never what it shipped; naming the specs makes the tag
289
+ * readable without checking out its commit, and the cap keeps a wide sync from growing
290
+ * an unbounded ref name. Empty for an empty publication, which then anchors on its date.
261
291
  */
262
- export function openspecFinisher(
292
+ function specLabel(paths: string[]): string {
293
+ const ids = specIds(paths);
294
+ const named = ids.slice(0, SPEC_LABEL_LIMIT);
295
+ const remaining = ids.length - named.length;
296
+ return [...named, ...(remaining > 0 ? [`${remaining}-more`] : [])].join("+");
297
+ }
298
+
299
+ /** Commit subject for a spec publication; it belongs to no change, so it names specs, never an anchor. */
300
+ export function specCommitSubject(paths: string[]): string {
301
+ const ids = specIds(paths);
302
+ const named = ids.slice(0, SPEC_LABEL_LIMIT);
303
+ const remaining = ids.length - named.length;
304
+ if (named.length === 0) return "chore(openspec): sync canonical specs";
305
+ const listed = remaining > 0 ? `${named.join(", ")} and ${remaining} more` : named.join(", ");
306
+ return `chore(openspec): sync canonical specs (${listed})`;
307
+ }
308
+
309
+ /** Working-tree reads a spec publication needs; nothing here mutates the repository. */
310
+ export type SpecDriftGit = Pick<GitOps, "changedPaths" | "changedPathKinds">;
311
+
312
+ export type DriftResolution = { ok: true; paths: string[] } | { ok: false; message: string };
313
+
314
+ function pad2(value: number): string {
315
+ return String(value).padStart(2, "0");
316
+ }
317
+
318
+ /** Local calendar date as `YYYY-MM-DD`. */
319
+ function calendarDate(now: Date): string {
320
+ return `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`;
321
+ }
322
+
323
+ function withoutTrailingSlash(candidate: string): string {
324
+ return candidate.endsWith("/") ? candidate.slice(0, -1) : candidate;
325
+ }
326
+
327
+ /**
328
+ * Canonical specs that drifted: uncommitted under `openspec/specs/` and claimed by no
329
+ * pending change. Delegates to the same discovery `mate artifact pending` reports from,
330
+ * so the two commands can never disagree about what is drifted. Only paths and
331
+ * working-tree status are read — never spec or archived file content.
332
+ *
333
+ * `requested` narrows the set rather than being trusted: a path that is not drifted, or
334
+ * sits outside the canonical spec tree, refuses the whole invocation instead of
335
+ * publishing a surprising subset.
336
+ */
337
+ export async function resolveDriftedSpecs(
338
+ companionPath: string,
339
+ git: SpecDriftGit,
340
+ requested: string[] = [],
341
+ ): Promise<DriftResolution> {
342
+ const changed = await git.changedPaths();
343
+ const changeKinds = (await git.changedPathKinds?.()) ?? {};
344
+ const archives = await discoverArchives(companionPath, changed, changeKinds);
345
+ const drifted = (await unattributedSpecs(companionPath, changed, archives, changeKinds)).map(
346
+ (spec) => spec.path,
347
+ );
348
+
349
+ if (requested.length === 0) return { ok: true, paths: drifted };
350
+
351
+ const normalized = requested.map(withoutTrailingSlash);
352
+ const outside = normalized.filter((candidate) => !candidate.startsWith(`${SPECS_RELATIVE_DIR}/`));
353
+ if (outside.length > 0) {
354
+ return {
355
+ ok: false,
356
+ message: `mate: not a canonical spec: ${outside.join(", ")}. Only specs under ${SPECS_RELATIVE_DIR}/ are spec publication targets.`,
357
+ };
358
+ }
359
+ const driftedPaths = new Set(drifted);
360
+ const notDrifted = normalized.filter((candidate) => !driftedPaths.has(candidate));
361
+ if (notDrifted.length > 0) {
362
+ return {
363
+ ok: false,
364
+ message: `mate: not drifted: ${notDrifted.join(", ")}. Run \`mate artifact pending\` to see which canonical specs are uncommitted and unaccounted for.`,
365
+ };
366
+ }
367
+ return { ok: true, paths: normalized.toSorted() };
368
+ }
369
+
370
+ /**
371
+ * The spec publication finisher. Its target is already resolved by
372
+ * {@link resolveDriftedSpecs}, because an empty drift set is a clean no-op rather than a
373
+ * refusal and the engine's resolve step can only succeed or fail.
374
+ *
375
+ * This is the one publication unit that computes its own anchor: it has no archive
376
+ * directory to read one from. The anchor is the calendar date plus the {@link specLabel}
377
+ * of the specs it ships. The segregated {@link SPEC_TAG_NAMESPACE} keeps that computed
378
+ * anchor out of the dated change anchors, which still never compute one.
379
+ */
380
+ export function openspecSpecsFinisher(
263
381
  contextOrPath: FinishContext | string,
264
- runCommand: typeof run = run,
382
+ paths: string[],
383
+ now: () => Date = () => new Date(),
265
384
  ): ArtifactFinisher {
385
+ const context: FinishContext =
386
+ typeof contextOrPath === "string"
387
+ ? { companionPath: contextOrPath, repositoryId: "" }
388
+ : contextOrPath;
389
+
390
+ return {
391
+ type: "openspec",
392
+ disabledReason: "mate: the openspec capability must be enabled to run artifact publish.",
393
+ isEnabled(capabilities) {
394
+ return hasOpenspecCapability(capabilities);
395
+ },
396
+ async resolve() {
397
+ const label = specLabel(paths);
398
+ const publication: Produced = {
399
+ anchorName: `${calendarDate(now())}${label === "" ? "" : `-${label}`}`,
400
+ commitPaths: paths,
401
+ tagNamespace: SPEC_TAG_NAMESPACE,
402
+ commitSubject: specCommitSubject(paths),
403
+ tagCollision: "suffix",
404
+ };
405
+ return { ok: true, resolved: publication };
406
+ },
407
+ capSync() {
408
+ return capSync(context);
409
+ },
410
+ };
411
+ }
412
+
413
+ /**
414
+ * The openspec artifact finisher: resolution of an already-archived change, an
415
+ * openspec-scoped cap sync, and a commit scoped to `openspec/`. Validation and
416
+ * completeness belong to `openspec archive`, which performs both and is the only step
417
+ * that can — an archived change is neither validatable nor listed as active.
418
+ *
419
+ * Resolution reads the dated `openspec/changes/archive/` directory names openspec
420
+ * actually created, so a change publication's tag is never a computed date and archived
421
+ * prose is never read. Spec publications are the documented exception: having no archive
422
+ * directory to read a date from, {@link openspecSpecsFinisher} computes one, in its own
423
+ * segregated tag namespace.
424
+ */
425
+ export function openspecFinisher(contextOrPath: FinishContext | string): ArtifactFinisher {
266
426
  const context: FinishContext =
267
427
  typeof contextOrPath === "string"
268
428
  ? { companionPath: contextOrPath, repositoryId: "" }
@@ -271,87 +431,55 @@ export function openspecFinisher(
271
431
 
272
432
  return {
273
433
  type: "openspec",
274
- disabledReason: "mate: the openspec capability must be enabled to run artifact finish.",
434
+ disabledReason: "mate: the openspec capability must be enabled to run artifact publish.",
275
435
  isEnabled(capabilities) {
276
436
  return hasOpenspecCapability(capabilities);
277
437
  },
278
- async validate(name) {
279
- const res = runCommand(companionPath, ["openspec", "validate", name, "--json"]);
280
- try {
281
- const parsed = JSON.parse(res.stdout) as {
282
- items?: Array<{ id: string; valid: boolean; issues?: Array<{ message: string }> }>;
283
- };
284
- const item = parsed.items?.find((entry) => entry.id === name) ?? parsed.items?.[0];
285
- if (!item) {
286
- return { valid: false, errors: [res.stderr.trim() || "no validation result"] };
438
+ async resolve(target) {
439
+ const archiveRoot = path.join(companionPath, "openspec", "changes", "archive");
440
+ const anchor = ANCHOR_PATTERN.exec(target);
441
+
442
+ /** A dated anchor names exactly one directory; no name matching can widen it. */
443
+ if (anchor) {
444
+ if (!(await pathExists(path.join(archiveRoot, target)))) {
445
+ return {
446
+ ok: false,
447
+ message: `mate: no archive at openspec/changes/archive/${target}. Archive the change first with \`openspec archive ${anchor[2]}\`.`,
448
+ };
287
449
  }
288
- return { valid: item.valid, errors: (item.issues ?? []).map((issue) => issue.message) };
289
- } catch {
290
- return {
291
- valid: res.status === 0,
292
- errors: res.status === 0 ? [] : [res.stderr.trim() || "validation failed"],
293
- };
450
+ return resolved(companionPath, anchor[2], target);
294
451
  }
295
- },
296
- async isComplete(name) {
297
- const res = runCommand(companionPath, ["openspec", "list", "--json"]);
452
+
453
+ const pattern = new RegExp(`^\\d{4}-\\d{2}-\\d{2}-${escapeRegExp(target)}$`);
454
+ let matches: string[] = [];
298
455
  try {
299
- const parsed = JSON.parse(res.stdout) as {
300
- changes?: Array<{ name: string; completedTasks: number; totalTasks: number }>;
301
- };
302
- const change = parsed.changes?.find((entry) => entry.name === name);
303
- if (!change) {
304
- return { complete: false, total: 0, remaining: 0 };
456
+ const entries = await fs.readdir(archiveRoot, { withFileTypes: true });
457
+ matches = [];
458
+ for (const entry of entries) {
459
+ if (entry.isDirectory() && pattern.test(entry.name)) matches.push(entry.name);
305
460
  }
306
- const remaining = change.totalTasks - change.completedTasks;
307
- return { complete: remaining <= 0, total: change.totalTasks, remaining };
308
- } catch {
309
- return { complete: false, total: 0, remaining: 0 };
310
- }
311
- },
312
- async detectProduced(name) {
313
- const archiveDir = path.join(companionPath, "openspec", "changes", "archive");
314
- let entries;
315
- try {
316
- entries = await fs.readdir(archiveDir, { withFileTypes: true });
461
+ matches = matches.toSorted();
317
462
  } catch {
318
- return null;
463
+ /** No archive directory at all: nothing can match. */
319
464
  }
320
- const pattern = new RegExp(`^\\d{4}-\\d{2}-\\d{2}-${escapeRegExp(name)}$`);
321
- const matches = entries
322
- .filter((entry) => entry.isDirectory() && pattern.test(entry.name))
323
- .map((entry) => entry.name)
324
- .toSorted();
465
+
325
466
  if (matches.length === 0) {
326
- return null;
327
- }
328
- // Latest dated folder wins if the same change name was ever archived twice.
329
- const anchorName = matches[matches.length - 1];
330
- const commitPaths = await commitPathsForArchive(companionPath, name, anchorName);
331
- await reconcileMainSpecFrontmatter(companionPath, anchorName);
332
- return { anchorName, commitPaths };
333
- },
334
- async produce(name) {
335
- const res = runCommand(companionPath, ["openspec", "archive", name, "--yes"]);
336
- if (res.status !== 0) {
467
+ const active = await pathExists(path.join(companionPath, "openspec", "changes", target));
337
468
  return {
338
469
  ok: false,
339
- produced: null,
340
- message: res.stderr.trim() || res.stdout.trim() || "archive failed",
470
+ message: active
471
+ ? `mate: ${target} is still active and cannot be published. Run \`openspec archive ${target}\` first; publish only publishes archived changes.`
472
+ : `mate: no archived change matches "${target}". Archive it first with \`openspec archive ${target}\`, or pass a dated archive anchor.`,
341
473
  };
342
474
  }
343
- // openspec prints: Change '<name>' archived as '<date>-<name>'.
344
- const match = res.stdout.match(/archived as '([^']+)'/);
345
- if (!match) {
346
- return { ok: false, produced: null, message: "could not detect archived folder name" };
475
+ /** Two archives, no tiebreaker: picking one would publish an anchor nobody chose. */
476
+ if (matches.length > 1) {
477
+ return {
478
+ ok: false,
479
+ message: `mate: "${target}" matches ${matches.length} archives (${matches.join(", ")}). Pass one anchor explicitly.`,
480
+ };
347
481
  }
348
- const commitPaths = await commitPathsForArchive(companionPath, name, match[1]);
349
- await reconcileMainSpecFrontmatter(companionPath, match[1]);
350
- return {
351
- ok: true,
352
- produced: { anchorName: match[1], commitPaths },
353
- message: res.stdout.trim(),
354
- };
482
+ return resolved(companionPath, target, matches[0]);
355
483
  },
356
484
  capSync() {
357
485
  return capSync(context);
@@ -0,0 +1,175 @@
1
+ import { hasOpenspecCapability } from "../../../../lib/orchestrator/capabilities";
2
+ import { resolveForCapability } from "../../../../lib/orchestrator/framework-context";
3
+ import type { LaunchContext } from "../../../../lib/orchestrator/framework-context";
4
+ import type { CapabilityConfig } from "../../../../lib/orchestrator/types";
5
+ import { WorkingRepoRequiredError } from "../../../../lib/orchestrator/types";
6
+ import { type BooleanFlagSet, parseFlags } from "../../../parse-flags";
7
+ import { ensureUnambiguousCompanion } from "../../shared/companion-selection";
8
+ import { defaultGitOps, type GitOps, type WorkingTreeChange } from "../finish/git";
9
+ import {
10
+ discoverArchives,
11
+ pendingArchives,
12
+ unattributedSpecs,
13
+ type ArchiveEntry,
14
+ type UnattributedSpec,
15
+ } from "./discovery";
16
+
17
+ export interface PendingCommandDeps {
18
+ ensureUnambiguousCompanion?: (cwd: string) => Promise<boolean>;
19
+ resolveContext?: (cwd: string) => Promise<LaunchContext>;
20
+ loadCapabilities?: (context: LaunchContext) => Promise<CapabilityConfig[]>;
21
+ git?: (companionPath: string, workingRepoPath?: string) => GitOps;
22
+ discover?: (
23
+ companionPath: string,
24
+ uncommittedPaths: string[],
25
+ changeKinds?: Readonly<Record<string, WorkingTreeChange>>,
26
+ ) => Promise<ArchiveEntry[]>;
27
+ stdout?: (line: string) => void;
28
+ stderr?: (line: string) => void;
29
+ }
30
+
31
+ /** Presence-only flags; every other `--flag` consumes the following token as its value. */
32
+ const BOOLEAN_FLAGS: BooleanFlagSet = new Set(["json"]);
33
+
34
+ /**
35
+ * Whether `mate artifact publish --all` would publish this entry. Discovery does not know
36
+ * about publication modes, so the marking is applied here rather than in the entry types.
37
+ */
38
+ export interface AllCoverage {
39
+ coveredByAll: boolean;
40
+ }
41
+
42
+ /** Machine-readable result emitted with `--json`. */
43
+ export interface PendingResult {
44
+ type: string;
45
+ companionPath: string;
46
+ count: number;
47
+ pending: Array<ArchiveEntry & AllCoverage>;
48
+ /** Uncommitted canonical specs no pending change accounts for, each with its attribution. */
49
+ unattributedSpecs: Array<UnattributedSpec & AllCoverage>;
50
+ }
51
+
52
+ /**
53
+ * `--all` publishes every pending change and then every remaining drifted spec, so both
54
+ * reported sets are covered in full — an unattributed spec included, whatever its
55
+ * attribution, because a spec publication needs no archive to carry it.
56
+ */
57
+ function markCoveredByAll<T>(entries: T[]): Array<T & AllCoverage> {
58
+ return entries.map((entry) => ({ ...entry, coveredByAll: true }));
59
+ }
60
+
61
+ async function defaultLoadCapabilities(context: LaunchContext): Promise<CapabilityConfig[]> {
62
+ const config = await context.configStore.load();
63
+ return config.capabilities ?? [];
64
+ }
65
+
66
+ /** Attribution is printed as candidate archives, never as a claim about which one is responsible. */
67
+ function renderUnattributed(specs: Array<UnattributedSpec & AllCoverage>): string[] {
68
+ if (specs.length === 0) return [];
69
+ return [
70
+ "",
71
+ `${specs.length} uncommitted spec${specs.length === 1 ? "" : "s"} not accounted for by a pending change:`,
72
+ ...specs.flatMap((spec) => [
73
+ ` ${spec.path} [${spec.kind}]${spec.coveredByAll ? " [--all]" : ""}`,
74
+ ...(spec.touchedByArchives.length === 0
75
+ ? [" no archive names this spec"]
76
+ : spec.touchedByArchives.map(
77
+ (archive) => ` publishes via ${archive.anchor} (archive ${archive.state})`,
78
+ )),
79
+ ]),
80
+ ];
81
+ }
82
+
83
+ function renderHuman(result: PendingResult): string[] {
84
+ const pending =
85
+ result.count === 0
86
+ ? ["No archived changes have uncommitted content."]
87
+ : [
88
+ `${result.count} archived change${result.count === 1 ? "" : "s"} pending publication:`,
89
+ ...result.pending.flatMap((entry, index) => [
90
+ ` ${index + 1}. ${entry.name} ${entry.tag} ${entry.path}${entry.coveredByAll ? " [--all]" : ""}`,
91
+ ...entry.uncommittedPaths.map((uncommitted) => ` ${uncommitted}`),
92
+ ...entry.uncommittedSpecs.map((spec) => ` ${spec}`),
93
+ ]),
94
+ ];
95
+ return [...pending, ...renderUnattributed(result.unattributedSpecs)];
96
+ }
97
+
98
+ /**
99
+ * @command mate artifact pending
100
+ * @description Lists archived OpenSpec changes whose own files — the archive directory or
101
+ * the active directory archiving deleted — are still uncommitted in the companion working
102
+ * tree, each with the uncommitted canonical specs it applied to, so the publication
103
+ * workflow can offer exact selectable entries instead of parsing OpenSpec prose.
104
+ * Uncommitted specs no pending change accounts for are reported separately, each with the
105
+ * archives whose delta specs name it. Every reported entry carries `coveredByAll`, marking
106
+ * what `mate artifact publish --all` would publish. Read-only:
107
+ * nothing is validated, produced, committed, tagged, or pushed.
108
+ * @flags
109
+ * - `--json` — emit a machine-readable {@link PendingResult} instead of human-readable text.
110
+ * @remarks No-ops (with a message on stderr) when the openspec capability is disabled for
111
+ * this repo. Exits non-zero only when the companion, launch context, or Git guard rejects.
112
+ */
113
+ export async function runArtifactPendingCommand(
114
+ argv: string[],
115
+ deps: PendingCommandDeps = {},
116
+ ): Promise<void> {
117
+ const emitOut = deps.stdout ?? ((line: string) => process.stdout.write(`${line}\n`));
118
+ const emitErr = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
119
+ const json = parseFlags(argv, BOOLEAN_FLAGS).json === true;
120
+
121
+ const ensureCompanion = deps.ensureUnambiguousCompanion ?? ensureUnambiguousCompanion;
122
+ if (!(await ensureCompanion(process.cwd()))) {
123
+ process.exitCode = 1;
124
+ return;
125
+ }
126
+
127
+ const resolveContext = deps.resolveContext ?? ((cwd: string) => resolveForCapability(cwd));
128
+ let context: LaunchContext;
129
+ try {
130
+ context = await resolveContext(process.cwd());
131
+ } catch (err) {
132
+ if (err instanceof WorkingRepoRequiredError) {
133
+ emitErr(err.message);
134
+ process.exitCode = 1;
135
+ return;
136
+ }
137
+ throw err;
138
+ }
139
+
140
+ const loadCapabilities = deps.loadCapabilities ?? defaultLoadCapabilities;
141
+ if (!hasOpenspecCapability(await loadCapabilities(context))) {
142
+ emitErr("mate: the openspec capability must be enabled to list pending artifacts.");
143
+ return;
144
+ }
145
+
146
+ let git: GitOps;
147
+ try {
148
+ git = (deps.git ?? defaultGitOps)(
149
+ context.companionPath,
150
+ context.repository?.path ?? process.env.MATE_REPO_PATH,
151
+ );
152
+ } catch (err) {
153
+ emitErr(`mate: pending Git guard rejected the target: ${String(err)}`);
154
+ process.exitCode = 1;
155
+ return;
156
+ }
157
+
158
+ const discover = deps.discover ?? discoverArchives;
159
+ const changed = await git.changedPaths();
160
+ const changeKinds = (await git.changedPathKinds?.()) ?? {};
161
+ const archives = await discover(context.companionPath, changed, changeKinds);
162
+ const pending = pendingArchives(archives);
163
+ const result: PendingResult = {
164
+ type: "openspec",
165
+ companionPath: context.companionPath,
166
+ count: pending.length,
167
+ pending: markCoveredByAll(pending),
168
+ unattributedSpecs: markCoveredByAll(
169
+ await unattributedSpecs(context.companionPath, changed, archives, changeKinds),
170
+ ),
171
+ };
172
+
173
+ if (json) emitOut(JSON.stringify(result));
174
+ else for (const line of renderHuman(result)) emitOut(line);
175
+ }