@uniqbit/mate-core 0.18.5 → 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 (209) hide show
  1. package/claude-plugin/hooks/companion-instructions.mjs +9 -0
  2. package/claude-plugin/hooks/hooks.json +12 -2
  3. package/package.json +10 -2
  4. package/src/brand/art.generated.ts +69 -0
  5. package/src/brand/index.ts +12 -0
  6. package/src/brand/palette.ts +19 -0
  7. package/src/brand/svg.generated.ts +8 -0
  8. package/src/brand/terminal.ts +83 -0
  9. package/src/brand/types.ts +33 -0
  10. package/src/cli/commands/artifact/artifact.ts +8 -0
  11. package/src/cli/commands/artifact/delivery/archive.ts +117 -0
  12. package/src/cli/commands/artifact/delivery/branch-flags.ts +55 -0
  13. package/src/cli/commands/artifact/delivery/environment.ts +104 -0
  14. package/src/cli/commands/artifact/delivery/link.ts +154 -0
  15. package/src/cli/commands/artifact/delivery/output.ts +58 -0
  16. package/src/cli/commands/artifact/finish/command.ts +33 -3
  17. package/src/cli/commands/artifact/finish/engine.ts +47 -3
  18. package/src/cli/commands/artifact/finish/finisher.ts +10 -0
  19. package/src/cli/commands/artifact/finish/openspec.ts +177 -138
  20. package/src/cli/commands/artifact/finish/secret-scan.ts +43 -0
  21. package/src/cli/commands/artifact/pending/command.ts +31 -3
  22. package/src/cli/commands/artifact/pending/discovery.ts +21 -57
  23. package/src/cli/commands/cap/graphify.ts +52 -3
  24. package/src/cli/commands/cap/index-cmd.ts +8 -1
  25. package/src/cli/commands/cap/openspec.ts +13 -25
  26. package/src/cli/commands/companion/open.ts +56 -39
  27. package/src/cli/commands/companion/update.ts +75 -42
  28. package/src/cli/commands/doctor.ts +176 -3
  29. package/src/cli/commands/plugin/install.ts +9 -4
  30. package/src/cli/commands/plugin/plugin.ts +3 -2
  31. package/src/cli/commands/report/adapter.ts +11 -2
  32. package/src/cli/commands/report/contract.ts +21 -0
  33. package/src/cli/commands/report/highlight.ts +34 -4
  34. package/src/cli/commands/report/markdown.ts +110 -0
  35. package/src/cli/commands/report/palette.ts +118 -0
  36. package/src/cli/commands/report/renderer.ts +456 -153
  37. package/src/cli/commands/report/types.ts +16 -0
  38. package/src/cli/commands/setup.ts +7 -15
  39. package/src/cli/commands/studio/client-asset.ts +16 -0
  40. package/src/cli/commands/studio/openspec-cli.ts +10 -58
  41. package/src/cli/commands/studio/routes.ts +2 -0
  42. package/src/cli/commands/studio/selection.ts +19 -1
  43. package/src/cli/commands/studio/server.ts +42 -1
  44. package/src/cli/commands/studio/views/client.ts +6 -616
  45. package/src/cli/commands/studio/views/dashboard/changes.tsx +5 -10
  46. package/src/cli/commands/studio/views/document.tsx +137 -69
  47. package/src/cli/commands/studio/views/markdown.tsx +250 -0
  48. package/src/cli/commands/studio/views/palette.ts +134 -0
  49. package/src/cli/commands/studio/views/reports/index.tsx +2 -7
  50. package/src/cli/commands/studio/views/skills/index.tsx +43 -62
  51. package/src/cli/commands/studio/views/specs/index.tsx +7 -15
  52. package/src/cli/commands/studio/views/studio-client/actions.ts +51 -0
  53. package/src/cli/commands/studio/views/studio-client/companion.ts +13 -0
  54. package/src/cli/commands/studio/views/studio-client/index.ts +32 -0
  55. package/src/cli/commands/studio/views/studio-client/keys.ts +3 -0
  56. package/src/cli/commands/studio/views/studio-client/main.ts +3 -0
  57. package/src/cli/commands/studio/views/studio-client/page.ts +146 -0
  58. package/src/cli/commands/studio/views/studio-client/panels.ts +57 -0
  59. package/src/cli/commands/studio/views/studio-client/reports.ts +122 -0
  60. package/src/cli/commands/studio/views/studio-client/theme.ts +63 -0
  61. package/src/cli/commands/studio/views/studio-client/vault-editor.ts +166 -0
  62. package/src/cli/commands/studio/views/studio-client/vault-pane.ts +202 -0
  63. package/src/cli/commands/studio/views/studio-client/vault-tree.ts +258 -0
  64. package/src/cli/commands/studio/views/studio-client.generated.ts +2 -0
  65. package/src/cli/commands/studio/views/styles.ts +217 -206
  66. package/src/cli/commands/studio/views/vault/state.ts +3 -5
  67. package/src/cli/commands/studio/views/workflow/delivery-path.tsx +370 -0
  68. package/src/cli/commands/studio/views/workflow/graph.ts +220 -0
  69. package/src/cli/commands/studio/views/workflow/index.tsx +2 -3
  70. package/src/cli/commands/studio/views/workflow/steps.ts +228 -287
  71. package/src/cli/commands/wrap.ts +5 -13
  72. package/src/cli/main.ts +4 -1
  73. package/src/cli/parse-flags.ts +25 -7
  74. package/src/cli/setup-selector.tsx +38 -162
  75. package/src/cli/usage.ts +4 -0
  76. package/src/hooks/companion-instructions.ts +190 -0
  77. package/src/hooks/hook-io.ts +63 -0
  78. package/src/hooks/session-banner.ts +21 -29
  79. package/src/hooks/session-guidance.ts +9 -17
  80. package/src/hooks/session-prepare.ts +115 -28
  81. package/src/hooks/validate-artifact-path.ts +82 -24
  82. package/src/index.ts +4 -0
  83. package/src/lib/change-layout.ts +154 -0
  84. package/src/lib/components/wizard-header.tsx +3 -1
  85. package/src/lib/context-mode-package.ts +24 -11
  86. package/src/lib/delivery/archive-change.ts +154 -0
  87. package/src/lib/delivery/branches.ts +108 -0
  88. package/src/lib/delivery/environment.ts +29 -0
  89. package/src/lib/delivery/forge.ts +279 -0
  90. package/src/lib/delivery/overlap.ts +202 -0
  91. package/src/lib/delivery/record-delivery.ts +196 -0
  92. package/src/lib/delivery/record.ts +343 -0
  93. package/src/lib/delivery/refresh.ts +174 -0
  94. package/src/lib/delivery/repository-access.ts +97 -0
  95. package/src/lib/delivery/stages.ts +111 -0
  96. package/src/lib/delivery/token.ts +58 -0
  97. package/src/lib/frontmatter.ts +18 -0
  98. package/src/lib/opencode-plugin-package.ts +5 -5
  99. package/src/lib/openspec-cli.ts +116 -0
  100. package/src/lib/openspec-scopes.ts +86 -0
  101. package/src/lib/orchestrator/adapters/claude-args.ts +50 -0
  102. package/src/lib/orchestrator/adapters/claude.ts +48 -40
  103. package/src/lib/orchestrator/adapters/opencode.ts +2 -0
  104. package/src/lib/orchestrator/editor.ts +9 -9
  105. package/src/lib/orchestrator/openspec-store.ts +14 -54
  106. package/src/lib/orchestrator/projection-claude-commands.ts +36 -0
  107. package/src/lib/orchestrator/projection-claude-links.ts +159 -0
  108. package/src/lib/orchestrator/projection-claude-skills.ts +19 -137
  109. package/src/lib/orchestrator/projection-entries.ts +45 -14
  110. package/src/lib/orchestrator/projection-runtime-documents.ts +15 -6
  111. package/src/lib/orchestrator/projection-types.ts +3 -0
  112. package/src/lib/orchestrator/refresh-working-claude-links.ts +87 -0
  113. package/src/lib/orchestrator/session-prepare.ts +45 -5
  114. package/src/lib/orchestrator/setup-compatibilities.ts +18 -75
  115. package/src/lib/orchestrator/types.ts +2 -2
  116. package/src/lib/orchestrator/wrap-pass.ts +22 -1
  117. package/src/lib/package-paths.ts +8 -0
  118. package/src/opencode/companion-hooks.ts +64 -5
  119. package/src/opencode/companion-policy.ts +37 -18
  120. package/src/opencode/projected-guidance.ts +4 -1
  121. package/src/opencode/session-prepare.ts +2 -1
  122. package/src/opencode/tui.tsx +4 -2
  123. package/src/runtime/artifact-refusal.ts +63 -0
  124. package/src/runtime/claude-delivery-plan.ts +89 -7
  125. package/src/runtime/commit-attribution-guard.ts +181 -0
  126. package/src/runtime/companion-guidance.ts +15 -3
  127. package/src/runtime/companion-instructions.ts +340 -0
  128. package/src/runtime/error-message.ts +4 -0
  129. package/src/runtime/index.ts +5 -0
  130. package/src/runtime/managed-block.ts +117 -0
  131. package/src/runtime/markdown-scan.ts +266 -0
  132. package/src/runtime/policy.ts +15 -1
  133. package/src/runtime/projected-flag.ts +7 -0
  134. package/src/runtime/projected-guidance.ts +1 -0
  135. package/src/runtime/runtime-document-manifest.ts +62 -11
  136. package/src/runtime/secret-guard.ts +148 -0
  137. package/src/runtime/secret-patterns.ts +164 -0
  138. package/src/runtime/secret-scan.ts +90 -0
  139. package/src/runtime/session-kind.ts +13 -0
  140. package/src/runtime/shell-heredoc.ts +81 -0
  141. package/src/runtime/wrap-state.ts +36 -2
  142. package/src/runtime/write-atomic.ts +64 -0
  143. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +8 -6
  144. package/src/templates/capabilities/openspec-cap/mate-minimal/templates/spec.md +3 -5
  145. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +7 -9
  146. package/src/templates/capabilities/openspec-cap/mate-v1/templates/spec.md +3 -5
  147. package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +10 -12
  148. package/src/templates/mate-skills/THIRD-PARTY-NOTICES.md +40 -0
  149. package/src/templates/mate-skills/agents/mate-apply-change/SKILL.md +76 -0
  150. package/src/templates/mate-skills/agents/mate-apply-change/references/openspec-apply-change.md +198 -0
  151. package/src/templates/mate-skills/agents/mate-archive-change/SKILL.md +59 -0
  152. package/src/templates/mate-skills/{claude/mate-openspec-backfill → agents/mate-backfill-spec}/SKILL.md +4 -4
  153. package/src/templates/mate-skills/agents/mate-clarify-intent/SKILL.md +95 -0
  154. package/src/templates/mate-skills/agents/mate-explore-change/SKILL.md +50 -0
  155. package/src/templates/mate-skills/agents/mate-explore-change/references/openspec-explore.md +348 -0
  156. package/src/templates/mate-skills/agents/mate-grill-me/SKILL.md +3 -3
  157. package/src/templates/mate-skills/agents/mate-interview-me/SKILL.md +5 -5
  158. package/src/templates/mate-skills/agents/mate-propose-change/SKILL.md +103 -0
  159. package/src/templates/mate-skills/agents/mate-propose-change/references/openspec-propose.md +167 -0
  160. package/src/templates/mate-skills/agents/mate-propose-change/references/openspec-update-change.md +101 -0
  161. package/src/templates/mate-skills/{claude/mate-artifact-publish → agents/mate-publish-artifact}/SKILL.md +91 -23
  162. package/src/templates/mate-skills/agents/{mate-artifact-publish → mate-publish-artifact}/references/openspec.md +4 -3
  163. package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +74 -16
  164. package/src/templates/mate-skills/agents/mate-simplify-code/SKILL.md +74 -68
  165. package/src/templates/mate-skills/claude/mate-apply-change/SKILL.md +76 -0
  166. package/src/templates/mate-skills/claude/mate-apply-change/references/openspec-apply-change.md +198 -0
  167. package/src/templates/mate-skills/claude/mate-archive-change/SKILL.md +59 -0
  168. package/src/templates/mate-skills/{agents/mate-openspec-backfill → claude/mate-backfill-spec}/SKILL.md +4 -4
  169. package/src/templates/mate-skills/claude/mate-clarify-intent/SKILL.md +95 -0
  170. package/src/templates/mate-skills/claude/mate-explore-change/SKILL.md +50 -0
  171. package/src/templates/mate-skills/claude/mate-explore-change/references/openspec-explore.md +348 -0
  172. package/src/templates/mate-skills/claude/mate-grill-me/SKILL.md +3 -3
  173. package/src/templates/mate-skills/claude/mate-interview-me/SKILL.md +5 -5
  174. package/src/templates/mate-skills/claude/mate-propose-change/SKILL.md +103 -0
  175. package/src/templates/mate-skills/claude/mate-propose-change/references/openspec-propose.md +167 -0
  176. package/src/templates/mate-skills/claude/mate-propose-change/references/openspec-update-change.md +101 -0
  177. package/src/templates/mate-skills/{agents/mate-artifact-publish → claude/mate-publish-artifact}/SKILL.md +91 -23
  178. package/src/templates/mate-skills/claude/{mate-artifact-publish → mate-publish-artifact}/references/openspec.md +4 -3
  179. package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +74 -16
  180. package/src/templates/mate-skills/claude/mate-simplify-code/SKILL.md +74 -68
  181. package/src/templates/mate-skills/openspec-references.ts +140 -0
  182. package/src/templates/root/TEMPLATE_AGENTS.md +2 -2
  183. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +3727 -320
  184. package/src/tools/setup/capabilities/context-mode.ts +25 -5
  185. package/src/tools/setup/capabilities/graphify-gate.ts +31 -0
  186. package/src/tools/setup/capabilities/graphify-secrets.ts +255 -0
  187. package/src/tools/setup/capabilities/graphify.ts +12 -0
  188. package/src/tools/setup/capabilities/openspec.ts +151 -270
  189. package/src/tools/setup/capabilities/tokensave.ts +36 -3
  190. package/src/tools/setup/context-services.ts +9 -17
  191. package/src/tools/setup/dynamic-plugins/install.ts +24 -28
  192. package/src/tools/setup/engine.ts +97 -21
  193. package/src/tools/setup/managed-output.ts +51 -0
  194. package/src/tools/setup/mate.ts +86 -11
  195. package/src/tools/setup/plugin.ts +35 -0
  196. package/src/tools/setup/plugins/guidance.ts +7 -4
  197. package/src/tools/setup/providers/agent-file-sections.ts +4 -2
  198. package/src/tools/setup/providers/claude-attribution.ts +65 -0
  199. package/src/tools/setup/providers/claude-plugin-folders.ts +625 -0
  200. package/src/tools/setup/providers/claude-plugin-hooks.ts +39 -19
  201. package/src/tools/setup/providers/claude.ts +284 -8
  202. package/src/tools/setup/providers/opencode.ts +180 -1
  203. package/src/tools/setup/utils.ts +14 -4
  204. package/src/tools/setup.ts +126 -10
  205. package/wrappers/bin/graphify +52 -4
  206. package/wrappers/bin/openspec +33 -9
  207. package/wrappers/lib/graphify-gate.mjs +8 -0
  208. package/src/cli/commands/studio/views/workflow/transcript.tsx +0 -190
  209. package/src/tools/setup/runtime-documents.ts +0 -291
