@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
@@ -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,63 +57,41 @@ 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
- function parseDeltaScopes(source: string): ScopePair[] {
77
- const text = source.startsWith(BOM) ? source.slice(BOM.length) : source;
78
- const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text);
79
- if (!match) return [];
75
+ /** The opening line of a `mate:delta` comment, closed or not. */
76
+ const DELTA_COMMENT_OPENER = /^<!--[ \t]*mate:delta/;
77
+
78
+ const DELTA_TAGS = new Set(["openspec/change", "openspec/delta"]);
80
79
 
80
+ /**
81
+ * A header an agent copied from a delta: it declares `type: delta-spec`, carries a
82
+ * `change` key, or a delta tag. A nested `scopes` key alone does not count, since
83
+ * legacy canonical specs carry one.
84
+ */
85
+ function isDeltaShapedFrontmatter(yamlSource: string): boolean {
81
86
  try {
82
- const parsed = parse(match[1]) as unknown;
83
- if (!parsed || typeof parsed !== "object") return [];
84
- const scopes = (parsed as Record<string, unknown>).scopes;
85
- if (!Array.isArray(scopes)) return [];
86
- return scopes.flatMap((entry) => {
87
- if (!entry || typeof entry !== "object") return [];
88
- const pair = entry as Record<string, unknown>;
89
- return typeof pair.repository === "string" && typeof pair.area === "string"
90
- ? [{ repository: pair.repository, area: pair.area }]
91
- : [];
92
- });
87
+ const parsed = parse(yamlSource) as unknown;
88
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false;
89
+ const header = parsed as Record<string, unknown>;
90
+ if (header.type === "delta-spec" || "change" in header) return true;
91
+ const tags = Array.isArray(header.tags) ? header.tags : [header.tags];
92
+ return tags.some((tag) => typeof tag === "string" && DELTA_TAGS.has(tag));
93
93
  } catch {
94
- return [];
94
+ return false;
95
95
  }
96
96
  }
97
97
 
@@ -133,7 +133,10 @@ async function archivedChangeUsesMateSchema(
133
133
  }
134
134
  }
135
135
 
136
- /** Scope keys are omitted when the delta had none — a partial block beats no frontmatter. */
136
+ /**
137
+ * The block through its closing line. Scope keys are omitted when the delta had
138
+ * none — a partial block beats no frontmatter.
139
+ */
137
140
  function renderCanonicalFrontmatter(
138
141
  capability: string,
139
142
  scope: { repository: string; areas: string[] } | null,
@@ -146,14 +149,15 @@ function renderCanonicalFrontmatter(
146
149
  "tags: [openspec/spec]",
147
150
  "---",
148
151
  "",
149
- "",
150
152
  ].join("\n");
151
153
  }
152
154
 
153
155
  /**
154
156
  * Repairs `openspec archive` output: prepends canonical frontmatter to main specs the
155
- * archive run created bare. Publish does not archive, so this runs the first time publish
156
- * resolves that archive, however the archive was made.
157
+ * archive run created bare, and replaces a delta-shaped header or `mate:delta` comment an
158
+ * agent copied onto one.
159
+ * Publish does not archive, so this runs the first time publish resolves that archive,
160
+ * however the archive was made.
157
161
  *
158
162
  * `openspec archive` rebuilds a brand-new main spec from a skeleton with no frontmatter slot,
159
163
  * so scope metadata dies exactly once, at spec birth; existing specs keep theirs because only
@@ -166,66 +170,50 @@ async function reconcileMainSpecFrontmatter(
166
170
  ): Promise<string[]> {
167
171
  if (!(await archivedChangeUsesMateSchema(companionPath, anchorName))) return [];
168
172
 
169
- const deltaSpecsDir = path.join(
170
- companionPath,
171
- "openspec",
172
- "changes",
173
- "archive",
174
- anchorName,
175
- "specs",
176
- );
173
+ const archiveDir = path.join(archiveDirOf(companionPath), anchorName);
177
174
  const reconciled: string[] = [];
178
175
 
179
- const walk = async (directory: string, relative = ""): Promise<void> => {
180
- 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
+
181
180
  try {
182
- entries = await fs.readdir(directory, { withFileTypes: true });
183
- } catch {
184
- return;
185
- }
186
- for (const entry of entries) {
187
- const entryRelative = path.posix.join(relative, entry.name);
188
- if (entry.isDirectory()) {
189
- await walk(path.join(directory, entry.name), entryRelative);
190
- 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);
191
192
  }
192
- if (!entry.isFile() || entry.name !== "spec.md") continue;
193
-
194
- const capability = path.posix.dirname(entryRelative);
195
- if (capability === ".") continue;
196
- const canonicalRelative = path.posix.join("openspec", "specs", entryRelative);
197
- const canonicalPath = path.join(companionPath, canonicalRelative);
198
-
199
- try {
200
- const canonical = await fs.readFile(canonicalPath, "utf8");
201
- if (canonical.replace(BOM, "").startsWith("---")) continue;
202
-
203
- const deltaSource = await fs.readFile(path.join(directory, entry.name), "utf8");
204
- const scopes = parseDeltaScopes(deltaSource);
205
- const projected = projectDeltaScopes(scopes);
206
-
207
- /** Multi-repository is invalid input, so skip; absent scopes still earn a partial block. */
208
- if (!projected.ok && scopes.length > 0) {
209
- process.stderr.write(
210
- `mate: skipped frontmatter for ${canonicalRelative}: ${projected.reason}\n`,
211
- );
212
- continue;
213
- }
214
- const head = renderCanonicalFrontmatter(
215
- capability,
216
- projected.ok ? { repository: projected.repository, areas: projected.areas } : null,
217
- );
218
- await fs.writeFile(canonicalPath, head + canonical.replace(BOM, ""), "utf8");
219
- reconciled.push(canonicalRelative);
220
- } catch (error) {
193
+
194
+ const deltaSource = await fs.readFile(path.join(archiveDir, "specs", file), "utf8");
195
+ const scopes = parseScopes(deltaSource);
196
+ const projected = projectDeltaScopes(scopes);
197
+
198
+ /** Multi-repository is invalid input, so skip; absent scopes still earn a partial block. */
199
+ if (!projected.ok && scopes.length > 0) {
221
200
  process.stderr.write(
222
- `mate: could not reconcile frontmatter for ${canonicalRelative}: ${String(error)}\n`,
201
+ `mate: skipped frontmatter for ${canonicalRelative}: ${projected.reason}\n`,
223
202
  );
203
+ continue;
224
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
+ );
225
215
  }
