@uniqbit/mate-core 0.18.4 → 1.0.0-canary.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (231) hide show
  1. package/claude-plugin/hooks/companion-instructions.mjs +9 -0
  2. package/claude-plugin/hooks/hooks.json +12 -2
  3. package/opencode-plugin/src/add-dir.ts +76 -10
  4. package/opencode-plugin/src/companion.ts +1 -8
  5. package/opencode-plugin/src/server.ts +2 -15
  6. package/package.json +10 -2
  7. package/src/brand/art.generated.ts +69 -0
  8. package/src/brand/index.ts +12 -0
  9. package/src/brand/palette.ts +19 -0
  10. package/src/brand/svg.generated.ts +8 -0
  11. package/src/brand/terminal.ts +83 -0
  12. package/src/brand/types.ts +33 -0
  13. package/src/cli/commands/artifact/artifact.ts +8 -0
  14. package/src/cli/commands/artifact/delivery/archive.ts +117 -0
  15. package/src/cli/commands/artifact/delivery/branch-flags.ts +55 -0
  16. package/src/cli/commands/artifact/delivery/environment.ts +104 -0
  17. package/src/cli/commands/artifact/delivery/link.ts +154 -0
  18. package/src/cli/commands/artifact/delivery/output.ts +58 -0
  19. package/src/cli/commands/artifact/finish/command.ts +33 -3
  20. package/src/cli/commands/artifact/finish/engine.ts +47 -3
  21. package/src/cli/commands/artifact/finish/finisher.ts +10 -0
  22. package/src/cli/commands/artifact/finish/openspec.ts +200 -129
  23. package/src/cli/commands/artifact/finish/secret-scan.ts +43 -0
  24. package/src/cli/commands/artifact/pending/command.ts +31 -3
  25. package/src/cli/commands/artifact/pending/discovery.ts +21 -57
  26. package/src/cli/commands/cap/graphify.ts +52 -3
  27. package/src/cli/commands/cap/index-cmd.ts +8 -1
  28. package/src/cli/commands/cap/openspec.ts +13 -25
  29. package/src/cli/commands/companion/open.ts +56 -39
  30. package/src/cli/commands/companion/update.ts +75 -42
  31. package/src/cli/commands/doctor.ts +257 -78
  32. package/src/cli/commands/install.ts +0 -8
  33. package/src/cli/commands/plugin/install.ts +9 -4
  34. package/src/cli/commands/plugin/plugin.ts +3 -2
  35. package/src/cli/commands/report/adapter.ts +11 -2
  36. package/src/cli/commands/report/contract.ts +21 -0
  37. package/src/cli/commands/report/highlight.ts +34 -4
  38. package/src/cli/commands/report/markdown.ts +110 -0
  39. package/src/cli/commands/report/palette.ts +118 -0
  40. package/src/cli/commands/report/renderer.ts +456 -153
  41. package/src/cli/commands/report/types.ts +16 -0
  42. package/src/cli/commands/setup.ts +7 -15
  43. package/src/cli/commands/studio/client-asset.ts +16 -0
  44. package/src/cli/commands/studio/openspec-cli.ts +10 -58
  45. package/src/cli/commands/studio/routes.ts +2 -0
  46. package/src/cli/commands/studio/selection.ts +19 -1
  47. package/src/cli/commands/studio/server.ts +42 -1
  48. package/src/cli/commands/studio/terminal-launch.ts +7 -16
  49. package/src/cli/commands/studio/views/client.ts +6 -616
  50. package/src/cli/commands/studio/views/dashboard/changes.tsx +5 -10
  51. package/src/cli/commands/studio/views/document.tsx +137 -69
  52. package/src/cli/commands/studio/views/markdown.tsx +250 -0
  53. package/src/cli/commands/studio/views/palette.ts +134 -0
  54. package/src/cli/commands/studio/views/reports/index.tsx +2 -7
  55. package/src/cli/commands/studio/views/skills/index.tsx +43 -62
  56. package/src/cli/commands/studio/views/specs/index.tsx +7 -15
  57. package/src/cli/commands/studio/views/studio-client/actions.ts +51 -0
  58. package/src/cli/commands/studio/views/studio-client/companion.ts +13 -0
  59. package/src/cli/commands/studio/views/studio-client/index.ts +32 -0
  60. package/src/cli/commands/studio/views/studio-client/keys.ts +3 -0
  61. package/src/cli/commands/studio/views/studio-client/main.ts +3 -0
  62. package/src/cli/commands/studio/views/studio-client/page.ts +146 -0
  63. package/src/cli/commands/studio/views/studio-client/panels.ts +57 -0
  64. package/src/cli/commands/studio/views/studio-client/reports.ts +122 -0
  65. package/src/cli/commands/studio/views/studio-client/theme.ts +63 -0
  66. package/src/cli/commands/studio/views/studio-client/vault-editor.ts +166 -0
  67. package/src/cli/commands/studio/views/studio-client/vault-pane.ts +202 -0
  68. package/src/cli/commands/studio/views/studio-client/vault-tree.ts +258 -0
  69. package/src/cli/commands/studio/views/studio-client.generated.ts +2 -0
  70. package/src/cli/commands/studio/views/styles.ts +217 -206
  71. package/src/cli/commands/studio/views/vault/state.ts +3 -5
  72. package/src/cli/commands/studio/views/workflow/delivery-path.tsx +370 -0
  73. package/src/cli/commands/studio/views/workflow/graph.ts +220 -0
  74. package/src/cli/commands/studio/views/workflow/index.tsx +2 -3
  75. package/src/cli/commands/studio/views/workflow/steps.ts +228 -287
  76. package/src/cli/commands/wrap.ts +9 -42
  77. package/src/cli/main.ts +4 -1
  78. package/src/cli/parse-flags.ts +25 -7
  79. package/src/cli/setup-selector.tsx +38 -162
  80. package/src/cli/usage.ts +5 -2
  81. package/src/hooks/companion-instructions.ts +190 -0
  82. package/src/hooks/hook-io.ts +63 -0
  83. package/src/hooks/session-banner.ts +21 -29
  84. package/src/hooks/session-guidance.ts +10 -36
  85. package/src/hooks/session-prepare.ts +115 -28
  86. package/src/hooks/validate-artifact-path.ts +82 -24
  87. package/src/index.ts +4 -0
  88. package/src/lib/change-layout.ts +154 -0
  89. package/src/lib/components/wizard-header.tsx +3 -1
  90. package/src/lib/context-mode-package.ts +24 -11
  91. package/src/lib/delivery/archive-change.ts +154 -0
  92. package/src/lib/delivery/branches.ts +108 -0
  93. package/src/lib/delivery/environment.ts +29 -0
  94. package/src/lib/delivery/forge.ts +279 -0
  95. package/src/lib/delivery/overlap.ts +202 -0
  96. package/src/lib/delivery/record-delivery.ts +196 -0
  97. package/src/lib/delivery/record.ts +343 -0
  98. package/src/lib/delivery/refresh.ts +174 -0
  99. package/src/lib/delivery/repository-access.ts +97 -0
  100. package/src/lib/delivery/stages.ts +111 -0
  101. package/src/lib/delivery/token.ts +58 -0
  102. package/src/lib/frontmatter.ts +18 -0
  103. package/src/lib/opencode-plugin-package.ts +5 -5
  104. package/src/lib/openspec-cli.ts +116 -0
  105. package/src/lib/openspec-scopes.ts +86 -0
  106. package/src/lib/orchestrator/adapters/claude-args.ts +50 -0
  107. package/src/lib/orchestrator/adapters/claude.ts +60 -162
  108. package/src/lib/orchestrator/adapters/opencode.ts +9 -24
  109. package/src/lib/orchestrator/config-store.ts +2 -70
  110. package/src/lib/orchestrator/editor.ts +9 -9
  111. package/src/lib/orchestrator/openspec-store.ts +14 -54
  112. package/src/lib/orchestrator/projection-claude-commands.ts +36 -0
  113. package/src/lib/orchestrator/projection-claude-links.ts +159 -0
  114. package/src/lib/orchestrator/projection-claude-skills.ts +19 -190
  115. package/src/lib/orchestrator/projection-entries.ts +45 -15
  116. package/src/lib/orchestrator/projection-runtime-documents.ts +15 -6
  117. package/src/lib/orchestrator/projection-types.ts +3 -2
  118. package/src/lib/orchestrator/refresh-working-claude-links.ts +87 -0
  119. package/src/lib/orchestrator/session-prepare.ts +49 -87
  120. package/src/lib/orchestrator/setup-compatibilities.ts +18 -75
  121. package/src/lib/orchestrator/types.ts +2 -2
  122. package/src/lib/orchestrator/wrap-pass.ts +22 -88
  123. package/src/lib/package-paths.ts +8 -0
  124. package/src/opencode/companion-hooks.ts +64 -5
  125. package/src/opencode/companion-policy.ts +37 -18
  126. package/src/opencode/projected-guidance.ts +4 -1
  127. package/src/opencode/session-prepare.ts +2 -1
  128. package/src/opencode/tui.tsx +4 -2
  129. package/src/runtime/artifact-refusal.ts +63 -0
  130. package/src/runtime/claude-delivery-plan.ts +89 -7
  131. package/src/runtime/commit-attribution-guard.ts +181 -0
  132. package/src/runtime/companion-guidance.ts +15 -3
  133. package/src/runtime/companion-instructions.ts +340 -0
  134. package/src/runtime/error-message.ts +4 -0
  135. package/src/runtime/guidance.ts +0 -17
  136. package/src/runtime/index.ts +5 -0
  137. package/src/runtime/managed-block.ts +117 -0
  138. package/src/runtime/markdown-scan.ts +266 -0
  139. package/src/runtime/policy.ts +16 -15
  140. package/src/runtime/projected-flag.ts +7 -0
  141. package/src/runtime/projected-guidance.ts +1 -0
  142. package/src/runtime/runtime-document-manifest.ts +62 -28
  143. package/src/runtime/secret-guard.ts +148 -0
  144. package/src/runtime/secret-patterns.ts +164 -0
  145. package/src/runtime/secret-scan.ts +90 -0
  146. package/src/runtime/session-kind.ts +13 -0
  147. package/src/runtime/shell-heredoc.ts +81 -0
  148. package/src/runtime/wrap-state.ts +36 -6
  149. package/src/runtime/write-atomic.ts +64 -0
  150. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +8 -6
  151. package/src/templates/capabilities/openspec-cap/mate-minimal/templates/spec.md +3 -5
  152. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +7 -9
  153. package/src/templates/capabilities/openspec-cap/mate-v1/templates/spec.md +3 -5
  154. package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +10 -12
  155. package/src/templates/mate-skills/THIRD-PARTY-NOTICES.md +40 -0
  156. package/src/templates/mate-skills/agents/mate-apply-change/SKILL.md +76 -0
  157. package/src/templates/mate-skills/agents/mate-apply-change/references/openspec-apply-change.md +198 -0
  158. package/src/templates/mate-skills/agents/mate-archive-change/SKILL.md +59 -0
  159. package/src/templates/mate-skills/{claude/mate-openspec-backfill → agents/mate-backfill-spec}/SKILL.md +4 -4
  160. package/src/templates/mate-skills/agents/mate-clarify-intent/SKILL.md +95 -0
  161. package/src/templates/mate-skills/agents/mate-explore-change/SKILL.md +50 -0
  162. package/src/templates/mate-skills/agents/mate-explore-change/references/openspec-explore.md +348 -0
  163. package/src/templates/mate-skills/agents/mate-grill-me/SKILL.md +3 -3
  164. package/src/templates/mate-skills/agents/mate-interview-me/SKILL.md +5 -5
  165. package/src/templates/mate-skills/agents/mate-propose-change/SKILL.md +103 -0
  166. package/src/templates/mate-skills/agents/mate-propose-change/references/openspec-propose.md +167 -0
  167. package/src/templates/mate-skills/agents/mate-propose-change/references/openspec-update-change.md +101 -0
  168. package/src/templates/mate-skills/agents/{mate-artifact-publish → mate-publish-artifact}/SKILL.md +91 -23
  169. package/src/templates/mate-skills/agents/{mate-artifact-publish → mate-publish-artifact}/references/openspec.md +4 -3
  170. package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +74 -16
  171. package/src/templates/mate-skills/agents/mate-simplify-code/SKILL.md +74 -68
  172. package/src/templates/mate-skills/claude/mate-apply-change/SKILL.md +76 -0
  173. package/src/templates/mate-skills/claude/mate-apply-change/references/openspec-apply-change.md +198 -0
  174. package/src/templates/mate-skills/claude/mate-archive-change/SKILL.md +59 -0
  175. package/src/templates/mate-skills/{agents/mate-openspec-backfill → claude/mate-backfill-spec}/SKILL.md +4 -4
  176. package/src/templates/mate-skills/claude/mate-clarify-intent/SKILL.md +95 -0
  177. package/src/templates/mate-skills/claude/mate-explore-change/SKILL.md +50 -0
  178. package/src/templates/mate-skills/claude/mate-explore-change/references/openspec-explore.md +348 -0
  179. package/src/templates/mate-skills/claude/mate-grill-me/SKILL.md +3 -3
  180. package/src/templates/mate-skills/claude/mate-interview-me/SKILL.md +5 -5
  181. package/src/templates/mate-skills/claude/mate-propose-change/SKILL.md +103 -0
  182. package/src/templates/mate-skills/claude/mate-propose-change/references/openspec-propose.md +167 -0
  183. package/src/templates/mate-skills/claude/mate-propose-change/references/openspec-update-change.md +101 -0
  184. package/src/templates/mate-skills/claude/{mate-artifact-publish → mate-publish-artifact}/SKILL.md +91 -23
  185. package/src/templates/mate-skills/claude/{mate-artifact-publish → mate-publish-artifact}/references/openspec.md +4 -3
  186. package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +74 -16
  187. package/src/templates/mate-skills/claude/mate-simplify-code/SKILL.md +74 -68
  188. package/src/templates/mate-skills/openspec-references.ts +140 -0
  189. package/src/templates/root/TEMPLATE_AGENTS.md +2 -2
  190. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +3727 -320
  191. package/src/tools/setup/capabilities/context-mode.ts +25 -5
  192. package/src/tools/setup/capabilities/graphify-gate.ts +31 -0
  193. package/src/tools/setup/capabilities/graphify-secrets.ts +255 -0
  194. package/src/tools/setup/capabilities/graphify.ts +12 -0
  195. package/src/tools/setup/capabilities/openspec.ts +273 -270
  196. package/src/tools/setup/capabilities/tokensave.ts +36 -3
  197. package/src/tools/setup/context-services.ts +9 -17
  198. package/src/tools/setup/dynamic-plugins/audiences.ts +7 -39
  199. package/src/tools/setup/dynamic-plugins/declarations.ts +1 -1
  200. package/src/tools/setup/dynamic-plugins/hydrate.ts +4 -7
  201. package/src/tools/setup/dynamic-plugins/install.ts +24 -28
  202. package/src/tools/setup/engine.ts +93 -148
  203. package/src/tools/setup/managed-output.ts +51 -0
  204. package/src/tools/setup/mate.ts +86 -11
  205. package/src/tools/setup/plugin.ts +37 -17
  206. package/src/tools/setup/plugins/gitignore.ts +1 -7
  207. package/src/tools/setup/plugins/guidance.ts +7 -4
  208. package/src/tools/setup/providers/agent-file-sections.ts +4 -2
  209. package/src/tools/setup/providers/claude-attribution.ts +65 -0
  210. package/src/tools/setup/providers/claude-plugin-folders.ts +625 -0
  211. package/src/tools/setup/providers/claude-plugin-hooks.ts +39 -19
  212. package/src/tools/setup/providers/claude.ts +285 -34
  213. package/src/tools/setup/providers/opencode.ts +180 -11
  214. package/src/tools/setup/registry.ts +2 -24
  215. package/src/tools/setup/surface-target.ts +4 -13
  216. package/src/tools/setup/utils.ts +14 -4
  217. package/src/tools/setup.ts +131 -18
  218. package/wrappers/bin/graphify +52 -4
  219. package/wrappers/bin/openspec +33 -9
  220. package/wrappers/lib/graphify-gate.mjs +8 -0
  221. package/opencode-plugin/src/audience-layer.ts +0 -91
  222. package/src/cli/commands/studio/views/workflow/transcript.tsx +0 -190
  223. package/src/runtime/audience-layer.ts +0 -84
  224. package/src/runtime/opencode-audience-layer.ts +0 -176
  225. package/src/tools/setup/audience-isolation.fixture.ts +0 -325
  226. package/src/tools/setup/audience-layer.ts +0 -207
  227. package/src/tools/setup/audience-registrations.ts +0 -52
  228. package/src/tools/setup/contribution-ledger.ts +0 -207
  229. package/src/tools/setup/isolation-diagnostics.ts +0 -139
  230. package/src/tools/setup/runtime-documents.ts +0 -291
  231. package/src/tools/setup/upgrade-sweep.ts +0 -153