@@ -3,16 +3,41 @@ import path from "node:path";
3
3
 
4
4
  import { parse } from "yaml";
5
5
 
6
+ import {
7
+ isPublishable,
8
+ legacyDeliveryWarning,
9
+ readDeliveryState,
10
+ } from "../../../../lib/delivery/record";
11
+ import {
12
+ ARCHIVE_RELATIVE_DIR,
13
+ archiveAnchors,
14
+ archiveDirOf,
15
+ CHANGES_RELATIVE_DIR,
16
+ deltaSpecs,
17
+ isChangeName,
18
+ parseAnchor,
19
+ SPECS_RELATIVE_DIR,
20
+ } from "../../../../lib/change-layout";
21
+ import { FRONTMATTER_PATTERN } from "../../../../lib/frontmatter";
22
+ import { pathIsDirectory } from "../../../../lib/fs-utils";
23
+ import {
24
+ BOM,
25
+ DELTA_COMMENT_PATTERN,
26
+ parseScopes,
27
+ type ScopePair,
28
+ } from "../../../../lib/openspec-scopes";
6
29
  import { hasOpenspecCapability } from "../../../../lib/orchestrator/capabilities";
7
30
  import { runIndexCapCommand } from "../../cap/index-cmd";
8
- import { discoverArchives, SPECS_RELATIVE_DIR, unattributedSpecs } from "../pending/discovery";
31
+ import {
32
+ canonicalSpecsFor,
33
+ discoverArchives,
34
+ pendingArchives,
35
+ unattributedSpecs,
36
+ type ArchiveEntry,
37
+ } from "../pending/discovery";
9
38
  import type { ArtifactFinisher, FinishContext, Produced, ResolveResult } from "./finisher";