226
- };
227
-
228
- await walk(deltaSpecsDir);
216
+ }
229
217
  return reconciled;
230
218
  }
231
219
 
@@ -258,14 +246,102 @@ async function capSync(context: FinishContext): Promise<boolean> {
258
246
  }
259
247
  }
260
248
 
261
- 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(
262
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,
263
323
  name: string,
264
324
  anchorName: string,
265
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));
266
335
  const commitPaths = await commitPathsForArchive(companionPath, name, anchorName);
267
336
  await reconcileMainSpecFrontmatter(companionPath, anchorName);
268
- 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
+ };
269
345
  }
270
346
 
271
347
  /** Tag namespace segregating spec publications from dated change anchors. */
@@ -436,35 +512,30 @@ export function openspecFinisher(contextOrPath: FinishContext | string): Artifac
436
512
  return hasOpenspecCapability(capabilities);
437
513
  },
438
514
  async resolve(target) {
439
- const archiveRoot = path.join(companionPath, "openspec", "changes", "archive");
440
- 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);
441
523
 
442
524
  /** A dated anchor names exactly one directory; no name matching can widen it. */
443
525
  if (anchor) {
444
- if (!(await pathExists(path.join(archiveRoot, target)))) {
526
+ if (!(await pathIsDirectory(path.join(archiveDirOf(companionPath), target)))) {
445
527
  return {
446
528
  ok: false,
447
- 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}\`.`,
448
530
  };
449
531
  }
450
- return resolved(companionPath, anchor[2], target);
532
+ return resolved(context, anchor.name, target);
451
533
  }
452
534
 
453
- const pattern = new RegExp(`^\\d{4}-\\d{2}-\\d{2}-${escapeRegExp(target)}$`);
454
- let matches: string[] = [];
455
- try {
456
- const entries = await fs.readdir(archiveRoot, { withFileTypes: true });
457
- matches = [];
458
- for (const entry of entries) {
459
- if (entry.isDirectory() && pattern.test(entry.name)) matches.push(entry.name);
460
- }
461
- matches = matches.toSorted();
462
- } catch {
463
- /** No archive directory at all: nothing can match. */
464
- }
535
+ const matches = await archiveAnchors(companionPath, target);
465
536
 
466
537
  if (matches.length === 0) {
467
- const active = await pathExists(path.join(companionPath, "openspec", "changes", target));
538
+ const active = await pathExists(path.join(companionPath, CHANGES_RELATIVE_DIR, target));
468
539
  return {
469
540
  ok: false,
470
541
  message: active
@@ -479,7 +550,7 @@ export function openspecFinisher(contextOrPath: FinishContext | string): Artifac
479
550
  message: `mate: "${target}" matches ${matches.length} archives (${matches.join(", ")}). Pass one anchor explicitly.`,
480
551
  };
481
552
  }
482
- return resolved(companionPath, target, matches[0]);
553
+ return resolved(context, target, matches[0]);
483
554
  },
484
555
  capSync() {
485
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
  /**