@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.
- package/claude-plugin/hooks/companion-instructions.mjs +9 -0
- package/claude-plugin/hooks/hooks.json +12 -2
- package/opencode-plugin/src/add-dir.ts +76 -10
- package/opencode-plugin/src/companion.ts +1 -8
- package/opencode-plugin/src/server.ts +2 -15
- package/package.json +10 -2
- package/src/brand/art.generated.ts +69 -0
- package/src/brand/index.ts +12 -0
- package/src/brand/palette.ts +19 -0
- package/src/brand/svg.generated.ts +8 -0
- package/src/brand/terminal.ts +83 -0
- package/src/brand/types.ts +33 -0
- package/src/cli/commands/artifact/artifact.ts +8 -0
- package/src/cli/commands/artifact/delivery/archive.ts +117 -0
- package/src/cli/commands/artifact/delivery/branch-flags.ts +55 -0
- package/src/cli/commands/artifact/delivery/environment.ts +104 -0
- package/src/cli/commands/artifact/delivery/link.ts +154 -0
- package/src/cli/commands/artifact/delivery/output.ts +58 -0
- package/src/cli/commands/artifact/finish/command.ts +33 -3
- package/src/cli/commands/artifact/finish/engine.ts +47 -3
- package/src/cli/commands/artifact/finish/finisher.ts +10 -0
- package/src/cli/commands/artifact/finish/openspec.ts +200 -129
- package/src/cli/commands/artifact/finish/secret-scan.ts +43 -0
- package/src/cli/commands/artifact/pending/command.ts +31 -3
- package/src/cli/commands/artifact/pending/discovery.ts +21 -57
- package/src/cli/commands/cap/graphify.ts +52 -3
- package/src/cli/commands/cap/index-cmd.ts +8 -1
- package/src/cli/commands/cap/openspec.ts +13 -25
- package/src/cli/commands/companion/open.ts +56 -39
- package/src/cli/commands/companion/update.ts +75 -42
- package/src/cli/commands/doctor.ts +257 -78
- package/src/cli/commands/install.ts +0 -8
- package/src/cli/commands/plugin/install.ts +9 -4
- package/src/cli/commands/plugin/plugin.ts +3 -2
- package/src/cli/commands/report/adapter.ts +11 -2
- package/src/cli/commands/report/contract.ts +21 -0
- package/src/cli/commands/report/highlight.ts +34 -4
- package/src/cli/commands/report/markdown.ts +110 -0
- package/src/cli/commands/report/palette.ts +118 -0
- package/src/cli/commands/report/renderer.ts +456 -153
- package/src/cli/commands/report/types.ts +16 -0
- package/src/cli/commands/setup.ts +7 -15
- package/src/cli/commands/studio/client-asset.ts +16 -0
- package/src/cli/commands/studio/openspec-cli.ts +10 -58
- package/src/cli/commands/studio/routes.ts +2 -0
- package/src/cli/commands/studio/selection.ts +19 -1
- package/src/cli/commands/studio/server.ts +42 -1
- package/src/cli/commands/studio/terminal-launch.ts +7 -16
- package/src/cli/commands/studio/views/client.ts +6 -616
- package/src/cli/commands/studio/views/dashboard/changes.tsx +5 -10
- package/src/cli/commands/studio/views/document.tsx +137 -69
- package/src/cli/commands/studio/views/markdown.tsx +250 -0
- package/src/cli/commands/studio/views/palette.ts +134 -0
- package/src/cli/commands/studio/views/reports/index.tsx +2 -7
- package/src/cli/commands/studio/views/skills/index.tsx +43 -62
- package/src/cli/commands/studio/views/specs/index.tsx +7 -15
- package/src/cli/commands/studio/views/studio-client/actions.ts +51 -0
- package/src/cli/commands/studio/views/studio-client/companion.ts +13 -0
- package/src/cli/commands/studio/views/studio-client/index.ts +32 -0
- package/src/cli/commands/studio/views/studio-client/keys.ts +3 -0
- package/src/cli/commands/studio/views/studio-client/main.ts +3 -0
- package/src/cli/commands/studio/views/studio-client/page.ts +146 -0
- package/src/cli/commands/studio/views/studio-client/panels.ts +57 -0
- package/src/cli/commands/studio/views/studio-client/reports.ts +122 -0
- package/src/cli/commands/studio/views/studio-client/theme.ts +63 -0
- package/src/cli/commands/studio/views/studio-client/vault-editor.ts +166 -0
- package/src/cli/commands/studio/views/studio-client/vault-pane.ts +202 -0
- package/src/cli/commands/studio/views/studio-client/vault-tree.ts +258 -0
- package/src/cli/commands/studio/views/studio-client.generated.ts +2 -0
- package/src/cli/commands/studio/views/styles.ts +217 -206
- package/src/cli/commands/studio/views/vault/state.ts +3 -5
- package/src/cli/commands/studio/views/workflow/delivery-path.tsx +370 -0
- package/src/cli/commands/studio/views/workflow/graph.ts +220 -0
- package/src/cli/commands/studio/views/workflow/index.tsx +2 -3
- package/src/cli/commands/studio/views/workflow/steps.ts +228 -287
- package/src/cli/commands/wrap.ts +9 -42
- package/src/cli/main.ts +4 -1
- package/src/cli/parse-flags.ts +25 -7
- package/src/cli/setup-selector.tsx +38 -162
- package/src/cli/usage.ts +5 -2
- package/src/hooks/companion-instructions.ts +190 -0
- package/src/hooks/hook-io.ts +63 -0
- package/src/hooks/session-banner.ts +21 -29
- package/src/hooks/session-guidance.ts +10 -36
- package/src/hooks/session-prepare.ts +115 -28
- package/src/hooks/validate-artifact-path.ts +82 -24
- package/src/index.ts +4 -0
- package/src/lib/change-layout.ts +154 -0
- package/src/lib/components/wizard-header.tsx +3 -1
- package/src/lib/context-mode-package.ts +24 -11
- package/src/lib/delivery/archive-change.ts +154 -0
- package/src/lib/delivery/branches.ts +108 -0
- package/src/lib/delivery/environment.ts +29 -0
- package/src/lib/delivery/forge.ts +279 -0
- package/src/lib/delivery/overlap.ts +202 -0
- package/src/lib/delivery/record-delivery.ts +196 -0
- package/src/lib/delivery/record.ts +343 -0
- package/src/lib/delivery/refresh.ts +174 -0
- package/src/lib/delivery/repository-access.ts +97 -0
- package/src/lib/delivery/stages.ts +111 -0
- package/src/lib/delivery/token.ts +58 -0
- package/src/lib/frontmatter.ts +18 -0
- package/src/lib/opencode-plugin-package.ts +5 -5
- package/src/lib/openspec-cli.ts +116 -0
- package/src/lib/openspec-scopes.ts +86 -0
- package/src/lib/orchestrator/adapters/claude-args.ts +50 -0
- package/src/lib/orchestrator/adapters/claude.ts +60 -162
- package/src/lib/orchestrator/adapters/opencode.ts +9 -24
- package/src/lib/orchestrator/config-store.ts +2 -70
- package/src/lib/orchestrator/editor.ts +9 -9
- package/src/lib/orchestrator/openspec-store.ts +14 -54
- package/src/lib/orchestrator/projection-claude-commands.ts +36 -0
- package/src/lib/orchestrator/projection-claude-links.ts +159 -0
- package/src/lib/orchestrator/projection-claude-skills.ts +19 -190
- package/src/lib/orchestrator/projection-entries.ts +45 -15
- package/src/lib/orchestrator/projection-runtime-documents.ts +15 -6
- package/src/lib/orchestrator/projection-types.ts +3 -2
- package/src/lib/orchestrator/refresh-working-claude-links.ts +87 -0
- package/src/lib/orchestrator/session-prepare.ts +49 -87
- package/src/lib/orchestrator/setup-compatibilities.ts +18 -75
- package/src/lib/orchestrator/types.ts +2 -2
- package/src/lib/orchestrator/wrap-pass.ts +22 -88
- package/src/lib/package-paths.ts +8 -0
- package/src/opencode/companion-hooks.ts +64 -5
- package/src/opencode/companion-policy.ts +37 -18
- package/src/opencode/projected-guidance.ts +4 -1
- package/src/opencode/session-prepare.ts +2 -1
- package/src/opencode/tui.tsx +4 -2
- package/src/runtime/artifact-refusal.ts +63 -0
- package/src/runtime/claude-delivery-plan.ts +89 -7
- package/src/runtime/commit-attribution-guard.ts +181 -0
- package/src/runtime/companion-guidance.ts +15 -3
- package/src/runtime/companion-instructions.ts +340 -0
- package/src/runtime/error-message.ts +4 -0
- package/src/runtime/guidance.ts +0 -17
- package/src/runtime/index.ts +5 -0
- package/src/runtime/managed-block.ts +117 -0
- package/src/runtime/markdown-scan.ts +266 -0
- package/src/runtime/policy.ts +16 -15
- package/src/runtime/projected-flag.ts +7 -0
- package/src/runtime/projected-guidance.ts +1 -0
- package/src/runtime/runtime-document-manifest.ts +62 -28
- package/src/runtime/secret-guard.ts +148 -0
- package/src/runtime/secret-patterns.ts +164 -0
- package/src/runtime/secret-scan.ts +90 -0
- package/src/runtime/session-kind.ts +13 -0
- package/src/runtime/shell-heredoc.ts +81 -0
- package/src/runtime/wrap-state.ts +36 -6
- package/src/runtime/write-atomic.ts +64 -0
- package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +8 -6
- package/src/templates/capabilities/openspec-cap/mate-minimal/templates/spec.md +3 -5
- package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +7 -9
- package/src/templates/capabilities/openspec-cap/mate-v1/templates/spec.md +3 -5
- package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +10 -12
- package/src/templates/mate-skills/THIRD-PARTY-NOTICES.md +40 -0
- package/src/templates/mate-skills/agents/mate-apply-change/SKILL.md +76 -0
- package/src/templates/mate-skills/agents/mate-apply-change/references/openspec-apply-change.md +198 -0
- package/src/templates/mate-skills/agents/mate-archive-change/SKILL.md +59 -0
- package/src/templates/mate-skills/{claude/mate-openspec-backfill → agents/mate-backfill-spec}/SKILL.md +4 -4
- package/src/templates/mate-skills/agents/mate-clarify-intent/SKILL.md +95 -0
- package/src/templates/mate-skills/agents/mate-explore-change/SKILL.md +50 -0
- package/src/templates/mate-skills/agents/mate-explore-change/references/openspec-explore.md +348 -0
- package/src/templates/mate-skills/agents/mate-grill-me/SKILL.md +3 -3
- package/src/templates/mate-skills/agents/mate-interview-me/SKILL.md +5 -5
- package/src/templates/mate-skills/agents/mate-propose-change/SKILL.md +103 -0
- package/src/templates/mate-skills/agents/mate-propose-change/references/openspec-propose.md +167 -0
- package/src/templates/mate-skills/agents/mate-propose-change/references/openspec-update-change.md +101 -0
- package/src/templates/mate-skills/agents/{mate-artifact-publish → mate-publish-artifact}/SKILL.md +91 -23
- package/src/templates/mate-skills/agents/{mate-artifact-publish → mate-publish-artifact}/references/openspec.md +4 -3
- package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +74 -16
- package/src/templates/mate-skills/agents/mate-simplify-code/SKILL.md +74 -68
- package/src/templates/mate-skills/claude/mate-apply-change/SKILL.md +76 -0
- package/src/templates/mate-skills/claude/mate-apply-change/references/openspec-apply-change.md +198 -0
- package/src/templates/mate-skills/claude/mate-archive-change/SKILL.md +59 -0
- package/src/templates/mate-skills/{agents/mate-openspec-backfill → claude/mate-backfill-spec}/SKILL.md +4 -4
- package/src/templates/mate-skills/claude/mate-clarify-intent/SKILL.md +95 -0
- package/src/templates/mate-skills/claude/mate-explore-change/SKILL.md +50 -0
- package/src/templates/mate-skills/claude/mate-explore-change/references/openspec-explore.md +348 -0
- package/src/templates/mate-skills/claude/mate-grill-me/SKILL.md +3 -3
- package/src/templates/mate-skills/claude/mate-interview-me/SKILL.md +5 -5
- package/src/templates/mate-skills/claude/mate-propose-change/SKILL.md +103 -0
- package/src/templates/mate-skills/claude/mate-propose-change/references/openspec-propose.md +167 -0
- package/src/templates/mate-skills/claude/mate-propose-change/references/openspec-update-change.md +101 -0
- package/src/templates/mate-skills/claude/{mate-artifact-publish → mate-publish-artifact}/SKILL.md +91 -23
- package/src/templates/mate-skills/claude/{mate-artifact-publish → mate-publish-artifact}/references/openspec.md +4 -3
- package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +74 -16
- package/src/templates/mate-skills/claude/mate-simplify-code/SKILL.md +74 -68
- package/src/templates/mate-skills/openspec-references.ts +140 -0
- package/src/templates/root/TEMPLATE_AGENTS.md +2 -2
- package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +3727 -320
- package/src/tools/setup/capabilities/context-mode.ts +25 -5
- package/src/tools/setup/capabilities/graphify-gate.ts +31 -0
- package/src/tools/setup/capabilities/graphify-secrets.ts +255 -0
- package/src/tools/setup/capabilities/graphify.ts +12 -0
- package/src/tools/setup/capabilities/openspec.ts +273 -270
- package/src/tools/setup/capabilities/tokensave.ts +36 -3
- package/src/tools/setup/context-services.ts +9 -17
- package/src/tools/setup/dynamic-plugins/audiences.ts +7 -39
- package/src/tools/setup/dynamic-plugins/declarations.ts +1 -1
- package/src/tools/setup/dynamic-plugins/hydrate.ts +4 -7
- package/src/tools/setup/dynamic-plugins/install.ts +24 -28
- package/src/tools/setup/engine.ts +93 -148
- package/src/tools/setup/managed-output.ts +51 -0
- package/src/tools/setup/mate.ts +86 -11
- package/src/tools/setup/plugin.ts +37 -17
- package/src/tools/setup/plugins/gitignore.ts +1 -7
- package/src/tools/setup/plugins/guidance.ts +7 -4
- package/src/tools/setup/providers/agent-file-sections.ts +4 -2
- package/src/tools/setup/providers/claude-attribution.ts +65 -0
- package/src/tools/setup/providers/claude-plugin-folders.ts +625 -0
- package/src/tools/setup/providers/claude-plugin-hooks.ts +39 -19
- package/src/tools/setup/providers/claude.ts +285 -34
- package/src/tools/setup/providers/opencode.ts +180 -11
- package/src/tools/setup/registry.ts +2 -24
- package/src/tools/setup/surface-target.ts +4 -13
- package/src/tools/setup/utils.ts +14 -4
- package/src/tools/setup.ts +131 -18
- package/wrappers/bin/graphify +52 -4
- package/wrappers/bin/openspec +33 -9
- package/wrappers/lib/graphify-gate.mjs +8 -0
- package/opencode-plugin/src/audience-layer.ts +0 -91
- package/src/cli/commands/studio/views/workflow/transcript.tsx +0 -190
- package/src/runtime/audience-layer.ts +0 -84
- package/src/runtime/opencode-audience-layer.ts +0 -176
- package/src/tools/setup/audience-isolation.fixture.ts +0 -325
- package/src/tools/setup/audience-layer.ts +0 -207
- package/src/tools/setup/audience-registrations.ts +0 -52
- package/src/tools/setup/contribution-ledger.ts +0 -207
- package/src/tools/setup/isolation-diagnostics.ts +0 -139
- package/src/tools/setup/runtime-documents.ts +0 -291
- package/src/tools/setup/upgrade-sweep.ts +0 -153
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import type { ProtectionReason } from "./branches";
|
|
2
|
+
import { locateChange, type ChangeLocation } from "../change-layout";
|
|
3
|
+
import { companionRelative, type DeliveryEnvironment } from "./environment";
|
|
4
|
+
import {
|
|
5
|
+
defaultDeliveryBranch,
|
|
6
|
+
findDelivery,
|
|
7
|
+
readOrUnlinkedRecord,
|
|
8
|
+
settleChangeDir,
|
|
9
|
+
upsertDelivery,
|
|
10
|
+
writeDeliveryRecord,
|
|
11
|
+
type DeliveryRecord,
|
|
12
|
+
type DeliverySettlement,
|
|
13
|
+
type MergeState,
|
|
14
|
+
} from "./record";
|
|
15
|
+
import {
|
|
16
|
+
refreshRecord,
|
|
17
|
+
type BranchOutcome,
|
|
18
|
+
type RefreshAccess,
|
|
19
|
+
type RefreshCache,
|
|
20
|
+
} from "./refresh";
|
|
21
|
+
import {
|
|
22
|
+
repositoryAccessFor,
|
|
23
|
+
type RepositoryAccess,
|
|
24
|
+
type RepositoryAccessDeps,
|
|
25
|
+
} from "./repository-access";
|
|
26
|
+
|
|
27
|
+
/** Edits to one repository's delivery, applied in this order around an optional refresh. */
|
|
28
|
+
export interface DeliveryEdit {
|
|
29
|
+
delivery?: { branch: string; direct: boolean } | { notApplicable: string };
|
|
30
|
+
/** Informational PR URL; also picks the PR a refresh asks about. */
|
|
31
|
+
pr?: string;
|
|
32
|
+
/** The user's answer, recorded after any refresh with source `user`. */
|
|
33
|
+
confirm?: MergeState;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface DeliveryRequest {
|
|
37
|
+
changes: string[];
|
|
38
|
+
/**
|
|
39
|
+
* The delivery to edit, and the only one refreshed unless `refreshAll`. Without it the
|
|
40
|
+
* Working Repository's delivery is edited and every delivery is refreshed.
|
|
41
|
+
*/
|
|
42
|
+
repository?: string;
|
|
43
|
+
edit?: DeliveryEdit;
|
|
44
|
+
/** Ask the forge for each branch's merge state; a direct delivery needs the user's answer instead. */
|
|
45
|
+
refresh?: boolean;
|
|
46
|
+
/** Refresh every delivery even when `repository` names the one to edit. */
|
|
47
|
+
refreshAll?: boolean;
|
|
48
|
+
/** Archived changes may be edited; otherwise only active ones are found. */
|
|
49
|
+
includeArchived?: boolean;
|
|
50
|
+
/** A change left without any delivery answers `needsBranch` and nothing is written. */
|
|
51
|
+
requireDelivery?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type DeliveryRefusal =
|
|
55
|
+
| { reason: "unknown-change"; change: string }
|
|
56
|
+
| { reason: "no-repository" }
|
|
57
|
+
| { reason: "protected-branch"; branch: string; protection: ProtectionReason | null }
|
|
58
|
+
| {
|
|
59
|
+
reason: "no-delivery-branch";
|
|
60
|
+
change: string;
|
|
61
|
+
repository: string;
|
|
62
|
+
for: "pr" | "confirm";
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export interface DeliveryResult {
|
|
66
|
+
change: string;
|
|
67
|
+
/** Companion-relative path of the change directory. */
|
|
68
|
+
path: string;
|
|
69
|
+
archived: boolean;
|
|
70
|
+
record: DeliveryRecord;
|
|
71
|
+
settlement: DeliverySettlement;
|
|
72
|
+
/** Lookups made by the refresh, including the ones the user must answer. */
|
|
73
|
+
outcomes: BranchOutcome[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface NeedsBranch {
|
|
77
|
+
change: string;
|
|
78
|
+
record: DeliveryRecord;
|
|
79
|
+
/** `change/<change-name>` when that branch exists in the Working Repository. */
|
|
80
|
+
suggestedBranch: string | null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type DeliveryOutcome =
|
|
84
|
+
| { refusal: DeliveryRefusal }
|
|
85
|
+
| { needsBranch: NeedsBranch }
|
|
86
|
+
/** Every record is already written when the request edited or refreshed. */
|
|
87
|
+
| { results: DeliveryResult[] };
|
|
88
|
+
|
|
89
|
+
export interface RecordDeliveryDeps extends RepositoryAccessDeps {
|
|
90
|
+
now?: () => Date;
|
|
91
|
+
repositoryAccess?: (repository: string) => RepositoryAccess;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function hasDeliveryBranch(record: DeliveryRecord, repository: string): boolean {
|
|
95
|
+
return Boolean(findDelivery(record, repository)?.branch);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Reads, edits, refreshes, and writes the Delivery Records of `request.changes`. Every
|
|
100
|
+
* change resolves before any record is written, so a refusal leaves no partial update; a
|
|
101
|
+
* request without an edit or refresh only reads. Network requests go only to the forge:
|
|
102
|
+
* for another repository's default branch, and to refresh. `--direct` is
|
|
103
|
+
* accepted on any branch; a protected one requires it.
|
|
104
|
+
*/
|
|
105
|
+
export async function recordDelivery(
|
|
106
|
+
environment: DeliveryEnvironment,
|
|
107
|
+
request: DeliveryRequest,
|
|
108
|
+
deps: RecordDeliveryDeps = {},
|
|
109
|
+
): Promise<DeliveryOutcome> {
|
|
110
|
+
const { edit = {} } = request;
|
|
111
|
+
const now = deps.now ?? (() => new Date());
|
|
112
|
+
const access = deps.repositoryAccess ?? repositoryAccessFor(environment, deps);
|
|
113
|
+
|
|
114
|
+
const located: Array<ChangeLocation & { change: string }> = [];
|
|
115
|
+
for (const change of request.changes) {
|
|
116
|
+
const location = await locateChange(environment.companionPath, change);
|
|
117
|
+
if (!location || (location.archived && !request.includeArchived)) {
|
|
118
|
+
return { refusal: { reason: "unknown-change", change } };
|
|
119
|
+
}
|
|
120
|
+
located.push({ ...location, change });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const repository = request.repository ?? environment.workingRepo?.slug ?? null;
|
|
124
|
+
const edits = edit.delivery !== undefined || edit.pr !== undefined || edit.confirm !== undefined;
|
|
125
|
+
if (edits && !repository) return { refusal: { reason: "no-repository" } };
|
|
126
|
+
if (repository && edit.delivery && "branch" in edit.delivery) {
|
|
127
|
+
const { branch, direct } = edit.delivery;
|
|
128
|
+
const protection = await access(repository).protection(branch);
|
|
129
|
+
if (protection.protected && !direct) {
|
|
130
|
+
return { refusal: { reason: "protected-branch", branch, protection: protection.reason } };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const refreshAccess: RefreshAccess = {
|
|
135
|
+
now,
|
|
136
|
+
forgeAccess: (repo) => access(repo).forge(),
|
|
137
|
+
};
|
|
138
|
+
const cache: RefreshCache = new Map();
|
|
139
|
+
const resolved: Array<DeliveryResult & { dir: string }> = [];
|
|
140
|
+
|
|
141
|
+
for (const { change, dir, archived } of located) {
|
|
142
|
+
let record = await readOrUnlinkedRecord(dir, change);
|
|
143
|
+
if (repository && edit.delivery) {
|
|
144
|
+
record = upsertDelivery(record, repository, edit.delivery);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (request.requireDelivery && record.deliveries.length === 0) {
|
|
148
|
+
const suggested = defaultDeliveryBranch(change);
|
|
149
|
+
const exists = (await environment.workingRepo?.git.branchExists(suggested)) ?? false;
|
|
150
|
+
return { needsBranch: { change, record, suggestedBranch: exists ? suggested : null } };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (repository && edit.pr !== undefined) {
|
|
154
|
+
if (!hasDeliveryBranch(record, repository)) {
|
|
155
|
+
return { refusal: { reason: "no-delivery-branch", change, repository, for: "pr" } };
|
|
156
|
+
}
|
|
157
|
+
record = upsertDelivery(record, repository, { pr: edit.pr });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let outcomes: BranchOutcome[] = [];
|
|
161
|
+
if (request.refresh) {
|
|
162
|
+
const refreshed = await refreshRecord(record, {
|
|
163
|
+
...refreshAccess,
|
|
164
|
+
cache,
|
|
165
|
+
...(request.repository && !request.refreshAll ? { repository: request.repository } : {}),
|
|
166
|
+
});
|
|
167
|
+
record = refreshed.record;
|
|
168
|
+
outcomes = refreshed.outcomes;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (repository && edit.confirm !== undefined) {
|
|
172
|
+
if (!hasDeliveryBranch(record, repository)) {
|
|
173
|
+
return { refusal: { reason: "no-delivery-branch", change, repository, for: "confirm" } };
|
|
174
|
+
}
|
|
175
|
+
record = upsertDelivery(record, repository, {
|
|
176
|
+
merge: { state: edit.confirm, source: "user", checkedAt: now().toISOString() },
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
resolved.push({
|
|
181
|
+
dir,
|
|
182
|
+
change: record.change,
|
|
183
|
+
path: companionRelative(environment, dir),
|
|
184
|
+
archived,
|
|
185
|
+
record,
|
|
186
|
+
settlement: await settleChangeDir(dir, record),
|
|
187
|
+
outcomes,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (edits || request.refresh) {
|
|
192
|
+
for (const { dir, record } of resolved) await writeDeliveryRecord(dir, record);
|
|
193
|
+
}
|
|
194
|
+
const results = resolved.map(({ dir: _dir, ...result }) => result);
|
|
195
|
+
return { results };
|
|
196
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { parse, stringify } from "yaml";
|
|
5
|
+
import { errorMessage } from "../../runtime/error-message";
|
|
6
|
+
import { changeNameOf } from "../change-layout";
|
|
7
|
+
import { scopedRepositories } from "../openspec-scopes";
|
|
8
|
+
|
|
9
|
+
/** The Delivery Record's file name at a change root; it moves with the change into its archive. */
|
|
10
|
+
export const DELIVERY_FILE = "delivery.yaml";
|
|
11
|
+
|
|
12
|
+
/** A merge result: `open` is "not yet merged", `closed-unmerged` is "dropped". */
|
|
13
|
+
export type MergeState = "merged" | "open" | "closed-unmerged";
|
|
14
|
+
|
|
15
|
+
/** Where a merge result came from; a user answer is recorded as such, never as forge truth. */
|
|
16
|
+
export type MergeSource = "forge" | "user";
|
|
17
|
+
|
|
18
|
+
/** What the forge reports about a merge, when it reports anything. */
|
|
19
|
+
export interface MergeProvenance {
|
|
20
|
+
mergedAt?: string;
|
|
21
|
+
mergeCommit?: string;
|
|
22
|
+
/** Provenance only; a merger is never presented as an artifact reviewer. */
|
|
23
|
+
mergedBy?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface MergeResult extends MergeProvenance {
|
|
27
|
+
state: MergeState;
|
|
28
|
+
source: MergeSource;
|
|
29
|
+
/** When the result was recorded (ISO 8601). */
|
|
30
|
+
checkedAt: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The provenance fields `source` carries, without empty ones. */
|
|
34
|
+
export function pickProvenance(source: MergeProvenance): MergeProvenance {
|
|
35
|
+
return {
|
|
36
|
+
...(source.mergedAt ? { mergedAt: source.mergedAt } : {}),
|
|
37
|
+
...(source.mergeCommit ? { mergeCommit: source.mergeCommit } : {}),
|
|
38
|
+
...(source.mergedBy ? { mergedBy: source.mergedBy } : {}),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** One Working Repository's delivery: a branch, or a reason no branch applies. */
|
|
43
|
+
export interface Delivery {
|
|
44
|
+
/** Canonical `org/repository` of the Working Repository. */
|
|
45
|
+
repository: string;
|
|
46
|
+
branch?: string;
|
|
47
|
+
/** True when the branch is protected and the developer opted into direct delivery. */
|
|
48
|
+
direct?: boolean;
|
|
49
|
+
/** Informational PR URL; the branch, not the PR, is what a change is delivered through. */
|
|
50
|
+
pr?: string;
|
|
51
|
+
merge?: MergeResult;
|
|
52
|
+
notApplicable?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface DeliveryRecord {
|
|
56
|
+
version: 1;
|
|
57
|
+
change: string;
|
|
58
|
+
deliveries: Delivery[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type DeliveryPatch = Partial<Omit<Delivery, "repository">>;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* - `legacy`: no record (archived before Delivery Records, or past `mate artifact archive`)
|
|
65
|
+
* - `unlinked`: a record without any delivery
|
|
66
|
+
* - `not-applicable`: every delivery carries a not-applicable reason
|
|
67
|
+
* - `merged`: every branch delivery is merged (the rest not applicable)
|
|
68
|
+
* - `dropped`: some branch was closed without merging
|
|
69
|
+
* - `waiting`: some branch has no merge result yet, or is still open, or a scoped
|
|
70
|
+
* repository has no delivery recorded
|
|
71
|
+
*/
|
|
72
|
+
export type DeliveryStatus =
|
|
73
|
+
| "legacy"
|
|
74
|
+
| "unlinked"
|
|
75
|
+
| "not-applicable"
|
|
76
|
+
| "merged"
|
|
77
|
+
| "dropped"
|
|
78
|
+
| "waiting";
|
|
79
|
+
|
|
80
|
+
export interface DeliverySettlement {
|
|
81
|
+
status: DeliveryStatus;
|
|
82
|
+
/** Repositories whose delivery keeps the change from being publishable. */
|
|
83
|
+
unsettled: string[];
|
|
84
|
+
/** Scoped repositories without any delivery, branch or not-applicable; a subset of `unsettled`. */
|
|
85
|
+
missing: string[];
|
|
86
|
+
/** Recorded repositories matching none of the change's scopes; empty when it has none. */
|
|
87
|
+
unscoped: UnscopedDelivery[];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface UnscopedDelivery {
|
|
91
|
+
repository: string;
|
|
92
|
+
/** The scoped spelling it most likely means, when one is obvious. */
|
|
93
|
+
closest?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Whether an archive with this status may be published; anything else is held back. A
|
|
98
|
+
* `legacy` archive (made before Delivery Records) publishes, with a warning that its merge
|
|
99
|
+
* was not checked.
|
|
100
|
+
*/
|
|
101
|
+
export function isPublishable(status: DeliveryStatus): boolean {
|
|
102
|
+
return status === "merged" || status === "not-applicable" || status === "legacy";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The warning a `legacy` archive publishes with. */
|
|
106
|
+
export function legacyDeliveryWarning(anchorName: string): string {
|
|
107
|
+
return `${anchorName} has no delivery.yaml, so its merge was not checked; publishing it as a legacy archive.`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function defaultDeliveryBranch(change: string): string {
|
|
111
|
+
return `change/${change}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function unlinkedRecord(change: string): DeliveryRecord {
|
|
115
|
+
return { version: 1, change, deliveries: [] };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** GitHub slugs are case-insensitive, so `Acme/Api` and `acme/api` name one repository. */
|
|
119
|
+
export function sameRepository(a: string, b: string): boolean {
|
|
120
|
+
return a.toLowerCase() === b.toLowerCase();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function findDelivery(record: DeliveryRecord, repository: string): Delivery | undefined {
|
|
124
|
+
return record.deliveries.find((entry) => sameRepository(entry.repository, repository));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Applies `patch` to the repository's delivery, creating it when absent; an existing
|
|
129
|
+
* delivery keeps its spelling of the repository. A branch and a not-applicable reason
|
|
130
|
+
* exclude each other, and a new branch or a switch between direct and PR delivery drops
|
|
131
|
+
* the previous merge result and PR, which described a different delivery; a different PR
|
|
132
|
+
* drops the merge result.
|
|
133
|
+
*/
|
|
134
|
+
export function upsertDelivery(
|
|
135
|
+
record: DeliveryRecord,
|
|
136
|
+
requested: string,
|
|
137
|
+
patch: DeliveryPatch,
|
|
138
|
+
): DeliveryRecord {
|
|
139
|
+
const existing = findDelivery(record, requested);
|
|
140
|
+
const repository = existing?.repository ?? requested;
|
|
141
|
+
let next: Delivery = { ...existing, repository };
|
|
142
|
+
if (patch.notApplicable !== undefined) {
|
|
143
|
+
next = { repository, notApplicable: patch.notApplicable };
|
|
144
|
+
} else {
|
|
145
|
+
const newBranch = patch.branch !== undefined && patch.branch !== next.branch;
|
|
146
|
+
const newMode = patch.direct !== undefined && patch.direct !== Boolean(next.direct);
|
|
147
|
+
if (newBranch || newMode) {
|
|
148
|
+
next = { repository, branch: patch.branch ?? next.branch };
|
|
149
|
+
}
|
|
150
|
+
if (patch.direct !== undefined) next.direct = patch.direct;
|
|
151
|
+
if (patch.pr !== undefined && patch.pr !== next.pr) {
|
|
152
|
+
delete next.merge;
|
|
153
|
+
next.pr = patch.pr;
|
|
154
|
+
}
|
|
155
|
+
if (patch.merge !== undefined) next.merge = patch.merge;
|
|
156
|
+
if (next.direct === false) delete next.direct;
|
|
157
|
+
}
|
|
158
|
+
const deliveries = existing
|
|
159
|
+
? record.deliveries.map((entry) => (entry === existing ? next : entry))
|
|
160
|
+
: [...record.deliveries, next];
|
|
161
|
+
return { ...record, deliveries };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Settles a record against the repositories the change is scoped to: each needs its own
|
|
166
|
+
* delivery, so linking only one repository of a cross-repository change settles nothing.
|
|
167
|
+
*/
|
|
168
|
+
export function settleDelivery(
|
|
169
|
+
record: DeliveryRecord | null,
|
|
170
|
+
scoped: readonly string[] = [],
|
|
171
|
+
): DeliverySettlement {
|
|
172
|
+
if (!record) return { status: "legacy", unsettled: [], missing: [], unscoped: [] };
|
|
173
|
+
const missing = scoped.filter((repository) => !findDelivery(record, repository));
|
|
174
|
+
const unscoped = unscopedDeliveries(record, scoped);
|
|
175
|
+
if (record.deliveries.length === 0) {
|
|
176
|
+
return { status: "unlinked", unsettled: missing, missing, unscoped };
|
|
177
|
+
}
|
|
178
|
+
const branches = record.deliveries.filter((entry) => entry.notApplicable === undefined);
|
|
179
|
+
const unsettled = [
|
|
180
|
+
...branches.filter((entry) => entry.merge?.state !== "merged").map((entry) => entry.repository),
|
|
181
|
+
...missing,
|
|
182
|
+
];
|
|
183
|
+
if (unsettled.length === 0) {
|
|
184
|
+
const status = branches.length === 0 ? "not-applicable" : "merged";
|
|
185
|
+
return { status, unsettled, missing, unscoped };
|
|
186
|
+
}
|
|
187
|
+
const dropped = branches.some((entry) => entry.merge?.state === "closed-unmerged");
|
|
188
|
+
return { status: dropped ? "dropped" : "waiting", unsettled, missing, unscoped };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function unscopedDeliveries(record: DeliveryRecord, scoped: readonly string[]): UnscopedDelivery[] {
|
|
192
|
+
if (scoped.length === 0) return [];
|
|
193
|
+
return record.deliveries
|
|
194
|
+
.filter((entry) => !scoped.some((repository) => sameRepository(repository, entry.repository)))
|
|
195
|
+
.map((entry) => {
|
|
196
|
+
const closest = scoped.find((repository) => likelySame(repository, entry.repository));
|
|
197
|
+
return closest ? { repository: entry.repository, closest } : { repository: entry.repository };
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Same last segment; this also covers one path being a suffix of the other (a dropped namespace). */
|
|
202
|
+
function likelySame(a: string, b: string): boolean {
|
|
203
|
+
return a.toLowerCase().split("/").at(-1) === b.toLowerCase().split("/").at(-1);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function malformed(file: string, reason: string): Error {
|
|
207
|
+
return new Error(`${file}: malformed ${DELIVERY_FILE}: ${reason}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function optionalString(value: unknown, file: string, field: string): string | undefined {
|
|
211
|
+
if (value === undefined || value === null) return undefined;
|
|
212
|
+
if (typeof value !== "string") throw malformed(file, `${field} must be a string`);
|
|
213
|
+
return value;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const MERGE_STATES: readonly MergeState[] = ["merged", "open", "closed-unmerged"];
|
|
217
|
+
const MERGE_SOURCES: readonly MergeSource[] = ["forge", "user"];
|
|
218
|
+
|
|
219
|
+
function parseMerge(value: unknown, file: string): MergeResult | undefined {
|
|
220
|
+
if (value === undefined || value === null) return undefined;
|
|
221
|
+
if (typeof value !== "object") throw malformed(file, "merge must be a mapping");
|
|
222
|
+
const merge = value as Record<string, unknown>;
|
|
223
|
+
if (!MERGE_STATES.includes(merge.state as MergeState)) {
|
|
224
|
+
throw malformed(file, `merge.state must be one of ${MERGE_STATES.join(", ")}`);
|
|
225
|
+
}
|
|
226
|
+
if (!MERGE_SOURCES.includes(merge.source as MergeSource)) {
|
|
227
|
+
throw malformed(file, `merge.source must be one of ${MERGE_SOURCES.join(", ")}`);
|
|
228
|
+
}
|
|
229
|
+
const result: MergeResult = {
|
|
230
|
+
state: merge.state as MergeState,
|
|
231
|
+
source: merge.source as MergeSource,
|
|
232
|
+
checkedAt: optionalString(merge.checkedAt, file, "merge.checkedAt") ?? "",
|
|
233
|
+
};
|
|
234
|
+
for (const key of ["mergedAt", "mergeCommit", "mergedBy"] as const) {
|
|
235
|
+
const field = optionalString(merge[key], file, `merge.${key}`);
|
|
236
|
+
if (field !== undefined) result[key] = field;
|
|
237
|
+
}
|
|
238
|
+
return result;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function parseDelivery(value: unknown, file: string): Delivery {
|
|
242
|
+
if (!value || typeof value !== "object") throw malformed(file, "a delivery must be a mapping");
|
|
243
|
+
const entry = value as Record<string, unknown>;
|
|
244
|
+
const repository = optionalString(entry.repository, file, "repository");
|
|
245
|
+
if (!repository) throw malformed(file, "every delivery names its repository");
|
|
246
|
+
const delivery: Delivery = { repository };
|
|
247
|
+
const branch = optionalString(entry.branch, file, "branch");
|
|
248
|
+
const pr = optionalString(entry.pr, file, "pr");
|
|
249
|
+
const notApplicable = optionalString(entry.notApplicable, file, "notApplicable");
|
|
250
|
+
if (branch !== undefined) delivery.branch = branch;
|
|
251
|
+
if (entry.direct === true) delivery.direct = true;
|
|
252
|
+
if (pr !== undefined) delivery.pr = pr;
|
|
253
|
+
const merge = parseMerge(entry.merge, file);
|
|
254
|
+
if (merge) delivery.merge = merge;
|
|
255
|
+
if (notApplicable !== undefined) delivery.notApplicable = notApplicable;
|
|
256
|
+
return delivery;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Reads `delivery.yaml` from a change directory, active or archived. Null means no file:
|
|
261
|
+
* a legacy change. A file that exists but cannot be understood throws, so a damaged
|
|
262
|
+
* record is never mistaken for a legacy archive and published past the merge gate.
|
|
263
|
+
*/
|
|
264
|
+
export async function readDeliveryRecord(changeDir: string): Promise<DeliveryRecord | null> {
|
|
265
|
+
const file = path.join(changeDir, DELIVERY_FILE);
|
|
266
|
+
let source: string;
|
|
267
|
+
try {
|
|
268
|
+
source = await fs.readFile(file, "utf8");
|
|
269
|
+
} catch (error) {
|
|
270
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
271
|
+
throw error;
|
|
272
|
+
}
|
|
273
|
+
let parsed: unknown;
|
|
274
|
+
try {
|
|
275
|
+
parsed = parse(source);
|
|
276
|
+
} catch (error) {
|
|
277
|
+
throw malformed(file, errorMessage(error));
|
|
278
|
+
}
|
|
279
|
+
if (!parsed || typeof parsed !== "object") throw malformed(file, "not a mapping");
|
|
280
|
+
const document = parsed as Record<string, unknown>;
|
|
281
|
+
const deliveries = document.deliveries ?? [];
|
|
282
|
+
if (!Array.isArray(deliveries)) throw malformed(file, "deliveries must be a list");
|
|
283
|
+
return {
|
|
284
|
+
version: 1,
|
|
285
|
+
change:
|
|
286
|
+
optionalString(document.change, file, "change") ?? changeNameOf(path.basename(changeDir)),
|
|
287
|
+
deliveries: deliveries.map((entry) => parseDelivery(entry, file)),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The change's record, or an unlinked one when none was written yet. The name comes from
|
|
293
|
+
* `change`, not the file, so a renamed change's record follows its directory.
|
|
294
|
+
*/
|
|
295
|
+
export async function readOrUnlinkedRecord(
|
|
296
|
+
changeDir: string,
|
|
297
|
+
change: string,
|
|
298
|
+
): Promise<DeliveryRecord> {
|
|
299
|
+
const name = changeNameOf(change);
|
|
300
|
+
const record = await readDeliveryRecord(changeDir);
|
|
301
|
+
return record ? { ...record, change: name } : unlinkedRecord(name);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** A change's record with its settlement; `error` names a record that exists but cannot be read. */
|
|
305
|
+
export interface DeliveryState extends DeliverySettlement {
|
|
306
|
+
record: DeliveryRecord | null;
|
|
307
|
+
error?: string;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** The change's settlement against its own scopes; see {@link settleDelivery}. */
|
|
311
|
+
export async function settleChangeDir(
|
|
312
|
+
changeDir: string,
|
|
313
|
+
record: DeliveryRecord | null,
|
|
314
|
+
): Promise<DeliverySettlement> {
|
|
315
|
+
return settleDelivery(record, await scopedRepositories(changeDir));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Like {@link readDeliveryRecord}, settled against the change's scopes; a damaged record is
|
|
320
|
+
* reported as `unlinked` with its error.
|
|
321
|
+
*/
|
|
322
|
+
export async function readDeliveryState(changeDir: string): Promise<DeliveryState> {
|
|
323
|
+
try {
|
|
324
|
+
const record = await readDeliveryRecord(changeDir);
|
|
325
|
+
return { ...(await settleChangeDir(changeDir, record)), record };
|
|
326
|
+
} catch (error) {
|
|
327
|
+
return {
|
|
328
|
+
status: "unlinked",
|
|
329
|
+
unsettled: [],
|
|
330
|
+
missing: [],
|
|
331
|
+
unscoped: [],
|
|
332
|
+
record: null,
|
|
333
|
+
error: errorMessage(error),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export async function writeDeliveryRecord(
|
|
339
|
+
changeDir: string,
|
|
340
|
+
record: DeliveryRecord,
|
|
341
|
+
): Promise<void> {
|
|
342
|
+
await fs.writeFile(path.join(changeDir, DELIVERY_FILE), stringify(record), "utf8");
|
|
343
|
+
}
|