10
39
  import type { GitOps } from "./git";
11
40
 
12
- function escapeRegExp(value: string): string {
13
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
14
- }
15
-
16
41
  async function pathExists(candidate: string): Promise<boolean> {
17
42
  try {
18
43
  await fs.stat(candidate);
@@ -22,9 +47,6 @@ async function pathExists(candidate: string): Promise<boolean> {
22
47
  }
23
48
  }
24
49
 
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
50
  /**
29
51
  * The publication commit's exact scope. The active-change path is included only when it
30
52
  * is gone from disk — that absence IS the deletion archiving produced. A path that still
@@ -35,68 +57,23 @@ async function commitPathsForArchive(
35
57
  name: string,
36
58
  anchorName: string,
37
59
  ): Promise<string[]> {
38
- const archiveRelative = path.posix.join("openspec", "changes", "archive", anchorName);
39
- const activeRelative = path.posix.join("openspec", "changes", name);
40
- const deltaSpecsDir = path.join(companionPath, archiveRelative, "specs");
41
- const canonicalSpecs: string[] = [];
42
- const collectDeltaSpecFiles = async (directory: string, relative = ""): Promise<void> => {
43
- const entries = await fs.readdir(directory, { withFileTypes: true });
44
- for (const entry of entries) {
45
- const entryRelative = path.posix.join(relative, entry.name);
46
- if (entry.isDirectory()) {
47
- await collectDeltaSpecFiles(path.join(directory, entry.name), entryRelative);
48
- } else if (entry.isFile()) {
49
- canonicalSpecs.push(path.posix.join("openspec", "specs", entryRelative));
50
- }
51
- }
52
- };
53
- try {
54
- await collectDeltaSpecFiles(deltaSpecsDir);
55
- } catch {
56
- // Changes without delta specs still commit their active deletion and archive.
57
- }
60
+ const archiveRelative = `${ARCHIVE_RELATIVE_DIR}/${anchorName}`;
61
+ const activeRelative = `${CHANGES_RELATIVE_DIR}/${name}`;
62
+ // Changes without delta specs still commit their active deletion and archive.
63
+ const canonicalSpecs = await deltaSpecs(path.join(companionPath, archiveRelative));
58
64
  return [
59
65
  ...((await pathExists(path.join(companionPath, activeRelative))) ? [] : [activeRelative]),
60
66
  archiveRelative,
61
- ...canonicalSpecs.toSorted(),
67
+ ...canonicalSpecs.map((spec) => spec.canonical),
62
68
  ];
63
69
  }
64
70
 
65
- const BOM = "";
66
-
67
- interface ScopePair {
68
- repository: string;
69
- area: string;
70
- }
71
-
72
71
  type ProjectionResult =
73
72
  | { ok: true; repository: string; areas: string[] }
74
73
  | { ok: false; reason: string };
75
74
 
76
- /** A leading `---` block; group 1 is its YAML, the match ends after the closing line. */
77
- const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
78
-
79
- function parseDeltaScopes(source: string): ScopePair[] {
80
- const text = source.startsWith(BOM) ? source.slice(BOM.length) : source;
81
- const match = FRONTMATTER_PATTERN.exec(text);
82
- if (!match) return [];
83
-
84
- try {
85
- const parsed = parse(match[1]) as unknown;
86
- if (!parsed || typeof parsed !== "object") return [];
87
- const scopes = (parsed as Record<string, unknown>).scopes;
88
- if (!Array.isArray(scopes)) return [];
89
- return scopes.flatMap((entry) => {
90
- if (!entry || typeof entry !== "object") return [];
91
- const pair = entry as Record<string, unknown>;
92
- return typeof pair.repository === "string" && typeof pair.area === "string"
93
- ? [{ repository: pair.repository, area: pair.area }]
94
- : [];
95
- });
96
- } catch {
97
- return [];
98
- }
99
- }
75
+ /** The opening line of a `mate:delta` comment, closed or not. */
76
+ const DELTA_COMMENT_OPENER = /^<!--[ \t]*mate:delta/;
100
77
 
101
78
  const DELTA_TAGS = new Set(["openspec/change", "openspec/delta"]);
102
79
 
@@ -177,7 +154,8 @@ function renderCanonicalFrontmatter(
177
154
 
178
155
  /**
179
156
  * Repairs `openspec archive` output: prepends canonical frontmatter to main specs the
180
- * archive run created bare, and replaces a delta-shaped header an agent copied onto one.
157
+ * archive run created bare, and replaces a delta-shaped header or `mate:delta` comment an
158
+ * agent copied onto one.
181
159
  * Publish does not archive, so this runs the first time publish resolves that archive,
182
160
  * however the archive was made.
183
161
  *
@@ -192,72 +170,50 @@ async function reconcileMainSpecFrontmatter(
192
170
  ): Promise<string[]> {
193
171
  if (!(await archivedChangeUsesMateSchema(companionPath, anchorName))) return [];
194
172
 
195
- const deltaSpecsDir = path.join(
196
- companionPath,
197
- "openspec",
198
- "changes",
199
- "archive",
200
- anchorName,
201
- "specs",
202
- );
173
+ const archiveDir = path.join(archiveDirOf(companionPath), anchorName);
203
174
  const reconciled: string[] = [];
204
175
 
205
- const walk = async (directory: string, relative = ""): Promise<void> => {
206
- let entries;
176
+ for (const { file, canonical: canonicalRelative, capability } of await deltaSpecs(archiveDir)) {
177
+ if (capability === null) continue;
178
+ const canonicalPath = path.join(companionPath, canonicalRelative);
179
+
207
180
  try {
208
- entries = await fs.readdir(directory, { withFileTypes: true });
209
- } catch {
210
- return;
211
- }
212
- for (const entry of entries) {
213
- const entryRelative = path.posix.join(relative, entry.name);
214
- if (entry.isDirectory()) {
215
- await walk(path.join(directory, entry.name), entryRelative);
216
- continue;
181
+ const canonical = (await fs.readFile(canonicalPath, "utf8")).replace(BOM, "");
182
+ /** The body that follows the written block: the whole file, or what trails a delta-shaped header. */
183
+ let body = `\n${canonical}`;
184
+ if (DELTA_COMMENT_OPENER.test(canonical)) {
185
+ const header = DELTA_COMMENT_PATTERN.exec(canonical);
186
+ if (!header) continue;
187
+ body = canonical.slice(header[0].length);
188
+ } else if (canonical.startsWith("---")) {
189
+ const header = FRONTMATTER_PATTERN.exec(canonical);
190
+ if (!header || !isDeltaShapedFrontmatter(header[1])) continue;
191
+ body = canonical.slice(header[0].length);
217
192
  }
218
- if (!entry.isFile() || entry.name !== "spec.md") continue;
219
-
220
- const capability = path.posix.dirname(entryRelative);
221
- if (capability === ".") continue;
222
- const canonicalRelative = path.posix.join("openspec", "specs", entryRelative);
223
- const canonicalPath = path.join(companionPath, canonicalRelative);
224
-
225
- try {
226
- const canonical = (await fs.readFile(canonicalPath, "utf8")).replace(BOM, "");
227
- /** The body that follows the written block: the whole file, or what trails a delta-shaped header. */
228
- let body = `\n${canonical}`;
229
- if (canonical.startsWith("---")) {
230
- const header = FRONTMATTER_PATTERN.exec(canonical);
231
- if (!header || !isDeltaShapedFrontmatter(header[1])) continue;
232
- body = canonical.slice(header[0].length);
233
- }
234
193
 
235
- const deltaSource = await fs.readFile(path.join(directory, entry.name), "utf8");
236
- const scopes = parseDeltaScopes(deltaSource);
237
- const projected = projectDeltaScopes(scopes);
194
+ const deltaSource = await fs.readFile(path.join(archiveDir, "specs", file), "utf8");
195
+ const scopes = parseScopes(deltaSource);
196
+ const projected = projectDeltaScopes(scopes);
238
197
 
239
- /** Multi-repository is invalid input, so skip; absent scopes still earn a partial block. */
240
- if (!projected.ok && scopes.length > 0) {
241
- process.stderr.write(
242
- `mate: skipped frontmatter for ${canonicalRelative}: ${projected.reason}\n`,
243
- );
244
- continue;
245
- }
246
- const head = renderCanonicalFrontmatter(
247
- capability,
248
- projected.ok ? { repository: projected.repository, areas: projected.areas } : null,
249
- );
250
- await fs.writeFile(canonicalPath, head + body, "utf8");
251
- reconciled.push(canonicalRelative);
252
- } catch (error) {
198
+ /** Multi-repository is invalid input, so skip; absent scopes still earn a partial block. */
199
+ if (!projected.ok && scopes.length > 0) {
253
200
  process.stderr.write(
254
- `mate: could not reconcile frontmatter for ${canonicalRelative}: ${String(error)}\n`,
201
+ `mate: skipped frontmatter for ${canonicalRelative}: ${projected.reason}\n`,
255
202
  );
203
+ continue;
256
204
  }
205
+ const head = renderCanonicalFrontmatter(
206
+ capability,
207
+ projected.ok ? { repository: projected.repository, areas: projected.areas } : null,
208
+ );
209
+ await fs.writeFile(canonicalPath, head + body, "utf8");
210
+ reconciled.push(canonicalRelative);
211
+ } catch (error) {
212
+ process.stderr.write(
213
+ `mate: could not reconcile frontmatter for ${canonicalRelative}: ${String(error)}\n`,
214
+ );
257
215
  }
258
- };
259
-
260
- await walk(deltaSpecsDir);
216
+ }
261
217
  return reconciled;