@@ -0,0 +1,55 @@
1
+ import type { DeliveryEnvironment } from "../../../../lib/delivery/environment";
2
+ import { sameRepository } from "../../../../lib/delivery/record";
3
+ import type { DeliveryEdit } from "../../../../lib/delivery/record-delivery";
4
+ import { type CliFlags, malformedValueFlag, stringFlag } from "../../../parse-flags";
5
+
6
+ /** `--branch` with `--direct`, or `--not-applicable`, as `artifact link` and `archive` take them. */
7
+ export interface BranchFlags {
8
+ branch?: string;
9
+ direct: boolean;
10
+ notApplicable?: string;
11
+ }
12
+
13
+ export function branchFlagsFrom(flags: CliFlags): BranchFlags {
14
+ return {
15
+ branch: stringFlag(flags.branch),
16
+ direct: flags.direct === true,
17
+ notApplicable: stringFlag(flags["not-applicable"]),
18
+ };
19
+ }
20
+
21
+ export const DIRECT_NEEDS_BRANCH = "mate: --direct records a direct delivery and needs --branch.";
22
+
23
+ /** The delivery edit the branch flags ask for; `--branch` wins over `--not-applicable`. */
24
+ export function deliveryEditFrom(flags: BranchFlags): DeliveryEdit["delivery"] {
25
+ if (flags.branch !== undefined) return { branch: flags.branch, direct: flags.direct };
26
+ if (flags.notApplicable !== undefined) return { notApplicable: flags.notApplicable };
27
+ return undefined;
28
+ }
29
+
30
+ /** Refuses a value flag given bare or repeated, which would otherwise read as absent. */
31
+ export function valueFlagError(flags: CliFlags, names: readonly string[]): string | undefined {
32
+ const name = malformedValueFlag(flags, names);
33
+ return name === undefined ? undefined : `mate: --${name} takes exactly one value.`;
34
+ }
35
+
36
+ /** `--repository <org/repo>`, or the refusal for a malformed one. */
37
+ export function repositoryFlag(flags: CliFlags): { repository?: string } | { error: string } {
38
+ const repository = stringFlag(flags.repository);
39
+ if (repository !== undefined && !/^[\w.-]+(?:\/[\w.-]+)+$/.test(repository)) {
40
+ return {
41
+ error:
42
+ "mate: --repository expects a repository path such as <org/repo> or <group/subgroup/repo>.",
43
+ };
44
+ }
45
+ return repository === undefined ? {} : { repository };
46
+ }
47
+
48
+ /** GitHub slugs are case-insensitive; the Working Repository's own spelling keeps one delivery. */
49
+ export function canonicalRepository(
50
+ environment: DeliveryEnvironment,
51
+ requested: string | undefined,
52
+ ): string | undefined {
53
+ const own = environment.workingRepo?.slug;
54
+ return own && requested !== undefined && sameRepository(requested, own) ? own : requested;
55
+ }
@@ -0,0 +1,104 @@
1
+ import {
2
+ workingRepoGit,
3
+ configuredProtectedPatterns,
4
+ type WorkingRepoGit,
5
+ } from "../../../../lib/delivery/branches";
6
+ import type { DeliveryEnvironment, WorkingRepoInfo } from "../../../../lib/delivery/environment";
7
+ import { parseRemote, repositoryPath, type ForgeRepo } from "../../../../lib/delivery/forge";
8
+ import { sameRepository } from "../../../../lib/delivery/record";
9
+ import { hasOpenspecCapability } from "../../../../lib/orchestrator/capabilities";
10
+ import {
11
+ resolveForCapability,
12
+ type LaunchContext,
13
+ } from "../../../../lib/orchestrator/framework-context";
14
+ import type { CapabilityConfig, LinkedRepository } from "../../../../lib/orchestrator/types";
15
+ import { WorkingRepoRequiredError } from "../../../../lib/orchestrator/types";
16
+ import { ensureUnambiguousCompanion } from "../../shared/companion-selection";
17
+
18
+ export interface DeliveryEnvironmentDeps {
19
+ ensureUnambiguousCompanion?: (cwd: string) => Promise<boolean>;
20
+ resolveContext?: (cwd: string) => Promise<LaunchContext>;
21
+ loadCapabilities?: (context: LaunchContext) => Promise<CapabilityConfig[]>;
22
+ workingRepoGit?: (repoPath: string) => WorkingRepoGit;
23
+ loadLinkedRepositories?: (context: LaunchContext) => Promise<LinkedRepository[]>;
24
+ }
25
+
26
+ async function defaultLoadLinkedRepositories(context: LaunchContext): Promise<LinkedRepository[]> {
27
+ return (await context.workingRepoStore.load()).repos;
28
+ }
29
+
30
+ async function defaultLoadCapabilities(context: LaunchContext): Promise<CapabilityConfig[]> {
31
+ const config = await context.configStore.load();
32
+ return config.capabilities ?? [];
33
+ }
34
+
35
+ /**
36
+ * The companion, the Working Repository with its remote identity, and the configured
37
+ * protected-branch patterns. `error` explains why the command cannot run; it is null
38
+ * when the companion check has already reported the problem.
39
+ */
40
+ export async function resolveDeliveryEnvironment(
41
+ deps: DeliveryEnvironmentDeps,
42
+ ): Promise<DeliveryEnvironment | { error: string | null }> {
43
+ const ensureCompanion = deps.ensureUnambiguousCompanion ?? ensureUnambiguousCompanion;
44
+ if (!(await ensureCompanion(process.cwd()))) return { error: null };
45
+
46
+ let context: LaunchContext;
47
+ try {
48
+ context = await (deps.resolveContext ?? ((cwd: string) => resolveForCapability(cwd)))(
49
+ process.cwd(),
50
+ );
51
+ } catch (err) {
52
+ if (err instanceof WorkingRepoRequiredError) return { error: err.message };
53
+ throw err;
54
+ }
55
+
56
+ const capabilities = await (deps.loadCapabilities ?? defaultLoadCapabilities)(context);
57
+ if (!hasOpenspecCapability(capabilities)) {
58
+ return { error: "mate: the openspec capability must be enabled to record delivery." };
59
+ }
60
+
61
+ const openGit = deps.workingRepoGit ?? workingRepoGit;
62
+ const repoPath = context.repository?.path ?? process.env.MATE_REPO_PATH;
63
+ let workingRepo: WorkingRepoInfo | null = null;
64
+ if (repoPath) {
65
+ const git = openGit(repoPath);
66
+ const remote = await git.remoteUrl();
67
+ const forgeRepo = remote ? parseRemote(remote) : null;
68
+ workingRepo = {
69
+ path: repoPath,
70
+ slug: forgeRepo ? repositoryPath(forgeRepo) : null,
71
+ forgeRepo,
72
+ git,
73
+ };
74
+ }
75
+
76
+ let linkedRemotes: Promise<ForgeRepo[]> | undefined;
77
+ // Read once, and only when a repository other than the Working Repository needs a host.
78
+ const linked = (): Promise<ForgeRepo[]> =>
79
+ (linkedRemotes ??= (async () => {
80
+ // An unreadable registry only loses host hints; the Working Repository's host remains.
81
+ const repositories = await (deps.loadLinkedRepositories ?? defaultLoadLinkedRepositories)(
82
+ context,
83
+ ).catch(() => []);
84
+ const remotes = await Promise.all(
85
+ repositories
86
+ .filter((repository) => repository.path !== repoPath)
87
+ .map((repository) => openGit(repository.path).remoteUrl()),
88
+ );
89
+ return remotes.flatMap((remote) => (remote ? (parseRemote(remote) ?? []) : []));
90
+ })());
91
+ const hostOf = async (repository: string): Promise<string | null> => {
92
+ const match = (await linked()).find((remote) =>
93
+ sameRepository(repositoryPath(remote), repository),
94
+ );
95
+ return match?.host ?? workingRepo?.forgeRepo?.host ?? null;
96
+ };
97
+
98
+ return {
99
+ companionPath: context.companionPath,
100
+ workingRepo,
101
+ protectedPatterns: configuredProtectedPatterns(capabilities),
102
+ hostOf,
103
+ };
104
+ }
@@ -0,0 +1,154 @@
1
+ import {
2
+ recordDelivery,
3
+ type DeliveryRefusal,
4
+ type DeliveryResult,
5
+ type RecordDeliveryDeps,
6
+ } from "../../../../lib/delivery/record-delivery";
7
+ import { applyWarnings, type DependencyWarning } from "../../../../lib/delivery/overlap";
8
+ import type { MergeState } from "../../../../lib/delivery/record";
9
+ import { type BooleanFlagSet, parseFlags, positionalArgs, stringFlag } from "../../../parse-flags";
10
+ import {
11
+ branchFlagsFrom,
12
+ canonicalRepository,
13
+ deliveryEditFrom,
14
+ DIRECT_NEEDS_BRANCH,
15
+ repositoryFlag,
16
+ valueFlagError,
17
+ } from "./branch-flags";
18
+ import { resolveDeliveryEnvironment, type DeliveryEnvironmentDeps } from "./environment";
19
+ import {
20
+ commandOutput,
21
+ renderDelivery,
22
+ sharedRefusalMessage,
23
+ unscopedDeliveryWarning,
24
+ } from "./output";
25
+
26
+ export interface LinkCommandDeps extends DeliveryEnvironmentDeps, RecordDeliveryDeps {
27
+ stdout?: (line: string) => void;
28
+ stderr?: (line: string) => void;
29
+ }
30
+
31
+ const BOOLEAN_FLAGS: BooleanFlagSet = new Set([
32
+ "json",
33
+ "refresh",
34
+ "direct",
35
+ "confirm-merged",
36
+ "confirm-open",
37
+ "confirm-dropped",
38
+ ]);
39
+
40
+ const CONFIRMATIONS: Record<string, MergeState> = {
41
+ "confirm-merged": "merged",
42
+ "confirm-open": "open",
43
+ "confirm-dropped": "closed-unmerged",
44
+ };
45
+
46
+ /** `warnings`: declared dependencies not implemented yet; they inform and never block. */
47
+ export type LinkResult = DeliveryResult & { warnings: DependencyWarning[] };
48
+
49
+ function refusalMessage(refusal: DeliveryRefusal): string {
50
+ if (refusal.reason === "unknown-change") {
51
+ return `mate: no active or archived change named "${refusal.change}".`;
52
+ }
53
+ return sharedRefusalMessage(refusal);
54
+ }
55
+
56
+ function renderHuman(result: LinkResult): string[] {
57
+ const lines = [`${result.change}: ${result.settlement.status}`];
58
+ lines.push(...result.record.deliveries.map(renderDelivery));
59
+ for (const repository of result.settlement.missing) {
60
+ lines.push(` ${repository}: no delivery recorded (named in the change's scopes)`);
61
+ }
62
+ for (const outcome of result.outcomes.filter((entry) => entry.needsAnswer)) {
63
+ lines.push(` ${outcome.branch}: needs an answer (${outcome.answerReason})`);
64
+ if (outcome.failure) lines.push(` ${outcome.failure.message}`);
65
+ for (const candidate of outcome.candidates ?? []) {
66
+ lines.push(` #${candidate.number} ${candidate.state} ${candidate.url}`);
67
+ }
68
+ }
69
+ return lines;
70
+ }
71
+
72
+ /**
73
+ * @command mate artifact link <change>...
74
+ * @description Writes a change's Delivery Record (`delivery.yaml`), with `artifact archive`
75
+ * the only writer of that file, for active and archived changes. Records the delivery branch or a
76
+ * not-applicable reason per Working Repository, an informational PR URL, merge results
77
+ * from the forge (`--refresh`), or the user's answer (`--confirm-*`, source `user`).
78
+ * @flags
79
+ * - `--repository <org/repo>` — the delivery to edit; defaults to the Working Repository.
80
+ * - `--branch <branch>` — record the delivery branch; a protected branch needs `--direct`.
81
+ * - `--direct` — with `--branch`, record a direct delivery, settled only by the user's answer; required for a protected branch.
82
+ * - `--not-applicable <reason>` — no branch delivers this change in the repository.
83
+ * - `--pr <url>` — record the PR URL; with `--refresh`, picks that PR among several.
84
+ * - `--refresh` — ask the forge for the merge state of each recorded branch; a direct delivery
85
+ * is reported as needing the user's answer.
86
+ * - `--confirm-merged` / `--confirm-open` / `--confirm-dropped` — the user's answer.
87
+ * - `--json` — emit an array of {@link LinkResult}, one per change.
88
+ * @remarks Warns, without blocking, about each `dependsOn` change that is neither archived nor
89
+ * fully implemented, since apply records the branch before writing code. Without an edit flag or `--refresh` it only reads the record and writes nothing.
90
+ * Network requests go only to the forge API: with `--refresh`, and with `--branch` for
91
+ * another repository's default branch. The forge token is never
92
+ * printed, logged, or written.
93
+ */
94
+ export async function runArtifactLinkCommand(
95
+ argv: string[],
96
+ deps: LinkCommandDeps = {},
97
+ ): Promise<void> {
98
+ const { emitOut, emitErr, refuse } = commandOutput(deps);
99
+ const flags = parseFlags(argv, BOOLEAN_FLAGS);
100
+ const flagError = valueFlagError(flags, ["repository", "branch", "not-applicable", "pr"]);
101
+ if (flagError) return refuse(flagError);
102
+ const changes = positionalArgs(argv, BOOLEAN_FLAGS);
103
+ const branchFlags = branchFlagsFrom(flags);
104
+ const { branch, notApplicable } = branchFlags;
105
+ const pr = stringFlag(flags.pr);
106
+ const confirmations = Object.keys(CONFIRMATIONS).filter((flag) => flags[flag] === true);
107
+
108
+ if (changes.length === 0) return refuse("mate: artifact link requires a change name.");
109
+ if (branch !== undefined && notApplicable !== undefined) {
110
+ return refuse("mate: --branch and --not-applicable exclude each other.");
111
+ }
112
+ if (confirmations.length > 1) {
113
+ return refuse("mate: pass one of --confirm-merged, --confirm-open, or --confirm-dropped.");
114
+ }
115
+ if (branchFlags.direct && branch === undefined) return refuse(DIRECT_NEEDS_BRANCH);
116
+ if (pr !== undefined && !/^https?:\/\/\S+$/.test(pr)) {
117
+ return refuse("mate: --pr expects the PR's URL.");
118
+ }
119
+ const requested = repositoryFlag(flags);
120
+ if ("error" in requested) return refuse(requested.error);
121
+
122
+ const environment = await resolveDeliveryEnvironment(deps);
123
+ if ("error" in environment) return refuse(environment.error);
124
+ const repository = canonicalRepository(environment, requested.repository);
125
+ const confirm = confirmations.length === 1 ? CONFIRMATIONS[confirmations[0]] : undefined;
126
+ const delivery = deliveryEditFrom(branchFlags);
127
+ const outcome = await recordDelivery(
128
+ environment,
129
+ {
130
+ changes,
131
+ ...(repository ? { repository } : {}),
132
+ edit: {
133
+ ...(delivery ? { delivery } : {}),
134
+ ...(pr !== undefined ? { pr } : {}),
135
+ ...(confirm ? { confirm } : {}),
136
+ },
137
+ refresh: flags.refresh === true,
138
+ includeArchived: true,
139
+ },
140
+ deps,
141
+ );
142
+ if ("refusal" in outcome) return refuse(refusalMessage(outcome.refusal));
143
+ const results: LinkResult[] = [];
144
+ for (const result of "results" in outcome ? outcome.results : []) {
145
+ const warnings = await applyWarnings(environment.companionPath, result.change);
146
+ for (const warning of warnings) emitErr(`warning: ${warning.message}`);
147
+ for (const entry of result.settlement.unscoped) {
148
+ emitErr(unscopedDeliveryWarning(result.change, entry));
149
+ }
150
+ results.push({ ...result, warnings });
151
+ }
152
+ if (flags.json === true) emitOut(JSON.stringify(results));
153
+ else for (const result of results) for (const line of renderHuman(result)) emitOut(line);
154
+ }
@@ -0,0 +1,58 @@
1
+ import type { Delivery, UnscopedDelivery } from "../../../../lib/delivery/record";
2
+ import type { DeliveryRefusal } from "../../../../lib/delivery/record-delivery";
3
+
4
+ export interface CommandOutput {
5
+ emitOut: (line: string) => void;
6
+ emitErr: (line: string) => void;
7
+ /** Writes `message`, when given, to stderr and sets a failing exit code. */
8
+ refuse: (message?: string | null) => void;
9
+ }
10
+
11
+ export function commandOutput(deps: {
12
+ stdout?: (line: string) => void;
13
+ stderr?: (line: string) => void;
14
+ }): CommandOutput {
15
+ const emitOut = deps.stdout ?? ((line: string) => process.stdout.write(`${line}\n`));
16
+ const emitErr = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
17
+ return {
18
+ emitOut,
19
+ emitErr,
20
+ refuse: (message) => {
21
+ if (message) emitErr(message);
22
+ process.exitCode = 1;
23
+ },
24
+ };
25
+ }
26
+
27
+ /** One delivery as `<repository> <branch or reason> <merge> <pr>`. */
28
+ export function renderDelivery(delivery: Delivery): string {
29
+ if (delivery.notApplicable !== undefined) {
30
+ return ` ${delivery.repository} not applicable: ${delivery.notApplicable}`;
31
+ }
32
+ const branch = `${delivery.branch ?? "?"}${delivery.direct ? " [direct]" : ""}`;
33
+ const merge = delivery.merge
34
+ ? `${delivery.merge.state} (${delivery.merge.source})`
35
+ : "no merge recorded";
36
+ const pr = delivery.pr ? ` ${delivery.pr}` : "";
37
+ return ` ${delivery.repository} ${branch} ${merge}${pr}`;
38
+ }
39
+
40
+ /** Wording for the refusals `link` and `archive` share; `unknown-change` names each command. */
41
+ export function sharedRefusalMessage(
42
+ refusal: Exclude<DeliveryRefusal, { reason: "unknown-change" }>,
43
+ ): string {
44
+ if (refusal.reason === "no-repository") {
45
+ return "mate: the Working Repository has no origin remote to identify it; pass --repository <org/repo>.";
46
+ }
47
+ if (refusal.reason === "protected-branch") {
48
+ return `mate: ${refusal.branch} is a protected branch (${refusal.protection}); record it only as an explicit direct delivery with --direct.`;
49
+ }
50
+ return refusal.for === "pr"
51
+ ? `mate: ${refusal.change} has no delivery branch for ${refusal.repository}; pass --branch first.`
52
+ : `mate: ${refusal.change} has no delivery branch for ${refusal.repository} to confirm.`;
53
+ }
54
+
55
+ export function unscopedDeliveryWarning(change: string, entry: UnscopedDelivery): string {
56
+ const hint = entry.closest ? `; did you mean ${entry.closest}?` : ".";
57
+ return `warning: ${entry.repository} matches none of ${change}'s scopes${hint}`;
58
+ }
@@ -8,8 +8,14 @@ import { discoverArchives, pendingArchives } from "../pending/discovery";
8
8
  import { type FinishResult, runFinishEngine } from "./engine";
