@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,108 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ import type { CapabilityConfig } from "../orchestrator/types";
5
+
6
+ const execFileAsync = promisify(execFile);
7
+
8
+ export type ProtectionReason = "default" | "pattern";
9
+
10
+ export interface BranchProtection {
11
+ protected: boolean;
12
+ reason: ProtectionReason | null;
13
+ }
14
+
15
+ export interface ProtectionOptions {
16
+ defaultBranch: string;
17
+ /** Configured glob patterns; `*` stays within one path segment, `**` crosses segments. */
18
+ patterns: readonly string[];
19
+ }
20
+
21
+ function globToRegExp(pattern: string): RegExp {
22
+ const source = pattern
23
+ .split("**")
24
+ .map((part) =>
25
+ part
26
+ .split("*")
27
+ .map((literal) => literal.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
28
+ .join("[^/]*"),
29
+ )
30
+ .join(".*");
31
+ return new RegExp(`^${source}$`);
32
+ }
33
+
34
+ /** A branch is protected when it is the default branch or matches a pattern. */
35
+ export function classifyBranch(branch: string, options: ProtectionOptions): BranchProtection {
36
+ if (branch === options.defaultBranch) return { protected: true, reason: "default" };
37
+ if (options.patterns.some((pattern) => globToRegExp(pattern).test(branch))) {
38
+ return { protected: true, reason: "pattern" };
39
+ }
40
+ return { protected: false, reason: null };
41
+ }
42
+
43
+ /** Protected-branch patterns from the companion's openspec capability config. */
44
+ export function configuredProtectedPatterns(capabilities: readonly CapabilityConfig[]): string[] {
45
+ const openspec = capabilities.find((capability) => capability.name === "openspec");
46
+ const patterns = openspec?.protectedBranches;
47
+ return Array.isArray(patterns)
48
+ ? patterns.filter((pattern): pattern is string => typeof pattern === "string")
49
+ : [];
50
+ }
51
+
52
+ /** Read-only local Git queries against a Working Repository. Nothing here touches the network. */
53
+ export interface WorkingRepoGit {
54
+ /** Checked-out branch, or null in a detached HEAD. */
55
+ currentBranch(): Promise<string | null>;
56
+ /** `origin/HEAD`, then an existing `origin/main` or `origin/master`, then `init.defaultBranch`, then `main`. */
57
+ defaultBranch(): Promise<string>;
58
+ /** The `origin` remote URL, or null without one. */
59
+ remoteUrl(): Promise<string | null>;
60
+ /** One-line summaries of commits on HEAD that no remote-tracking branch contains. */
61
+ unpushedCommits(): Promise<string[]>;
62
+ /** True when the branch exists locally or as `origin/<branch>`. */
63
+ branchExists(branch: string): Promise<boolean>;
64
+ }
65
+
66
+ export function workingRepoGit(repoPath: string): WorkingRepoGit {
67
+ const run = async (...args: string[]): Promise<string | null> => {
68
+ try {
69
+ const { stdout } = await execFileAsync("git", args, { cwd: repoPath });
70
+ return stdout.trim();
71
+ } catch {
72
+ return null;
73
+ }
74
+ };
75
+
76
+ const remoteTip = async (branch: string): Promise<string | null> =>
77
+ (await run("rev-parse", "--verify", "--quiet", `refs/remotes/origin/${branch}`)) || null;
78
+ const defaultBranch = async (): Promise<string> => {
79
+ const remoteHead = await run("symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD");
80
+ if (remoteHead) return remoteHead.replace(/^origin\//, "");
81
+ for (const candidate of ["main", "master"]) {
82
+ // oxlint-disable-next-line no-await-in-loop -- the steps are a decision chain
83
+ if (await remoteTip(candidate)) return candidate;
84
+ }
85
+ return (await run("config", "--get", "init.defaultBranch")) || "main";
86
+ };
87
+
88
+ const currentBranch = async (): Promise<string | null> =>
89
+ (await run("symbolic-ref", "--quiet", "--short", "HEAD")) || null;
90
+
91
+ return {
92
+ currentBranch,
93
+ defaultBranch,
94
+ async remoteUrl() {
95
+ return (await run("remote", "get-url", "origin")) || null;
96
+ },
97
+ async unpushedCommits() {
98
+ const log = await run("log", "--oneline", "HEAD", "--not", "--remotes");
99
+ return log ? log.split("\n").filter(Boolean) : [];
100
+ },
101
+ async branchExists(branch: string) {
102
+ for (const ref of [`refs/heads/${branch}`, `refs/remotes/origin/${branch}`]) {
103
+ if ((await run("rev-parse", "--verify", "--quiet", ref)) !== null) return true;
104
+ }
105
+ return false;
106
+ },
107
+ };
108
+ }
@@ -0,0 +1,29 @@
1
+ import path from "node:path";
2
+
3
+ import type { WorkingRepoGit } from "./branches";
4
+ import type { ForgeRepo } from "./forge";
5
+
6
+ export interface WorkingRepoInfo {
7
+ path: string;
8
+ /** Full repository path from the `origin` remote (`acme/platform/api`), or null without one. */
9
+ slug: string | null;
10
+ forgeRepo: ForgeRepo | null;
11
+ git: WorkingRepoGit;
12
+ }
13
+
14
+ /** The companion, the Working Repository with its remote identity, and protected-branch patterns. */
15
+ export interface DeliveryEnvironment {
16
+ companionPath: string;
17
+ workingRepo: WorkingRepoInfo | null;
18
+ protectedPatterns: string[];
19
+ /**
20
+ * Host of a repository other than the Working Repository, from a linked checkout's
21
+ * `origin` remote, else the Working Repository's host; null when neither is known.
22
+ */
23
+ hostOf?: (repository: string) => Promise<string | null>;
24
+ }
25
+
26
+ /** `dir` relative to the companion, with forward slashes. */
27
+ export function companionRelative(environment: DeliveryEnvironment, dir: string): string {
28
+ return path.relative(environment.companionPath, dir).split(path.sep).join("/");
29
+ }
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Forge adapters answer read-only questions about a repository and its Delivery Branch:
3
+ * the default branch, whether the branch is protected, and which PR has it as head. GitHub (github.com) is the only adapter; every other host
4
+ * falls back to asking the user. Every failure is returned, never thrown, and the token
5
+ * is scrubbed from anything a failure carries.
6
+ */
7
+
8
+ import { errorMessage } from "../../runtime/error-message";
9
+ import { pickProvenance, type MergeProvenance, type MergeState } from "./record";
10
+
11
+ export interface ForgeRepo {
12
+ host: string;
13
+ /** Every path segment before the repository; nested groups keep theirs (`acme/platform`). */
14
+ owner: string;
15
+ repo: string;
16
+ }
17
+
18
+ export interface PullCandidate {
19
+ number: number;
20
+ url: string;
21
+ state: MergeState;
22
+ }
23
+
24
+ export interface PullDetail extends MergeProvenance {
25
+ number: number;
26
+ url: string;
27
+ }
28
+
29
+ export type PullClassification =
30
+ | { kind: "merged"; pr: PullDetail }
31
+ | { kind: "open"; pr: PullDetail }
32
+ | { kind: "closed-unmerged"; pr: PullDetail }
33
+ | { kind: "ambiguous"; candidates: PullCandidate[] }
34
+ | { kind: "none" };
35
+
36
+ export type ForgeFailureReason = "permission" | "rate-limit" | "auth" | "network" | "unexpected";
37
+
38
+ export interface ForgeFailure {
39
+ reason: ForgeFailureReason;
40
+ message: string;
41
+ /** The fine-grained read permission a `permission` failure lacks. */
42
+ permission?: string;
43
+ /** The HTTP status the forge answered, when it answered. */
44
+ status?: number;
45
+ }
46
+
47
+ export type ForgeOutcome<T> = { ok: true; value: T } | { ok: false; failure: ForgeFailure };
48
+
49
+ export interface ForgeAdapter {
50
+ id: string;
51
+ defaultBranch(repo: ForgeRepo): Promise<ForgeOutcome<string>>;
52
+ /** Classifies the PRs whose head is `branch`; `prNumber` picks one of several. */
53
+ pullsForBranch(
54
+ repo: ForgeRepo,
55
+ branch: string,
56
+ prNumber?: number,
57
+ ): Promise<ForgeOutcome<PullClassification>>;
58
+ }
59
+
60
+ export type FetchLike = (
61
+ url: string,
62
+ init?: { headers?: Record<string, string> },
63
+ ) => Promise<Response>;
64
+
65
+ export interface ForgeDeps {
66
+ fetch?: FetchLike;
67
+ }
68
+
69
+ /** Parses SSH, scp-style, and HTTPS remotes; credentials in a URL are discarded. */
70
+ export function parseRemote(url: string): ForgeRepo | null {
71
+ const cleaned = url.trim();
72
+ let host: string;
73
+ let remotePath: string;
74
+ if (/^[a-z][a-z\d+.-]*:\/\//i.test(cleaned)) {
75
+ try {
76
+ const parsed = new URL(cleaned);
77
+ host = parsed.hostname;
78
+ remotePath = parsed.pathname;
79
+ } catch {
80
+ return null;
81
+ }
82
+ } else {
83
+ // `user@` is optional: an SSH config alias such as `work:org/repo` has none.
84
+ const scp = /^(?:[^/\s@]+@)?([^/\s:]{2,}):(.+)$/.exec(cleaned);
85
+ if (!scp) return null;
86
+ host = scp[1];
87
+ remotePath = scp[2];
88
+ }
89
+ if (!host) return null;
90
+ return forgeRepoOf(host, remotePath.replace(/\.git\/?$/, ""));
91
+ }
92
+
93
+ /** Splits a repository path at its last segment; null below two segments. */
94
+ export function forgeRepoOf(host: string, path: string): ForgeRepo | null {
95
+ const parts = path.split("/").filter(Boolean);
96
+ if (parts.length < 2) return null;
97
+ return {
98
+ host: host.toLowerCase(),
99
+ owner: parts.slice(0, -1).join("/"),
100
+ repo: parts.at(-1) as string,
101
+ };
102
+ }
103
+
104
+ /** The repository's identity: its full path on the host, `owner/repo`. */
105
+ export function repositoryPath(repo: ForgeRepo): string {
106
+ return `${repo.owner}/${repo.repo}`;
107
+ }
108
+
109
+ /** The adapter for a remote's host, or null when the host has none. */
110
+ export function forgeFor(
111
+ repo: ForgeRepo,
112
+ token: string,
113
+ deps: ForgeDeps = {},
114
+ ): ForgeAdapter | null {
115
+ return repo.host === "github.com" ? githubForge(token, deps) : null;
116
+ }
117
+
118
+ /** Replaces every occurrence of the token, so no failure text can carry it. */
119
+ function redactToken(text: string, token: string): string {
120
+ return token ? text.split(token).join("[redacted]") : text;
121
+ }
122
+
123
+ const GITHUB_API = "https://api.github.com";
124
+
125
+ interface GithubPull {
126
+ number: number;
127
+ html_url: string;
128
+ state: string;
129
+ merged_at: string | null;
130
+ merge_commit_sha?: string | null;
131
+ merged_by?: { login?: string } | null;
132
+ }
133
+
134
+ function candidateState(pull: GithubPull): PullCandidate["state"] {
135
+ if (pull.merged_at) return "merged";
136
+ return pull.state === "open" ? "open" : "closed-unmerged";
137
+ }
138
+
139
+ function detail(pull: GithubPull): PullDetail {
140
+ return {
141
+ number: pull.number,
142
+ url: pull.html_url,
143
+ ...pickProvenance({
144
+ mergedAt: pull.merged_at ?? undefined,
145
+ mergeCommit: (pull.merged_at && pull.merge_commit_sha) || undefined,
146
+ mergedBy: pull.merged_by?.login,
147
+ }),
148
+ };
149
+ }
150
+
151
+ function repoPath(repo: ForgeRepo): string {
152
+ return `/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.repo)}`;
153
+ }
154
+
155
+ export function githubForge(token: string, deps: ForgeDeps = {}): ForgeAdapter {
156
+ const fetchFn: FetchLike = deps.fetch ?? ((url, init) => fetch(url, init));
157
+
158
+ async function get<T>(apiPath: string, permission: string): Promise<ForgeOutcome<T>> {
159
+ let response: Response;
160
+ try {
161
+ response = await fetchFn(`${GITHUB_API}${apiPath}`, {
162
+ headers: {
163
+ accept: "application/vnd.github+json",
164
+ authorization: `Bearer ${token}`,
165
+ "x-github-api-version": "2022-11-28",
166
+ "user-agent": "mate-cli",
167
+ },
168
+ });
169
+ } catch (error) {
170
+ return {
171
+ ok: false,
172
+ failure: {
173
+ reason: "network",
174
+ message: redactToken(`GitHub request failed: ${errorMessage(error)}`, token),
175
+ },
176
+ };
177
+ }
178
+ const fail = (
179
+ reason: ForgeFailureReason,
180
+ message: string,
181
+ extra: Pick<ForgeFailure, "permission"> = {},
182
+ ): ForgeOutcome<T> => ({
183
+ ok: false,
184
+ failure: {
185
+ reason,
186
+ message: redactToken(message, token),
187
+ ...extra,
188
+ ...(response.ok ? {} : { status: response.status }),
189
+ },
190
+ });
191
+ if (response.ok) {
192
+ try {
193
+ return { ok: true, value: (await response.json()) as T };
194
+ } catch (error) {
195
+ return fail(
196
+ "unexpected",
197
+ `GitHub answered with an unreadable body: ${errorMessage(error)}`,
198
+ );
199
+ }
200
+ }
201
+ if (response.status === 401) return fail("auth", "GitHub rejected the token (401).");
202
+ if (response.status === 403 && response.headers.get("x-ratelimit-remaining") === "0") {
203
+ return fail("rate-limit", "GitHub API rate limit reached (403).");
204
+ }
205
+ if (response.status === 403 || response.status === 404) {
206
+ return fail(
207
+ "permission",
208
+ `GitHub refused the request (${response.status}); the token needs ${permission} on this repository.`,
209
+ { permission },
210
+ );
211
+ }
212
+ return fail("unexpected", `GitHub answered ${response.status}.`);
213
+ }
214
+
215
+ return {
216
+ id: "github",
217
+ async defaultBranch(repo) {
218
+ const result = await get<{ default_branch?: string }>(repoPath(repo), "Metadata: read");
219
+ if (!result.ok) return result;
220
+ const branch = result.value.default_branch;
221
+ return branch
222
+ ? { ok: true, value: branch }
223
+ : {
224
+ ok: false,
225
+ failure: { reason: "unexpected", message: "GitHub named no default branch." },
226
+ };
227
+ },
228
+ async pullsForBranch(repo, branch, prNumber) {
229
+ const head = encodeURIComponent(`${repo.owner}:${branch}`);
230
+ const listPulls = (target: string) =>
231
+ get<GithubPull[]>(
232
+ `${target}/pulls?head=${head}&state=all&per_page=100`,
233
+ "Pull requests: read",
234
+ );
235
+ let listed = await listPulls(repoPath(repo));
236
+ if (!listed.ok) return listed;
237
+ let pullRepo = repoPath(repo);
238
+ if (listed.value.length === 0) {
239
+ // A fork's PR is opened on its parent; the head stays `<fork owner>:<branch>`.
240
+ const metadata = await get<{ parent?: { full_name?: string } }>(
241
+ repoPath(repo),
242
+ "Metadata: read",
243
+ );
244
+ const parent = metadata.ok ? metadata.value.parent?.full_name : undefined;
245
+ if (parent) {
246
+ pullRepo = `/repos/${parent.split("/").map(encodeURIComponent).join("/")}`;
247
+ listed = await listPulls(pullRepo);
248
+ if (!listed.ok) return listed;
249
+ }
250
+ }
251
+ const pulls =
252
+ prNumber === undefined
253
+ ? listed.value
254
+ : listed.value.filter((entry) => entry.number === prNumber);
255
+ if (pulls.length === 0) return { ok: true, value: { kind: "none" } };
256
+ if (pulls.length > 1) {
257
+ return {
258
+ ok: true,
259
+ value: {
260
+ kind: "ambiguous",
261
+ candidates: pulls
262
+ .map((entry) => ({
263
+ number: entry.number,
264
+ url: entry.html_url,
265
+ state: candidateState(entry),
266
+ }))
267
+ .toSorted((a, b) => a.number - b.number),
268
+ },
269
+ };
270
+ }
271
+ const [only] = pulls;
272
+ const state = candidateState(only);
273
+ if (state !== "merged") return { ok: true, value: { kind: state, pr: detail(only) } };
274
+ // The list omits the merger; only the single-PR endpoint carries it.
275
+ const full = await get<GithubPull>(`${pullRepo}/pulls/${only.number}`, "Pull requests: read");
276
+ return { ok: true, value: { kind: "merged", pr: detail(full.ok ? full.value : only) } };
277
+ },
278
+ };
279
+ }
@@ -0,0 +1,202 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import {
5
+ activeChanges,
6
+ archiveAnchors,
7
+ changeNameOf,
8
+ changesDirOf,
9
+ deltaSpecs,
10
+ } from "../change-layout";
11
+ import { readFrontmatter } from "../frontmatter";
12
+ import { isMateSchema } from "./stages";
13
+
14
+ export interface RequirementEdit {
15
+ capability: string;
16
+ requirement: string;
17
+ operation: "MODIFIED" | "REMOVED";
18
+ }
19
+
20
+ export type DependencyWarning = { kind: "dependency"; change: string; message: string };
21
+
22
+ export type PlanningWarning = {
23
+ kind: "incomplete-planning";
24
+ change: string;
25
+ artifacts: string[];
26
+ message: string;
27
+ };
28
+
29
+ export type ArchiveWarning =
30
+ | { kind: "overlap"; change: string; capability: string; requirement: string; message: string }
31
+ | DependencyWarning
32
+ | PlanningWarning;
33
+
34
+ const SECTION = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\s*$/;
35
+ const REQUIREMENT = /^###\s+Requirement:\s*(.+?)\s*$/;
36
+ const TASK = /^\s*[-*]\s+\[([ xX])\]/;
37
+
38
+ /** Requirements a change's delta specs MODIFY or REMOVE, by full capability path. */
39
+ export async function deltaRequirementEdits(changeDir: string): Promise<RequirementEdit[]> {
40
+ const edits: RequirementEdit[] = [];
41
+ for (const { file, capability } of await deltaSpecs(changeDir)) {
42
+ if (capability === null) continue;
43
+ let source: string;
44
+ try {
45
+ source = await fs.readFile(path.join(changeDir, "specs", file), "utf8");
46
+ } catch {
47
+ continue;
48
+ }
49
+ let section: string | null = null;
50
+ for (const line of source.split(/\r?\n/)) {
51
+ const heading = SECTION.exec(line);
52
+ if (heading) {
53
+ section = heading[1];
54
+ continue;
55
+ }
56
+ if (line.startsWith("## ")) section = null;
57
+ const requirement = REQUIREMENT.exec(line);
58
+ if (requirement && (section === "MODIFIED" || section === "REMOVED")) {
59
+ edits.push({ capability, requirement: requirement[1], operation: section });
60
+ }
61
+ }
62
+ }
63
+ return edits;
64
+ }
65
+
66
+ /** Change names listed under `dependsOn` in any of the change's artifact frontmatter. */
67
+ export async function declaredDependencies(changeDir: string): Promise<string[]> {
68
+ let files: string[];
69
+ try {
70
+ files = (await fs.readdir(changeDir)).filter((file) => file.endsWith(".md"));
71
+ } catch {
72
+ return [];
73
+ }
74
+ const names = new Set<string>();
75
+ for (const file of files) {
76
+ // A malformed header declares nothing; validation reports it elsewhere.
77
+ const dependsOn = readFrontmatter(
78
+ await fs.readFile(path.join(changeDir, file), "utf8"),
79
+ )?.dependsOn;
80
+ for (const name of Array.isArray(dependsOn) ? dependsOn : [dependsOn]) {
81
+ if (typeof name === "string" && name.trim()) names.add(name.trim());
82
+ }
83
+ }
84
+ return [...names].toSorted();
85
+ }
86
+
87
+ async function archivedChangeNames(companionPath: string): Promise<Set<string>> {
88
+ return new Set((await archiveAnchors(companionPath)).map(changeNameOf));
89
+ }
90
+
91
+ /** True when the change's `tasks.md` has tasks and every one is checked. */
92
+ async function tasksComplete(changeDir: string): Promise<boolean> {
93
+ let source: string;
94
+ try {
95
+ source = await fs.readFile(path.join(changeDir, "tasks.md"), "utf8");
96
+ } catch {
97
+ return false;
98
+ }
99
+ const marks = source.split(/\r?\n/).flatMap((line) => TASK.exec(line)?.[1] ?? []);
100
+ return marks.length > 0 && marks.every((mark) => mark !== " ");
101
+ }
102
+
103
+ /**
104
+ * Warnings shown before applying `change`: declared dependencies neither archived nor fully
105
+ * implemented, so the change would be built without the code it builds on. Never blocks.
106
+ */
107
+ export async function applyWarnings(
108
+ companionPath: string,
109
+ change: string,
110
+ ): Promise<DependencyWarning[]> {
111
+ const changesDir = changesDirOf(companionPath);
112
+ const archived = await archivedChangeNames(companionPath);
113
+ const warnings: DependencyWarning[] = [];
114
+ for (const dependency of await declaredDependencies(path.join(changesDir, change))) {
115
+ if (archived.has(dependency) || (await tasksComplete(path.join(changesDir, dependency)))) {
116
+ continue;
117
+ }
118
+ warnings.push({
119
+ kind: "dependency",
120
+ change: dependency,
121
+ message: `${change} depends on ${dependency}, which is not implemented yet; apply ${dependency} first.`,
122
+ });
123
+ }
124
+ return warnings;
125
+ }
126
+
127
+ /**
128
+ * Warning for schema artifacts that `openspec status --change <change> --json` reports neither
129
+ * done nor skipped, e.g. a design missing after a `mate-minimal` change moved to `mate-v1`.
130
+ * Only Mate's schemas require every artifact; OpenSpec reports another schema's conditional
131
+ * artifacts (spec-driven's design) as `ready`, so those yield no warning, as does unparsable output.
132
+ */
133
+ export function planningWarning(change: string, statusJson: string): PlanningWarning | null {
134
+ let status: { schemaName?: unknown; artifacts?: unknown };
135
+ try {
136
+ status = JSON.parse(statusJson) as typeof status;
137
+ } catch {
138
+ return null;
139
+ }
140
+ const { schemaName, artifacts } = status ?? {};
141
+ if (typeof schemaName !== "string" || !isMateSchema(schemaName)) return null;
142
+ if (!Array.isArray(artifacts)) return null;
143
+ const missing = artifacts
144
+ .filter((artifact): artifact is { id: string; status?: unknown } => {
145
+ return typeof artifact?.id === "string";
146
+ })
147
+ .filter(({ status }) => status !== "done" && status !== "skipped")
148
+ .map(({ id }) => id);
149
+ if (missing.length === 0) return null;
150
+ return {
151
+ kind: "incomplete-planning",
152
+ change,
153
+ artifacts: missing,
154
+ message: `${change} is missing planning artifacts its schema requires: ${missing.join(", ")}.`,
155
+ };
156
+ }
157
+
158
+ function requirementKey(edit: RequirementEdit): string {
159
+ return JSON.stringify([edit.capability, edit.requirement]);
160
+ }
161
+
162
+ /**
163
+ * Warnings shown before archiving `change`: another active change MODIFYing or REMOVing a
164
+ * requirement this change also MODIFIES or REMOVES, and declared dependencies that are not
165
+ * archived yet. Warnings inform; they never block archive.
166
+ */
167
+ export async function archiveWarnings(
168
+ companionPath: string,
169
+ change: string,
170
+ ): Promise<ArchiveWarning[]> {
171
+ const changesDir = changesDirOf(companionPath);
172
+ const warnings: ArchiveWarning[] = [];
173
+
174
+ const own = await deltaRequirementEdits(path.join(changesDir, change));
175
+ if (own.length > 0) {
176
+ const ownKeys = new Set(own.map(requirementKey));
177
+ for (const other of await activeChanges(companionPath)) {
178
+ if (other === change) continue;
179
+ for (const edit of await deltaRequirementEdits(path.join(changesDir, other))) {
180
+ if (!ownKeys.has(requirementKey(edit))) continue;
181
+ warnings.push({
182
+ kind: "overlap",
183
+ change: other,
184
+ capability: edit.capability,
185
+ requirement: edit.requirement,
186
+ message: `${other} also changes ${edit.capability} › ${edit.requirement}; archiving ${other} later will replace this change's version of that requirement.`,
187
+ });
188
+ }
189
+ }
190
+ }
191
+
192
+ const archived = await archivedChangeNames(companionPath);
193
+ for (const dependency of await declaredDependencies(path.join(changesDir, change))) {
194
+ if (archived.has(dependency)) continue;
195
+ warnings.push({
196
+ kind: "dependency",
197
+ change: dependency,
198
+ message: `${change} depends on ${dependency}, which is not archived yet; archive ${dependency} first.`,
199
+ });
200
+ }
201
+ return warnings;
202
+ }