262
218
  }
263
219
 
@@ -290,14 +246,102 @@ async function capSync(context: FinishContext): Promise<boolean> {
290
246
  }
291
247
  }
292
248
 
293
- async function resolved(
249
+ /** Why an archive's delivery keeps it unpublished, or null when it may be published. */
250
+ async function deliveryHold(companionPath: string, anchorName: string): Promise<string | null> {
251
+ const delivery = await readDeliveryState(path.join(archiveDirOf(companionPath), anchorName));
252
+ if (delivery.error) return `mate: refusing to publish ${anchorName}: ${delivery.error}`;
253
+ if (isPublishable(delivery.status)) return null;
254
+ const waiting = delivery.unsettled.length > 0 ? ` (${delivery.unsettled.join(", ")})` : "";
255
+ // The dated anchor, since a bare name resolves to an active change or the newest archive.
256
+ const [missing] = delivery.missing;
257
+ const remedy = missing
258
+ ? `Its scopes name ${delivery.missing.join(", ")} without a delivery; record each with \`mate artifact link ${anchorName} --repository ${missing} --branch <branch>\` or \`--not-applicable <reason>\`, then publish.`
259
+ : `Record or settle it with \`mate artifact link ${anchorName}\`, then publish.`;
260
+ return `mate: ${anchorName} is held back: its delivery is ${delivery.status}${waiting}. ${remedy}`;
261
+ }
262
+
263
+ /**
264
+ * Per-run memo of each archive's canonical specs, delivery hold, and the pending archives.
265
+ * None changes the answer while a run publishes: a published archive was not held, so it
266
+ * never held another back. `publish --all` thus reads each archive and Git status once.
267
+ */
268
+ export interface PublicationHoldCache {
269
+ specs: Map<string, Promise<string[]>>;
270
+ holds: Map<string, Promise<string | null>>;
271
+ pending?: Promise<ArchiveEntry[]>;
272
+ }
273
+
274
+ export function publicationHoldCache(): PublicationHoldCache {
275
+ return { specs: new Map(), holds: new Map() };
276
+ }
277
+
278
+ function memo<T>(cache: Map<string, Promise<T>>, key: string, load: () => Promise<T>): Promise<T> {
279
+ let value = cache.get(key);
280
+ if (!value) {
281
+ value = load();
282
+ cache.set(key, value);
283
+ }
284
+ return value;
285
+ }
286
+
287
+ /**
288
+ * The merge gate, whoever asks: an archive whose delivery is not settled stays unpublished,
289
+ * and so does one sharing a canonical spec with a pending held-back archive, since its
290
+ * commit would carry that archive's unmerged requirements along. Null when publishable.
291
+ */
292
+ export async function publicationHold(
294
293
  companionPath: string,
294
+ anchorName: string,
295
+ changedPaths?: () => Promise<string[]>,
296
+ cache: PublicationHoldCache = publicationHoldCache(),
297
+ ): Promise<string | null> {
298
+ const holdOf = (anchor: string): Promise<string | null> =>
299
+ memo(cache.holds, anchor, () => deliveryHold(companionPath, anchor));
300
+ const specsOf = (anchor: string): Promise<string[]> =>
301
+ memo(cache.specs, anchor, () => canonicalSpecsFor(companionPath, anchor));
302
+
303
+ const own = await holdOf(anchorName);
304
+ if (own || !changedPaths) return own;
305
+
306
+ const specs = new Set(await specsOf(anchorName));
307
+ if (specs.size === 0) return null;
308
+ cache.pending ??= changedPaths().then(async (changed) =>
309
+ pendingArchives(await discoverArchives(companionPath, changed)),
310
+ );
311
+ const others = (await cache.pending).filter((entry) => entry.anchor !== anchorName);
312
+ for (const other of others) {
313
+ const shared = (await specsOf(other.anchor)).filter((spec) => specs.has(spec));
314
+ if (shared.length === 0) continue;
315
+ if ((await holdOf(other.anchor)) === null) continue;
316
+ return `mate: ${anchorName} is held back: ${shared.join(", ")} also carries edits from ${other.anchor}, which is not publishable yet. Publish it after ${other.anchor} settles.`;
317
+ }
318
+ return null;
319
+ }
320
+
321
+ async function resolved(
322
+ context: FinishContext,
295
323
  name: string,
296
324
  anchorName: string,
297
325
  ): Promise<ResolveResult> {
326
+ const { companionPath } = context;
327
+ const hold = await publicationHold(
328
+ companionPath,
329
+ anchorName,
330
+ context.changedPaths,
331
+ context.holdCache,
332
+ );
333
+ if (hold) return { ok: false, message: hold };
334
+ const delivery = await readDeliveryState(path.join(archiveDirOf(companionPath), anchorName));
298
335
  const commitPaths = await commitPathsForArchive(companionPath, name, anchorName);
299
336
  await reconcileMainSpecFrontmatter(companionPath, anchorName);
300
- return { ok: true, resolved: { anchorName, commitPaths } };
337
+ return {
338
+ ok: true,
339
+ resolved: {
340
+ anchorName,
341
+ commitPaths,
342
+ ...(delivery.status === "legacy" ? { warning: legacyDeliveryWarning(anchorName) } : {}),
343
+ },
344
+ };
301
345
  }
302
346
 
303
347
  /** Tag namespace segregating spec publications from dated change anchors. */
@@ -468,35 +512,30 @@ export function openspecFinisher(contextOrPath: FinishContext | string): Artifac
468
512
  return hasOpenspecCapability(capabilities);
469
513
  },