9
9
  import type { ArtifactFinisher, FinishContext, FinisherFactory } from "./finisher";
10
10
  import { defaultGitOps, type GitOps } from "./git";
11
- import { openspecSpecsFinisher, resolveDriftedSpecs } from "./openspec";
11
+ import {
12
+ openspecSpecsFinisher,
13
+ publicationHold,
14
+ publicationHoldCache,
15
+ resolveDriftedSpecs,
16
+ } from "./openspec";
12
17
  import { DEFAULT_FINISHER_TYPE, knownFinisherTypes, selectFinisher } from "./registry";
18
+ import { type PublicationSecretScan, scanPublication } from "./secret-scan";
13
19
 
14
20
  export interface PublishCommandDeps {
15
21
  ensureUnambiguousCompanion?: (cwd: string) => Promise<boolean>;
@@ -21,6 +27,11 @@ export interface PublishCommandDeps {
21
27
  now?: () => Date;
22
28
  /** Injectable spec-publication finisher; mirrors `selectFinisher` for the other unit. */
23
29
  specsFinisher?: (context: FinishContext, paths: string[], now: () => Date) => ArtifactFinisher;
30
+ /** Injectable pre-commit secret scan over a publication's staged outputs. */
31
+ scanPublication?: (
32
+ companionPath: string,
33
+ commitPaths: string[],
34
+ ) => Promise<PublicationSecretScan>;
24
35
  stdout?: (line: string) => void;
25
36
  stderr?: (line: string) => void;
26
37
  }
@@ -99,7 +110,8 @@ function refusalResult(type: string, name: string, message: string): FinishResul
99
110
  * new `openspec/specs/<date>-<specs>` tag. Bare, it publishes every drifted spec; with paths, only
100
111
  * those. A bare name alongside it is a change target and is refused.
101
112
  * - `--all` — publish every pending change in discovery order, then one spec publication for
102
- * the drift that remains. Halts at the first conflict or error; with `--json` it emits a
113
+ * the drift that remains. Skips an archive the merge gate holds back, reporting why, and
114
+ * halts at the first conflict or error; with `--json` it emits a
103
115
  * single array of results in execution order.
104
116
  * - `--type <type>` — finisher type to use; see `knownFinisherTypes()`. `--specs` and
105
117
  * `--all` are openspec-only.
@@ -188,10 +200,14 @@ export async function runArtifactPublishCommand(
188
200
  const loadCapabilities = deps.loadCapabilities ?? defaultLoadCapabilities;
189
201
  const capabilities = await loadCapabilities(context);
190
202
 
203
+ let git: GitOps;
191
204
  const finishContext: FinishContext = {
192
205
  companionPath: context.companionPath,
193
206
  repositoryId: context.repositoryId,
194
207
  repository: context.repository,
208
+ // Resolution runs only after `git` is set below.
209
+ changedPaths: () => git.changedPaths(),
210
+ holdCache: publicationHoldCache(),
195
211
  };
196
212
  const finisher = finisherFactory(finishContext);
197
213
  if (!finisher.isEnabled(capabilities)) {
@@ -200,7 +216,6 @@ export async function runArtifactPublishCommand(
200
216
  return;
201
217
  }
202
218
 
203
- let git: GitOps;
204
219
  try {
205
220
  git = (deps.git ?? defaultGitOps)(
206
221
  context.companionPath,
@@ -220,11 +235,13 @@ export async function runArtifactPublishCommand(
220
235
  * suppressed; every other mode lets the engine report each publication as it finishes.
221
236
  */
222
237
  const quiet = allMode && json;
238
+ const scanPublicationFn = deps.scanPublication ?? scanPublication;
223
239
  const engineDeps = {
224
240
  git,
225
241
  json: !allMode && json,
226
242
  stdout: quiet ? (): void => {} : emitOut,
227
243
  stderr: quiet ? (): void => {} : emitErr,
244
+ secretScan: (commitPaths: string[]) => scanPublicationFn(context.companionPath, commitPaths),
228
245
  };
229
246
 
230
247
  const runChange = (target: string): Promise<FinishResult> =>
@@ -253,6 +270,19 @@ export async function runArtifactPublishCommand(
253
270
  const archives = await discoverArchives(context.companionPath, changed, changeKinds);
254
271
 
255
272
  for (const entry of pendingArchives(archives)) {
273
+ // A held-back archive waits for its merge; it does not stop the merged ones.
274
+ // Same cache as the finisher's resolve step, so both see one pending set and agree.
275
+ const hold = await publicationHold(
276
+ context.companionPath,
277
+ entry.anchor,
278
+ async () => changed,
279
+ finishContext.holdCache,
280
+ );
281
+ if (hold) {
282
+ if (!quiet) emitErr(hold);
283
+ results.push(nothingToPublish(entry.anchor, hold));
284
+ continue;
285
+ }
256
286
  const result = await runChange(entry.anchor);
257
287
  results.push(result);
258
288
  if (halted(result)) {
@@ -1,11 +1,14 @@
1
+ import { describeSecretFindings, type SecretFinding } from "../../../../runtime/secret-scan";
1
2
  import type { ArtifactFinisher, Produced } from "./finisher";
2
3
  import type { GitOps } from "./git";
4
+ import type { PublicationSecretScan } from "./secret-scan";
3
5
 
4
6
  /** Pipeline step a {@link FinishResult} refers to (generic across artifact kinds). */
5
7
  export type FinishStep =
6
8
  | "resolve"
7
9
  | "branch-guard"
8
10
  | "cap-sync"
11
+ | "secret-scan"
9
12
  | "commit"
10
13
  | "sync-remote"
11
14
  | "tag"
@@ -31,6 +34,10 @@ export interface FinishResult {
31
34
  conflictedPaths: string[];
32
35
  local: { committed: boolean; tagged: boolean; pushed: boolean };
33
36
  message: string;
37
+ /** Set when the secret scan stopped the publication: where and which rule, never the value. */
38
+ secretFindings?: SecretFinding[];
39
+ /** The finisher's warning about what it resolved; publication continued. */
40
+ warning?: string;
34
41
  }
35
42
 
36
43
  export interface EngineOptions {
@@ -43,13 +50,15 @@ export interface EngineDeps {
43
50
  json: boolean;
44
51
  stdout: (line: string) => void;
45
52
  stderr: (line: string) => void;
53
+ /** Scans the staged outputs for secrets; absent means no scan step. */
54
+ secretScan?: (commitPaths: string[]) => Promise<PublicationSecretScan>;
46
55
  }
47
56
 
48
57
  /**
49
58
  * 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.
59
+ * resolve → branch guard → cap sync → secret scan → scoped commit → sync-remote → tag →
60
+ * push. The git, remote-sync, and conflict-handoff machinery is identical for every
61
+ * artifact kind — only the finisher's resolution and cap sync vary.
53
62
  *
54
63
  * Isolation is a clean abort, not a rollback: the resolved artifact is durable input
55
64
  * the pipeline never produced, so no step resets, restores, or deletes a path.
@@ -111,6 +120,10 @@ export async function runFinishEngine(
111
120
  }
112
121
  const resolved: Produced = resolution.resolved;
113
122
  result.anchorName = resolved.anchorName;
123
+ if (resolved.warning) {
124
+ result.warning = resolved.warning;
125
+ if (!deps.json) deps.stderr(`warning: ${resolved.warning}`);
126
+ }
114
127
  const baseTag = `${resolved.tagNamespace ?? finisher.type}/${resolved.anchorName}`;
115
128
  // Provisional: a `suffix` publication only learns its final name at the tag
116
129
  // step, once the remote sync has revealed tags created elsewhere. Early exits
@@ -151,6 +164,37 @@ export async function runFinishEngine(
151
164
  }
152
165
  }
153
166
 
167
+ // Secret scan — the last pre-commit step, over exactly what the commit would stage.
168
+ // A finding or a scanner failure stops here like any pre-commit failure: no commit,
169
+ // no tag, no push, and the archive stays on disk.
170
+ if (deps.secretScan) {
171
+ result.step = "secret-scan";
172
+ let scan: PublicationSecretScan;
173
+ try {
174
+ scan = await deps.secretScan(resolved.commitPaths);
175
+ } catch (err) {
176
+ fail(
177
+ "secret-scan",
178
+ `mate: the secret scan failed (${String(err)}); nothing was committed — re-run \`mate artifact publish\` to retry.`,
179
+ );
180
+ return result;
181
+ }
182
+ if (scan.skipped) {
183
+ deps.stderr("mate: secret scan skipped (secrets.guardrails is false in framework.yaml).");
184
+ } else if (scan.findings.length > 0) {
185
+ result.secretFindings = scan.findings;
186
+ fail(
187
+ "secret-scan",
188
+ [
189
+ "mate: refusing to publish: the staged outputs contain secrets. Nothing was committed; the archive was left in place.",
190
+ ...describeSecretFindings(scan.findings).map((line) => ` ${line}`),
191
+ "Replace each value with a placeholder or variable name, then re-run `mate artifact publish`.",
192
+ ].join("\n"),
193
+ );
194
+ return result;
195
+ }
196
+ }
197
+
154
198
  // Commit — stage only the finisher's scoped paths. Nothing to stage means a prior
155
199
  // publication already committed this anchor, which is a resume rather than an error.
156
200
  result.step = "commit";
@@ -1,9 +1,17 @@
1
1
  import type { CapabilityConfig, LinkedRepository } from "../../../../lib/orchestrator/types";
2
+ import type { PublicationHoldCache } from "./openspec";
2
3
 
3
4
  export interface FinishContext {
4
5
  companionPath: string;
5
6
  repositoryId: string;
6
7
  repository?: LinkedRepository;
8
+ /**
9
+ * The companion's uncommitted paths (porcelain). Without it, publication cannot tell
10
+ * which other archives are pending and skips the shared-spec check.
11
+ */
12
+ changedPaths?: () => Promise<string[]>;
13
+ /** Shared by every publication of one run; a fresh memo per call without it. */
14
+ holdCache?: PublicationHoldCache;
7
15
  }
8
16
 
9
17
  /**
@@ -26,6 +34,8 @@ export interface Produced {
26
34
  * run reuses under either value: its commit is the tagged one already.
27
35
  */
28
36
  tagCollision?: "reuse" | "suffix";
37
+ /** Published anyway, but reported: e.g. a legacy archive whose merge was not checked. */
38
+ warning?: string;
29
39
  }
30
40
 
31
41
  /** Outcome of {@link ArtifactFinisher.resolve}; the failure carries the user-facing refusal. */