470
514
  async resolve(target) {
471
- const archiveRoot = path.join(companionPath, "openspec", "changes", "archive");
472
- const anchor = ANCHOR_PATTERN.exec(target);
515
+ /** A path-like target names no archive; refuse before touching the filesystem. */
516
+ if (!isChangeName(target)) {
517
+ return {
518
+ ok: false,
519
+ message: `mate: no archived change matches "${target}". Pass a change name or a dated archive anchor.`,
520
+ };
521
+ }
522
+ const anchor = parseAnchor(target);
473
523
 
474
524
  /** A dated anchor names exactly one directory; no name matching can widen it. */
475
525
  if (anchor) {
476
- if (!(await pathExists(path.join(archiveRoot, target)))) {
526
+ if (!(await pathIsDirectory(path.join(archiveDirOf(companionPath), target)))) {
477
527
  return {
478
528
  ok: false,
479
- message: `mate: no archive at openspec/changes/archive/${target}. Archive the change first with \`openspec archive ${anchor[2]}\`.`,
529
+ message: `mate: no archive at ${ARCHIVE_RELATIVE_DIR}/${target}. Archive the change first with \`openspec archive ${anchor.name}\`.`,
480
530
  };
481
531
  }
482
- return resolved(companionPath, anchor[2], target);
532
+ return resolved(context, anchor.name, target);
483
533
  }
484
534
 
485
- const pattern = new RegExp(`^\\d{4}-\\d{2}-\\d{2}-${escapeRegExp(target)}$`);
486
- let matches: string[] = [];
487
- try {
488
- const entries = await fs.readdir(archiveRoot, { withFileTypes: true });
489
- matches = [];
490
- for (const entry of entries) {
491
- if (entry.isDirectory() && pattern.test(entry.name)) matches.push(entry.name);
492
- }
493
- matches = matches.toSorted();
494
- } catch {
495
- /** No archive directory at all: nothing can match. */
496
- }
535
+ const matches = await archiveAnchors(companionPath, target);
497
536
 
498
537
  if (matches.length === 0) {
499
- const active = await pathExists(path.join(companionPath, "openspec", "changes", target));
538
+ const active = await pathExists(path.join(companionPath, CHANGES_RELATIVE_DIR, target));
500
539
  return {
501
540
  ok: false,
502
541
  message: active
@@ -511,7 +550,7 @@ export function openspecFinisher(contextOrPath: FinishContext | string): Artifac
511
550
  message: `mate: "${target}" matches ${matches.length} archives (${matches.join(", ")}). Pass one anchor explicitly.`,
512
551
  };
513
552
  }
514
- return resolved(companionPath, target, matches[0]);
553
+ return resolved(context, target, matches[0]);
515
554
  },
516
555
  capSync() {
517
556
  return capSync(context);
@@ -0,0 +1,43 @@
1
+ import path from "node:path";
2
+
3
+ import { resolveSecretPolicy } from "../../../../runtime/policy";
4
+ import { isSecretFile } from "../../../../runtime/secret-patterns";
5
+ import { listFiles, scanFiles, type SecretFinding } from "../../../../runtime/secret-scan";
6
+
7
+ /** Rule id reported for a staged path that is itself a secret file. */
8
+ export const SECRET_FILE_RULE_ID = "mate/secret-file";
9
+
10
+ export type PublicationSecretScan =
11
+ | { skipped: true }
12
+ | { skipped: false; findings: SecretFinding[] };
13
+
14
+ /**
15
+ * Scans exactly what a publication would stage: every file under the commit
16
+ * paths that exists on disk (a deleted path has nothing to leak). A staged
17
+ * secret file counts as a finding of its own. Paths are reported relative to
18
+ * the companion; values never leave this function. Throws when the scanner
19
+ * fails, which the engine treats like a finding.
20
+ */
21
+ export async function scanPublication(
22
+ companionPath: string,
23
+ commitPaths: readonly string[],
24
+ ): Promise<PublicationSecretScan> {
25
+ const policy = resolveSecretPolicy(companionPath);
26
+ if (!policy.guardrails) return { skipped: true };
27
+
28
+ const files = (
29
+ await Promise.all(
30
+ commitPaths.map((commitPath) => listFiles(path.join(companionPath, commitPath))),
31
+ )
32
+ ).flat();
33
+ const relative = (file: string) => path.relative(companionPath, file).split(path.sep).join("/");
34
+ const secretFiles: SecretFinding[] = files
35
+ .map(relative)
36
+ .filter((file) => isSecretFile(file, policy))
37
+ .map((file) => ({ path: file, line: 0, ruleId: SECRET_FILE_RULE_ID }));
38
+ const contentFindings = (await scanFiles(files)).map((finding) => ({
39
+ ...finding,
40
+ path: relative(finding.path),
41
+ }));
42
+ return { skipped: false, findings: [...secretFiles, ...contentFindings] };
43
+ }
@@ -1,3 +1,6 @@
1
+ import path from "node:path";
2
+
3
+ import { readDeliveryState, type DeliveryState } from "../../../../lib/delivery/record";
1
4
  import { hasOpenspecCapability } from "../../../../lib/orchestrator/capabilities";
2
5
  import { resolveForCapability } from "../../../../lib/orchestrator/framework-context";
3
6
  import type { LaunchContext } from "../../../../lib/orchestrator/framework-context";
@@ -39,12 +42,20 @@ export interface AllCoverage {
39
42
  coveredByAll: boolean;
40
43
  }
41
44
 
45
+ /**
46
+ * The archive's Delivery Record and its settlement; held back unless `isPublishable`.
47
+ * `error` names an unreadable record.
48
+ */
49
+ export interface PendingDelivery {
50
+ delivery: DeliveryState;
51
+ }
52
+
42
53
  /** Machine-readable result emitted with `--json`. */
43
54
  export interface PendingResult {
44
55
  type: string;
45
56
  companionPath: string;
46
57
  count: number;
47
- pending: Array<ArchiveEntry & AllCoverage>;
58
+ pending: Array<ArchiveEntry & AllCoverage & PendingDelivery>;
48
59
  /** Uncommitted canonical specs no pending change accounts for, each with its attribution. */
49
60
  unattributedSpecs: Array<UnattributedSpec & AllCoverage>;
50
61
  }
@@ -80,6 +91,23 @@ function renderUnattributed(specs: Array<UnattributedSpec & AllCoverage>): strin
80
91
  ];
81
92
  }
82
93
 
94
+ async function withDelivery<T extends ArchiveEntry>(
95
+ companionPath: string,
96
+ entries: T[],
97
+ ): Promise<Array<T & PendingDelivery>> {
98
+ return Promise.all(
99
+ entries.map(async (entry) => {
100
+ const delivery = await readDeliveryState(path.join(companionPath, entry.path));
101
+ return { ...entry, delivery };
102
+ }),
103
+ );
104
+ }
105
+
106
+ /** A damaged record is shown with its error, so it is fixed rather than re-linked over. */
107
+ function renderDeliveryStatus(delivery: DeliveryState): string {
108
+ return delivery.error === undefined ? delivery.status : `unreadable (${delivery.error})`;
109
+ }
110
+
83
111
  function renderHuman(result: PendingResult): string[] {
84
112
  const pending =
85
113
  result.count === 0
@@ -87,7 +115,7 @@ function renderHuman(result: PendingResult): string[] {
87
115
  : [
88
116
  `${result.count} archived change${result.count === 1 ? "" : "s"} pending publication:`,
89
117
  ...result.pending.flatMap((entry, index) => [
90
- ` ${index + 1}. ${entry.name} ${entry.tag} ${entry.path}${entry.coveredByAll ? " [--all]" : ""}`,
118
+ ` ${index + 1}. ${entry.name} ${entry.tag} ${entry.path}${entry.coveredByAll ? " [--all]" : ""} delivery: ${renderDeliveryStatus(entry.delivery)}`,
91
119
  ...entry.uncommittedPaths.map((uncommitted) => ` ${uncommitted}`),
92
120
  ...entry.uncommittedSpecs.map((spec) => ` ${spec}`),
93
121
  ]),
@@ -164,7 +192,7 @@ export async function runArtifactPendingCommand(
164
192
  type: "openspec",
165
193
  companionPath: context.companionPath,
166
194
  count: pending.length,
167
- pending: markCoveredByAll(pending),
195
+ pending: await withDelivery(context.companionPath, markCoveredByAll(pending)),
168
196
  unattributedSpecs: markCoveredByAll(
169
197
  await unattributedSpecs(context.companionPath, changed, archives, changeKinds),
170
198
  ),
@@ -1,6 +1,15 @@
1
- import fs from "node:fs/promises";
2
1
  import path from "node:path";
3
2
 
3
+ import {
4
+ ARCHIVE_RELATIVE_DIR,
5
+ archiveAnchors,
6
+ archiveDirOf,
7
+ CHANGES_RELATIVE_DIR,
8
+ changeNameOf,
9
+ deltaSpecs,
10
+ filesBelow,
11
+ SPECS_RELATIVE_DIR,
12
+ } from "../../../../lib/change-layout";
4
13
  import type { WorkingTreeChange } from "../finish/git";
5
14
 
6
15
  /** Whether an archived change still has uncommitted content in the companion working tree. */
@@ -48,13 +57,6 @@ export interface ArchiveEntry {
48
57
  state: CommitState;
49
58
  }
50
59
 
51
- /** Only dated archive directories are publishable; everything else is ignored. */
52
- const ARCHIVE_ANCHOR_PATTERN = /^\d{4}-\d{2}-\d{2}-.+$/;
53
- const ARCHIVE_DATE_PREFIX = /^\d{4}-\d{2}-\d{2}-/;
54
-
55
- const ARCHIVE_RELATIVE_DIR = "openspec/changes/archive";
56
- export const SPECS_RELATIVE_DIR = "openspec/specs";
57
-
58
60
  /** The tag `mate artifact publish` creates, never a recomputed date. */
59
61
  export function finishMarker(anchor: string): string {
60
62
  return `openspec/${anchor}`;
@@ -109,38 +111,16 @@ function changeKindFor(
109
111
  );
110
112
  }
111
113
 
112
- /**
113
- * Every file beneath `directory`, as POSIX paths under `relativeRoot`. Reads directory
114
- * entry names only, never file contents. Throws when `directory` cannot be read.
115
- */
116
- async function filesUnder(directory: string, relativeRoot: string): Promise<string[]> {
117
- const entries = await fs.readdir(directory, { withFileTypes: true });
118
- const nested = await Promise.all(
119
- entries.map(async (entry) => {
120
- const entryRelative = path.posix.join(relativeRoot, entry.name);
121
- if (entry.isDirectory()) return filesUnder(path.join(directory, entry.name), entryRelative);
122
- return entry.isFile() ? [entryRelative] : [];
123
- }),
124
- );
125
- return nested.flat();
126
- }
127
-
128
114
  /**
129
115
  * Canonical specs the change's delta specs applied to. Only directory entry names under
130
116
  * the archive's `specs/` tree are read — never any file contents — so archived prose
131
117
  * cannot influence discovery.
132
118
  */
133
- async function canonicalSpecsFor(companionPath: string, anchor: string): Promise<string[]> {
134
- try {
135
- const specs = await filesUnder(
136
- path.join(companionPath, ARCHIVE_RELATIVE_DIR, anchor, "specs"),
137
- SPECS_RELATIVE_DIR,
138
- );
139
- return specs.toSorted();
140
- } catch {
141
- // A change without delta specs still owns its archive and active directories.
142
- return [];
143
- }
119
+ export async function canonicalSpecsFor(companionPath: string, anchor: string): Promise<string[]> {
120
+ // A change without delta specs still owns its archive and active directories.
121
+ return (await deltaSpecs(path.join(archiveDirOf(companionPath), anchor))).map(
122
+ (spec) => spec.canonical,
123
+ );
144
124
  }
145
125
 
146
126
  /**
@@ -156,24 +136,12 @@ export async function discoverArchives(
156
136
  uncommittedPaths: string[],
157
137
  changeKinds: Readonly<Record<string, WorkingTreeChange>> = {},
158
138
  ): Promise<ArchiveEntry[]> {
159
- let entries;
160
- try {
161
- entries = await fs.readdir(path.join(companionPath, "openspec", "changes", "archive"), {
162
- withFileTypes: true,
163
- });
164
- } catch {
165
- return [];
166
- }
167
-
168
- const anchors = entries
169
- .filter((entry) => entry.isDirectory() && ARCHIVE_ANCHOR_PATTERN.test(entry.name))
170
- .map((entry) => entry.name)
171
- .toSorted();
139
+ const anchors = await archiveAnchors(companionPath);
172
140
 
173
141
  return Promise.all(
174
142
  anchors.map(async (anchor) => {
175
- const name = anchor.replace(ARCHIVE_DATE_PREFIX, "");
176
- const ownedPaths = [`${ARCHIVE_RELATIVE_DIR}/${anchor}`, `openspec/changes/${name}`];
143
+ const name = changeNameOf(anchor);
144
+ const ownedPaths = [`${ARCHIVE_RELATIVE_DIR}/${anchor}`, `${CHANGES_RELATIVE_DIR}/${name}`];
177
145
  const owned = matching(uncommittedPaths, ownedPaths);
178
146
  const ownedDirectories = matchingDirectories(uncommittedPaths, ownedPaths);
179
147
  const specs =
@@ -237,13 +205,9 @@ async function specFilesFor(
237
205
  }
238
206
  const collapsed = withoutTrailingSlash(worktreePath);
239
207
  const root = collapsed.startsWith(`${SPECS_RELATIVE_DIR}/`) ? collapsed : SPECS_RELATIVE_DIR;
240
- try {
241
- const files = await filesUnder(path.join(companionPath, root), root);
242
- return files.map((file) => ({ path: file, kind: "new" as const }));
243
- } catch {
244
- // A collapsed ancestor such as `openspec/` need not contain a spec tree at all.
245
- return [];
246
- }
208
+ // A collapsed ancestor such as `openspec/` need not contain a spec tree at all.
209
+ const files = await filesBelow(path.join(companionPath, root));
210
+ return files.map((file) => ({ path: `${root}/${file}`, kind: "new" as const }));
247
211
  }
248
212
 
249
213
  /**