@uniqbit/mate-core 0.15.5 → 0.16.0-canary.1
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/.claude-plugin/plugin.json +2 -2
- package/claude-plugin/hooks/hooks.json +3 -6
- package/claude-plugin/hooks/session-guidance.mjs +8 -0
- package/claude-plugin/hooks/ts-loader.mjs +19 -0
- package/package.json +7 -5
- package/src/cli/commands/artifact/artifact.ts +19 -3
- package/src/cli/commands/artifact/finish/command.ts +183 -57
- package/src/cli/commands/artifact/finish/engine.ts +80 -91
- package/src/cli/commands/artifact/finish/finisher.ts +26 -33
- package/src/cli/commands/artifact/finish/git.ts +34 -23
- package/src/cli/commands/artifact/finish/index.ts +9 -3
- package/src/cli/commands/artifact/finish/openspec.ts +225 -97
- package/src/cli/commands/artifact/pending/command.ts +175 -0
- package/src/cli/commands/artifact/pending/discovery.ts +249 -0
- package/src/cli/commands/artifact/pending/index.ts +17 -0
- package/src/cli/commands/cap/index-cmd.ts +9 -1
- package/src/cli/commands/cap/index.ts +2 -6
- package/src/cli/commands/cap/tokensave.ts +4 -4
- package/src/cli/commands/companion/companion.ts +5 -1
- package/src/cli/commands/companion/link.ts +2 -2
- package/src/cli/commands/companion/sync.ts +92 -0
- package/src/cli/commands/doctor.ts +0 -3
- package/src/cli/commands/launch/shared.ts +23 -5
- package/src/cli/commands/report/collector.ts +72 -78
- package/src/cli/commands/report/contract.ts +40 -1
- package/src/cli/commands/report/highlight.ts +27 -0
- package/src/cli/commands/report/index.ts +11 -18
- package/src/cli/commands/report/renderer.ts +199 -2
- package/src/cli/commands/report/types.ts +26 -1
- package/src/cli/commands/shared/companion-selection.ts +107 -10
- package/src/cli/commands/studio/areas.ts +68 -0
- package/src/cli/commands/studio/index.ts +69 -0
- package/src/cli/commands/studio/inventory.ts +55 -0
- package/src/cli/commands/studio/mate-inventory.ts +43 -0
- package/src/cli/commands/studio/openspec-cli.ts +198 -0
- package/src/cli/commands/studio/payload.ts +184 -0
- package/src/cli/commands/studio/routes.ts +2 -0
- package/src/cli/commands/studio/selection.ts +61 -0
- package/src/cli/commands/studio/server.ts +201 -0
- package/src/cli/commands/studio/snapshot.ts +63 -0
- package/src/cli/commands/studio/topology.ts +199 -0
- package/src/cli/commands/studio/views/client.ts +197 -0
- package/src/cli/commands/studio/views/companion-picker.tsx +91 -0
- package/src/cli/commands/studio/views/companion-selector.tsx +90 -0
- package/src/cli/commands/studio/views/dashboard/changes.tsx +107 -0
- package/src/cli/commands/studio/views/dashboard/index.tsx +19 -0
- package/src/cli/commands/studio/views/document.tsx +256 -0
- package/src/cli/commands/studio/views/error.tsx +20 -0
- package/src/cli/commands/studio/views/model.ts +34 -0
- package/src/cli/commands/studio/views/pairings.tsx +38 -0
- package/src/cli/commands/studio/views/skills/index.tsx +87 -0
- package/src/cli/commands/studio/views/specs/index.tsx +101 -0
- package/src/cli/commands/studio/views/styles.ts +389 -0
- package/src/cli/commands/studio/views/warnings.tsx +21 -0
- package/src/cli/commands/studio/views/workflow/index.tsx +21 -0
- package/src/cli/commands/studio/views/workflow/steps.ts +329 -0
- package/src/cli/commands/studio/views/workflow/transcript.tsx +190 -0
- package/src/cli/commands/unwrap.ts +70 -0
- package/src/cli/commands/wrap.ts +164 -0
- package/src/cli/main.ts +68 -19
- package/src/cli/parse-flags.ts +36 -11
- package/src/cli/usage.ts +12 -3
- package/src/framework.ts +1 -7
- package/src/hooks/session-banner.ts +64 -11
- package/src/hooks/session-guidance.ts +40 -0
- package/src/hooks/validate-artifact-path.ts +108 -35
- package/src/lib/fs-utils.ts +9 -0
- package/src/lib/install.ts +33 -0
- package/src/lib/orchestrator/adapters/base.ts +14 -125
- package/src/lib/orchestrator/adapters/claude.ts +0 -11
- package/src/lib/orchestrator/adapters/opencode.ts +2 -32
- package/src/lib/orchestrator/companion-git-sync.ts +94 -84
- package/src/lib/orchestrator/config-store.ts +2 -21
- package/src/lib/orchestrator/editor.ts +12 -22
- package/src/lib/orchestrator/framework-context.ts +17 -6
- package/src/lib/orchestrator/global-config-store.ts +1 -1
- package/src/lib/orchestrator/launcher.ts +97 -7
- package/src/lib/orchestrator/opencode-guidance.ts +4 -56
- package/src/lib/orchestrator/projection-claude-entry.ts +198 -0
- package/src/lib/orchestrator/projection-claude-skills.ts +120 -0
- package/src/lib/orchestrator/projection-companion-link.ts +62 -0
- package/src/lib/orchestrator/projection-entries.ts +377 -0
- package/src/lib/orchestrator/projection-record.ts +56 -0
- package/src/lib/orchestrator/projection-runtime-documents.ts +424 -0
- package/src/lib/orchestrator/projection-types.ts +169 -0
- package/src/lib/orchestrator/repo-local-registry.ts +37 -133
- package/src/lib/orchestrator/repo-local-store.ts +96 -0
- package/src/lib/orchestrator/setup-compatibilities.ts +1 -9
- package/src/lib/orchestrator/types.ts +1 -0
- package/src/lib/orchestrator/working-repo-projection.ts +366 -0
- package/src/lib/orchestrator/workspace-inventory.ts +1 -1
- package/src/lib/package-paths.ts +11 -1
- package/src/lib/public-npm.ts +2 -1
- package/src/lib/update-checker.ts +15 -9
- package/src/opencode/companion-hooks.ts +89 -245
- package/src/opencode/companion-policy.ts +35 -10
- package/src/opencode/index.ts +1 -0
- package/src/opencode/projected-guidance.ts +56 -0
- package/src/opencode/tui.tsx +13 -4
- package/src/playbooks/companion-guidance.ts +32 -116
- package/src/plugins.ts +0 -1
- package/src/runtime/companion-git-state.ts +156 -0
- package/src/runtime/companion-git.ts +203 -0
- package/src/runtime/companion-guidance.ts +222 -0
- package/src/runtime/companion-sync.ts +298 -0
- package/src/runtime/env-names.ts +30 -0
- package/src/runtime/env.ts +67 -35
- package/src/runtime/framework.ts +10 -0
- package/src/runtime/freshness.ts +58 -0
- package/src/runtime/index.ts +104 -0
- package/src/runtime/install.ts +30 -0
- package/src/runtime/policy.ts +66 -0
- package/src/runtime/projected-guidance.ts +45 -0
- package/src/runtime/projection.ts +224 -0
- package/src/runtime/repo-local.ts +64 -0
- package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +56 -0
- package/src/templates/capabilities/openspec-cap/mate-minimal/templates/spec.md +48 -0
- package/src/templates/capabilities/openspec-cap/mate-minimal/templates/tasks.md +22 -0
- package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +31 -90
- package/src/templates/capabilities/openspec-cap/mate-v1/templates/design.md +3 -0
- package/src/templates/capabilities/openspec-cap/mate-v1/templates/explore-brief.md +6 -6
- package/src/templates/capabilities/openspec-cap/mate-v1/templates/spec.md +3 -5
- package/src/templates/capabilities/openspec-cap/mate-v1/templates/tasks.md +3 -0
- package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +30 -0
- package/src/templates/capabilities/react-doctor/claude/hooks/react-doctor.sh +2 -2
- package/src/templates/mate-skills/agents/mate-artifact-publish/SKILL.md +185 -0
- package/src/templates/mate-skills/agents/mate-artifact-publish/references/openspec.md +227 -0
- package/src/templates/mate-skills/agents/mate-domain-modeling/SKILL.md +68 -0
- package/src/templates/mate-skills/agents/mate-domain-modeling/references/ADR-FORMAT.md +9 -0
- package/src/templates/mate-skills/agents/mate-domain-modeling/references/CONTEXT-FORMAT.md +18 -0
- package/src/templates/mate-skills/agents/mate-grill-me/SKILL.md +14 -0
- package/src/templates/mate-skills/agents/mate-grill-with-docs/SKILL.md +29 -0
- package/src/templates/mate-skills/agents/mate-grilling/SKILL.md +49 -0
- package/src/templates/mate-skills/agents/mate-interview-me/SKILL.md +156 -0
- package/src/templates/{capabilities/openspec-cap/mate-skills → mate-skills}/agents/mate-openspec-backfill/SKILL.md +4 -2
- package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +139 -0
- package/src/templates/mate-skills/agents/mate-simplify-code/SKILL.md +503 -0
- package/src/templates/mate-skills/claude/mate-artifact-publish/SKILL.md +185 -0
- package/src/templates/mate-skills/claude/mate-artifact-publish/references/openspec.md +227 -0
- package/src/templates/mate-skills/claude/mate-domain-modeling/SKILL.md +68 -0
- package/src/templates/mate-skills/claude/mate-domain-modeling/references/ADR-FORMAT.md +9 -0
- package/src/templates/mate-skills/claude/mate-domain-modeling/references/CONTEXT-FORMAT.md +18 -0
- package/src/templates/mate-skills/claude/mate-grill-me/SKILL.md +14 -0
- package/src/templates/mate-skills/claude/mate-grill-with-docs/SKILL.md +29 -0
- package/src/templates/mate-skills/claude/mate-grilling/SKILL.md +49 -0
- package/src/templates/mate-skills/claude/mate-interview-me/SKILL.md +156 -0
- package/src/templates/mate-skills/claude/mate-openspec-backfill/SKILL.md +67 -0
- package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +139 -0
- package/src/templates/mate-skills/claude/mate-simplify-code/SKILL.md +503 -0
- package/src/templates/report-assets/README.md +32 -0
- package/src/templates/report-assets/mermaid.LICENSE +21 -0
- package/src/templates/report-assets/mermaid.min.js +4376 -0
- package/src/templates/root/TEMPLATE_CLAUDE.md +1 -9
- package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +1476 -197
- package/src/tools/setup/capabilities/graphify.ts +16 -7
- package/src/tools/setup/capabilities/openspec.ts +63 -56
- package/src/tools/setup/capabilities/tokensave.ts +116 -2
- package/src/tools/setup/engine.ts +34 -6
- package/src/tools/setup/mate.ts +42 -13
- package/src/tools/setup/plugin.ts +9 -0
- package/src/tools/setup/plugins/guidance.ts +11 -1
- package/src/tools/setup/providers/claude-format.ts +49 -4
- package/src/tools/setup/providers/claude-plugin-hooks.ts +117 -0
- package/src/tools/setup/providers/claude.ts +55 -220
- package/src/tools/setup/providers/opencode.ts +41 -14
- package/src/tools/setup/runtime-documents.ts +174 -0
- package/src/tools/setup/surface-target.ts +50 -0
- package/src/tools/setup/working-repo-cleanup.ts +33 -26
- package/src/tools/setup/working-repo-local-state.ts +21 -1
- package/src/tools/setup.ts +25 -3
- package/wrappers/bin/graphify +57 -8
- package/wrappers/bin/openspec +50 -3
- package/claude-plugin/hooks/artifact-finish-nudge.mjs +0 -8
- package/src/cli/commands/cap/headroom.ts +0 -52
- package/src/cli/commands/workspace/list.ts +0 -25
- package/src/cli/commands/workspace/materialize.ts +0 -46
- package/src/cli/commands/workspace/workspace.ts +0 -22
- package/src/hooks/artifact-finish-nudge.ts +0 -244
- package/src/lib/orchestrator/headroom/proxy.ts +0 -116
- package/src/lib/orchestrator/workspace-materialize.ts +0 -80
- package/src/templates/capabilities/openspec-cap/mate-skills/agents/mate-artifact-finish/SKILL.md +0 -51
- package/src/templates/capabilities/openspec-cap/mate-skills/agents/mate-artifact-finish/references/openspec.md +0 -134
- package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/SKILL.md +0 -58
- package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/references/openspec.md +0 -139
- package/src/tools/setup/capabilities/headroom.ts +0 -57
- /package/src/cli/commands/{workspace → companion}/open.ts +0 -0
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
2
1
|
import fs from "node:fs/promises";
|
|
3
2
|
import path from "node:path";
|
|
4
3
|
|
|
@@ -6,34 +5,38 @@ import { parse } from "yaml";
|
|
|
6
5
|
|
|
7
6
|
import { hasOpenspecCapability } from "../../../../lib/orchestrator/capabilities";
|
|
8
7
|
import { runIndexCapCommand } from "../../cap/index-cmd";
|
|
9
|
-
import
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
companionPath: string,
|
|
13
|
-
args: string[],
|
|
14
|
-
): { status: number; stdout: string; stderr: string } {
|
|
15
|
-
const result = spawnSync(args[0], args.slice(1), {
|
|
16
|
-
cwd: companionPath,
|
|
17
|
-
encoding: "utf8",
|
|
18
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
19
|
-
});
|
|
20
|
-
return {
|
|
21
|
-
status: result.status ?? 1,
|
|
22
|
-
stdout: result.stdout ?? "",
|
|
23
|
-
stderr: result.stderr ?? "",
|
|
24
|
-
};
|
|
25
|
-
}
|
|
8
|
+
import { discoverArchives, SPECS_RELATIVE_DIR, unattributedSpecs } from "../pending/discovery";
|
|
9
|
+
import type { ArtifactFinisher, FinishContext, Produced, ResolveResult } from "./finisher";
|
|
10
|
+
import type { GitOps } from "./git";
|
|
26
11
|
|
|
27
12
|
function escapeRegExp(value: string): string {
|
|
28
13
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
29
14
|
}
|
|
30
15
|
|
|
16
|
+
async function pathExists(candidate: string): Promise<boolean> {
|
|
17
|
+
try {
|
|
18
|
+
await fs.stat(candidate);
|
|
19
|
+
return true;
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
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
|
+
/**
|
|
29
|
+
* The publication commit's exact scope. The active-change path is included only when it
|
|
30
|
+
* is gone from disk — that absence IS the deletion archiving produced. A path that still
|
|
31
|
+
* exists belongs to a same-name change started after archiving, which is unrelated work.
|
|
32
|
+
*/
|
|
31
33
|
async function commitPathsForArchive(
|
|
32
34
|
companionPath: string,
|
|
33
35
|
name: string,
|
|
34
36
|
anchorName: string,
|
|
35
37
|
): Promise<string[]> {
|
|
36
38
|
const archiveRelative = path.posix.join("openspec", "changes", "archive", anchorName);
|
|
39
|
+
const activeRelative = path.posix.join("openspec", "changes", name);
|
|
37
40
|
const deltaSpecsDir = path.join(companionPath, archiveRelative, "specs");
|
|
38
41
|
const canonicalSpecs: string[] = [];
|
|
39
42
|
const collectDeltaSpecFiles = async (directory: string, relative = ""): Promise<void> => {
|
|
@@ -53,7 +56,7 @@ async function commitPathsForArchive(
|
|
|
53
56
|
// Changes without delta specs still commit their active deletion and archive.
|
|
54
57
|
}
|
|
55
58
|
return [
|
|
56
|
-
path.
|
|
59
|
+
...((await pathExists(path.join(companionPath, activeRelative))) ? [] : [activeRelative]),
|
|
57
60
|
archiveRelative,
|
|
58
61
|
...canonicalSpecs.toSorted(),
|
|
59
62
|
];
|
|
@@ -108,7 +111,7 @@ function projectDeltaScopes(scopes: ScopePair[]): ProjectionResult {
|
|
|
108
111
|
};
|
|
109
112
|
}
|
|
110
113
|
|
|
111
|
-
async function
|
|
114
|
+
async function archivedChangeUsesMateSchema(
|
|
112
115
|
companionPath: string,
|
|
113
116
|
anchorName: string,
|
|
114
117
|
): Promise<boolean> {
|
|
@@ -124,7 +127,7 @@ async function archivedChangeUsesMateV1(
|
|
|
124
127
|
const parsed = parse(await fs.readFile(metadataPath, "utf8")) as unknown;
|
|
125
128
|
if (!parsed || typeof parsed !== "object") return false;
|
|
126
129
|
const schema = (parsed as Record<string, unknown>).schema;
|
|
127
|
-
return typeof schema === "string" && schema.trim()
|
|
130
|
+
return typeof schema === "string" && ["mate-v1", "mate-minimal"].includes(schema.trim());
|
|
128
131
|
} catch {
|
|
129
132
|
return false;
|
|
130
133
|
}
|
|
@@ -148,18 +151,20 @@ function renderCanonicalFrontmatter(
|
|
|
148
151
|
}
|
|
149
152
|
|
|
150
153
|
/**
|
|
151
|
-
*
|
|
154
|
+
* 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.
|
|
152
157
|
*
|
|
153
158
|
* `openspec archive` rebuilds a brand-new main spec from a skeleton with no frontmatter slot,
|
|
154
159
|
* so scope metadata dies exactly once, at spec birth; existing specs keep theirs because only
|
|
155
|
-
* requirement blocks are spliced.
|
|
156
|
-
* failure here warns rather than
|
|
160
|
+
* requirement blocks are spliced. Idempotent, and best-effort by design — the archive is
|
|
161
|
+
* durable input, so a failure here warns rather than refusing the publication.
|
|
157
162
|
*/
|
|
158
163
|
async function reconcileMainSpecFrontmatter(
|
|
159
164
|
companionPath: string,
|
|
160
165
|
anchorName: string,
|
|
161
166
|
): Promise<string[]> {
|
|
162
|
-
if (!(await
|
|
167
|
+
if (!(await archivedChangeUsesMateSchema(companionPath, anchorName))) return [];
|
|
163
168
|
|
|
164
169
|
const deltaSpecsDir = path.join(
|
|
165
170
|
companionPath,
|
|
@@ -253,16 +258,171 @@ async function capSync(context: FinishContext): Promise<boolean> {
|
|
|
253
258
|
}
|
|
254
259
|
}
|
|
255
260
|
|
|
261
|
+
async function resolved(
|
|
262
|
+
companionPath: string,
|
|
263
|
+
name: string,
|
|
264
|
+
anchorName: string,
|
|
265
|
+
): Promise<ResolveResult> {
|
|
266
|
+
const commitPaths = await commitPathsForArchive(companionPath, name, anchorName);
|
|
267
|
+
await reconcileMainSpecFrontmatter(companionPath, anchorName);
|
|
268
|
+
return { ok: true, resolved: { anchorName, commitPaths } };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Tag namespace segregating spec publications from dated change anchors. */
|
|
272
|
+
export const SPEC_TAG_NAMESPACE = "openspec/specs";
|
|
273
|
+
/** Spec ids named individually before the remainder collapses into a count. */
|
|
274
|
+
const SPEC_LABEL_LIMIT = 3;
|
|
275
|
+
|
|
276
|
+
/** `openspec/specs/<id>/...` -> `<id>`: the capability directory is the spec's identity. */
|
|
277
|
+
function specId(candidate: string): string {
|
|
278
|
+
return candidate.slice(`${SPECS_RELATIVE_DIR}/`.length).split("/")[0];
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Unique ids in path order, so anchor and subject always name the same specs. */
|
|
282
|
+
function specIds(paths: string[]): string[] {
|
|
283
|
+
return [...new Set(paths.map(specId))];
|
|
284
|
+
}
|
|
285
|
+
|
|
256
286
|
/**
|
|
257
|
-
* The
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
287
|
+
* The spec ids a publication ships, as `<a>+<b>+<c>+<n>-more`. A dated anchor alone
|
|
288
|
+
* says when a publication ran but never what it shipped; naming the specs makes the tag
|
|
289
|
+
* readable without checking out its commit, and the cap keeps a wide sync from growing
|
|
290
|
+
* an unbounded ref name. Empty for an empty publication, which then anchors on its date.
|
|
261
291
|
*/
|
|
262
|
-
|
|
292
|
+
function specLabel(paths: string[]): string {
|
|
293
|
+
const ids = specIds(paths);
|
|
294
|
+
const named = ids.slice(0, SPEC_LABEL_LIMIT);
|
|
295
|
+
const remaining = ids.length - named.length;
|
|
296
|
+
return [...named, ...(remaining > 0 ? [`${remaining}-more`] : [])].join("+");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Commit subject for a spec publication; it belongs to no change, so it names specs, never an anchor. */
|
|
300
|
+
export function specCommitSubject(paths: string[]): string {
|
|
301
|
+
const ids = specIds(paths);
|
|
302
|
+
const named = ids.slice(0, SPEC_LABEL_LIMIT);
|
|
303
|
+
const remaining = ids.length - named.length;
|
|
304
|
+
if (named.length === 0) return "chore(openspec): sync canonical specs";
|
|
305
|
+
const listed = remaining > 0 ? `${named.join(", ")} and ${remaining} more` : named.join(", ");
|
|
306
|
+
return `chore(openspec): sync canonical specs (${listed})`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Working-tree reads a spec publication needs; nothing here mutates the repository. */
|
|
310
|
+
export type SpecDriftGit = Pick<GitOps, "changedPaths" | "changedPathKinds">;
|
|
311
|
+
|
|
312
|
+
export type DriftResolution = { ok: true; paths: string[] } | { ok: false; message: string };
|
|
313
|
+
|
|
314
|
+
function pad2(value: number): string {
|
|
315
|
+
return String(value).padStart(2, "0");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Local calendar date as `YYYY-MM-DD`. */
|
|
319
|
+
function calendarDate(now: Date): string {
|
|
320
|
+
return `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function withoutTrailingSlash(candidate: string): string {
|
|
324
|
+
return candidate.endsWith("/") ? candidate.slice(0, -1) : candidate;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Canonical specs that drifted: uncommitted under `openspec/specs/` and claimed by no
|
|
329
|
+
* pending change. Delegates to the same discovery `mate artifact pending` reports from,
|
|
330
|
+
* so the two commands can never disagree about what is drifted. Only paths and
|
|
331
|
+
* working-tree status are read — never spec or archived file content.
|
|
332
|
+
*
|
|
333
|
+
* `requested` narrows the set rather than being trusted: a path that is not drifted, or
|
|
334
|
+
* sits outside the canonical spec tree, refuses the whole invocation instead of
|
|
335
|
+
* publishing a surprising subset.
|
|
336
|
+
*/
|
|
337
|
+
export async function resolveDriftedSpecs(
|
|
338
|
+
companionPath: string,
|
|
339
|
+
git: SpecDriftGit,
|
|
340
|
+
requested: string[] = [],
|
|
341
|
+
): Promise<DriftResolution> {
|
|
342
|
+
const changed = await git.changedPaths();
|
|
343
|
+
const changeKinds = (await git.changedPathKinds?.()) ?? {};
|
|
344
|
+
const archives = await discoverArchives(companionPath, changed, changeKinds);
|
|
345
|
+
const drifted = (await unattributedSpecs(companionPath, changed, archives, changeKinds)).map(
|
|
346
|
+
(spec) => spec.path,
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
if (requested.length === 0) return { ok: true, paths: drifted };
|
|
350
|
+
|
|
351
|
+
const normalized = requested.map(withoutTrailingSlash);
|
|
352
|
+
const outside = normalized.filter((candidate) => !candidate.startsWith(`${SPECS_RELATIVE_DIR}/`));
|
|
353
|
+
if (outside.length > 0) {
|
|
354
|
+
return {
|
|
355
|
+
ok: false,
|
|
356
|
+
message: `mate: not a canonical spec: ${outside.join(", ")}. Only specs under ${SPECS_RELATIVE_DIR}/ are spec publication targets.`,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
const driftedPaths = new Set(drifted);
|
|
360
|
+
const notDrifted = normalized.filter((candidate) => !driftedPaths.has(candidate));
|
|
361
|
+
if (notDrifted.length > 0) {
|
|
362
|
+
return {
|
|
363
|
+
ok: false,
|
|
364
|
+
message: `mate: not drifted: ${notDrifted.join(", ")}. Run \`mate artifact pending\` to see which canonical specs are uncommitted and unaccounted for.`,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
return { ok: true, paths: normalized.toSorted() };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* The spec publication finisher. Its target is already resolved by
|
|
372
|
+
* {@link resolveDriftedSpecs}, because an empty drift set is a clean no-op rather than a
|
|
373
|
+
* refusal and the engine's resolve step can only succeed or fail.
|
|
374
|
+
*
|
|
375
|
+
* This is the one publication unit that computes its own anchor: it has no archive
|
|
376
|
+
* directory to read one from. The anchor is the calendar date plus the {@link specLabel}
|
|
377
|
+
* of the specs it ships. The segregated {@link SPEC_TAG_NAMESPACE} keeps that computed
|
|
378
|
+
* anchor out of the dated change anchors, which still never compute one.
|
|
379
|
+
*/
|
|
380
|
+
export function openspecSpecsFinisher(
|
|
263
381
|
contextOrPath: FinishContext | string,
|
|
264
|
-
|
|
382
|
+
paths: string[],
|
|
383
|
+
now: () => Date = () => new Date(),
|
|
265
384
|
): ArtifactFinisher {
|
|
385
|
+
const context: FinishContext =
|
|
386
|
+
typeof contextOrPath === "string"
|
|
387
|
+
? { companionPath: contextOrPath, repositoryId: "" }
|
|
388
|
+
: contextOrPath;
|
|
389
|
+
|
|
390
|
+
return {
|
|
391
|
+
type: "openspec",
|
|
392
|
+
disabledReason: "mate: the openspec capability must be enabled to run artifact publish.",
|
|
393
|
+
isEnabled(capabilities) {
|
|
394
|
+
return hasOpenspecCapability(capabilities);
|
|
395
|
+
},
|
|
396
|
+
async resolve() {
|
|
397
|
+
const label = specLabel(paths);
|
|
398
|
+
const publication: Produced = {
|
|
399
|
+
anchorName: `${calendarDate(now())}${label === "" ? "" : `-${label}`}`,
|
|
400
|
+
commitPaths: paths,
|
|
401
|
+
tagNamespace: SPEC_TAG_NAMESPACE,
|
|
402
|
+
commitSubject: specCommitSubject(paths),
|
|
403
|
+
tagCollision: "suffix",
|
|
404
|
+
};
|
|
405
|
+
return { ok: true, resolved: publication };
|
|
406
|
+
},
|
|
407
|
+
capSync() {
|
|
408
|
+
return capSync(context);
|
|
409
|
+
},
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* The openspec artifact finisher: resolution of an already-archived change, an
|
|
415
|
+
* openspec-scoped cap sync, and a commit scoped to `openspec/`. Validation and
|
|
416
|
+
* completeness belong to `openspec archive`, which performs both and is the only step
|
|
417
|
+
* that can — an archived change is neither validatable nor listed as active.
|
|
418
|
+
*
|
|
419
|
+
* Resolution reads the dated `openspec/changes/archive/` directory names openspec
|
|
420
|
+
* actually created, so a change publication's tag is never a computed date and archived
|
|
421
|
+
* prose is never read. Spec publications are the documented exception: having no archive
|
|
422
|
+
* directory to read a date from, {@link openspecSpecsFinisher} computes one, in its own
|
|
423
|
+
* segregated tag namespace.
|
|
424
|
+
*/
|
|
425
|
+
export function openspecFinisher(contextOrPath: FinishContext | string): ArtifactFinisher {
|
|
266
426
|
const context: FinishContext =
|
|
267
427
|
typeof contextOrPath === "string"
|
|
268
428
|
? { companionPath: contextOrPath, repositoryId: "" }
|
|
@@ -271,87 +431,55 @@ export function openspecFinisher(
|
|
|
271
431
|
|
|
272
432
|
return {
|
|
273
433
|
type: "openspec",
|
|
274
|
-
disabledReason: "mate: the openspec capability must be enabled to run artifact
|
|
434
|
+
disabledReason: "mate: the openspec capability must be enabled to run artifact publish.",
|
|
275
435
|
isEnabled(capabilities) {
|
|
276
436
|
return hasOpenspecCapability(capabilities);
|
|
277
437
|
},
|
|
278
|
-
async
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
438
|
+
async resolve(target) {
|
|
439
|
+
const archiveRoot = path.join(companionPath, "openspec", "changes", "archive");
|
|
440
|
+
const anchor = ANCHOR_PATTERN.exec(target);
|
|
441
|
+
|
|
442
|
+
/** A dated anchor names exactly one directory; no name matching can widen it. */
|
|
443
|
+
if (anchor) {
|
|
444
|
+
if (!(await pathExists(path.join(archiveRoot, target)))) {
|
|
445
|
+
return {
|
|
446
|
+
ok: false,
|
|
447
|
+
message: `mate: no archive at openspec/changes/archive/${target}. Archive the change first with \`openspec archive ${anchor[2]}\`.`,
|
|
448
|
+
};
|
|
287
449
|
}
|
|
288
|
-
return
|
|
289
|
-
} catch {
|
|
290
|
-
return {
|
|
291
|
-
valid: res.status === 0,
|
|
292
|
-
errors: res.status === 0 ? [] : [res.stderr.trim() || "validation failed"],
|
|
293
|
-
};
|
|
450
|
+
return resolved(companionPath, anchor[2], target);
|
|
294
451
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
452
|
+
|
|
453
|
+
const pattern = new RegExp(`^\\d{4}-\\d{2}-\\d{2}-${escapeRegExp(target)}$`);
|
|
454
|
+
let matches: string[] = [];
|
|
298
455
|
try {
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
if (!change) {
|
|
304
|
-
return { complete: false, total: 0, remaining: 0 };
|
|
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);
|
|
305
460
|
}
|
|
306
|
-
|
|
307
|
-
return { complete: remaining <= 0, total: change.totalTasks, remaining };
|
|
308
|
-
} catch {
|
|
309
|
-
return { complete: false, total: 0, remaining: 0 };
|
|
310
|
-
}
|
|
311
|
-
},
|
|
312
|
-
async detectProduced(name) {
|
|
313
|
-
const archiveDir = path.join(companionPath, "openspec", "changes", "archive");
|
|
314
|
-
let entries;
|
|
315
|
-
try {
|
|
316
|
-
entries = await fs.readdir(archiveDir, { withFileTypes: true });
|
|
461
|
+
matches = matches.toSorted();
|
|
317
462
|
} catch {
|
|
318
|
-
|
|
463
|
+
/** No archive directory at all: nothing can match. */
|
|
319
464
|
}
|
|
320
|
-
|
|
321
|
-
const matches = entries
|
|
322
|
-
.filter((entry) => entry.isDirectory() && pattern.test(entry.name))
|
|
323
|
-
.map((entry) => entry.name)
|
|
324
|
-
.toSorted();
|
|
465
|
+
|
|
325
466
|
if (matches.length === 0) {
|
|
326
|
-
|
|
327
|
-
}
|
|
328
|
-
// Latest dated folder wins if the same change name was ever archived twice.
|
|
329
|
-
const anchorName = matches[matches.length - 1];
|
|
330
|
-
const commitPaths = await commitPathsForArchive(companionPath, name, anchorName);
|
|
331
|
-
await reconcileMainSpecFrontmatter(companionPath, anchorName);
|
|
332
|
-
return { anchorName, commitPaths };
|
|
333
|
-
},
|
|
334
|
-
async produce(name) {
|
|
335
|
-
const res = runCommand(companionPath, ["openspec", "archive", name, "--yes"]);
|
|
336
|
-
if (res.status !== 0) {
|
|
467
|
+
const active = await pathExists(path.join(companionPath, "openspec", "changes", target));
|
|
337
468
|
return {
|
|
338
469
|
ok: false,
|
|
339
|
-
|
|
340
|
-
|
|
470
|
+
message: active
|
|
471
|
+
? `mate: ${target} is still active and cannot be published. Run \`openspec archive ${target}\` first; publish only publishes archived changes.`
|
|
472
|
+
: `mate: no archived change matches "${target}". Archive it first with \`openspec archive ${target}\`, or pass a dated archive anchor.`,
|
|
341
473
|
};
|
|
342
474
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
475
|
+
/** Two archives, no tiebreaker: picking one would publish an anchor nobody chose. */
|
|
476
|
+
if (matches.length > 1) {
|
|
477
|
+
return {
|
|
478
|
+
ok: false,
|
|
479
|
+
message: `mate: "${target}" matches ${matches.length} archives (${matches.join(", ")}). Pass one anchor explicitly.`,
|
|
480
|
+
};
|
|
347
481
|
}
|
|
348
|
-
|
|
349
|
-
await reconcileMainSpecFrontmatter(companionPath, match[1]);
|
|
350
|
-
return {
|
|
351
|
-
ok: true,
|
|
352
|
-
produced: { anchorName: match[1], commitPaths },
|
|
353
|
-
message: res.stdout.trim(),
|
|
354
|
-
};
|
|
482
|
+
return resolved(companionPath, target, matches[0]);
|
|
355
483
|
},
|
|
356
484
|
capSync() {
|
|
357
485
|
return capSync(context);
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { hasOpenspecCapability } from "../../../../lib/orchestrator/capabilities";
|
|
2
|
+
import { resolveForCapability } from "../../../../lib/orchestrator/framework-context";
|
|
3
|
+
import type { LaunchContext } from "../../../../lib/orchestrator/framework-context";
|
|
4
|
+
import type { CapabilityConfig } from "../../../../lib/orchestrator/types";
|
|
5
|
+
import { WorkingRepoRequiredError } from "../../../../lib/orchestrator/types";
|
|
6
|
+
import { type BooleanFlagSet, parseFlags } from "../../../parse-flags";
|
|
7
|
+
import { ensureUnambiguousCompanion } from "../../shared/companion-selection";
|
|
8
|
+
import { defaultGitOps, type GitOps, type WorkingTreeChange } from "../finish/git";
|
|
9
|
+
import {
|
|
10
|
+
discoverArchives,
|
|
11
|
+
pendingArchives,
|
|
12
|
+
unattributedSpecs,
|
|
13
|
+
type ArchiveEntry,
|
|
14
|
+
type UnattributedSpec,
|
|
15
|
+
} from "./discovery";
|
|
16
|
+
|
|
17
|
+
export interface PendingCommandDeps {
|
|
18
|
+
ensureUnambiguousCompanion?: (cwd: string) => Promise<boolean>;
|
|
19
|
+
resolveContext?: (cwd: string) => Promise<LaunchContext>;
|
|
20
|
+
loadCapabilities?: (context: LaunchContext) => Promise<CapabilityConfig[]>;
|
|
21
|
+
git?: (companionPath: string, workingRepoPath?: string) => GitOps;
|
|
22
|
+
discover?: (
|
|
23
|
+
companionPath: string,
|
|
24
|
+
uncommittedPaths: string[],
|
|
25
|
+
changeKinds?: Readonly<Record<string, WorkingTreeChange>>,
|
|
26
|
+
) => Promise<ArchiveEntry[]>;
|
|
27
|
+
stdout?: (line: string) => void;
|
|
28
|
+
stderr?: (line: string) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Presence-only flags; every other `--flag` consumes the following token as its value. */
|
|
32
|
+
const BOOLEAN_FLAGS: BooleanFlagSet = new Set(["json"]);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Whether `mate artifact publish --all` would publish this entry. Discovery does not know
|
|
36
|
+
* about publication modes, so the marking is applied here rather than in the entry types.
|
|
37
|
+
*/
|
|
38
|
+
export interface AllCoverage {
|
|
39
|
+
coveredByAll: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Machine-readable result emitted with `--json`. */
|
|
43
|
+
export interface PendingResult {
|
|
44
|
+
type: string;
|
|
45
|
+
companionPath: string;
|
|
46
|
+
count: number;
|
|
47
|
+
pending: Array<ArchiveEntry & AllCoverage>;
|
|
48
|
+
/** Uncommitted canonical specs no pending change accounts for, each with its attribution. */
|
|
49
|
+
unattributedSpecs: Array<UnattributedSpec & AllCoverage>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `--all` publishes every pending change and then every remaining drifted spec, so both
|
|
54
|
+
* reported sets are covered in full — an unattributed spec included, whatever its
|
|
55
|
+
* attribution, because a spec publication needs no archive to carry it.
|
|
56
|
+
*/
|
|
57
|
+
function markCoveredByAll<T>(entries: T[]): Array<T & AllCoverage> {
|
|
58
|
+
return entries.map((entry) => ({ ...entry, coveredByAll: true }));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function defaultLoadCapabilities(context: LaunchContext): Promise<CapabilityConfig[]> {
|
|
62
|
+
const config = await context.configStore.load();
|
|
63
|
+
return config.capabilities ?? [];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Attribution is printed as candidate archives, never as a claim about which one is responsible. */
|
|
67
|
+
function renderUnattributed(specs: Array<UnattributedSpec & AllCoverage>): string[] {
|
|
68
|
+
if (specs.length === 0) return [];
|
|
69
|
+
return [
|
|
70
|
+
"",
|
|
71
|
+
`${specs.length} uncommitted spec${specs.length === 1 ? "" : "s"} not accounted for by a pending change:`,
|
|
72
|
+
...specs.flatMap((spec) => [
|
|
73
|
+
` ${spec.path} [${spec.kind}]${spec.coveredByAll ? " [--all]" : ""}`,
|
|
74
|
+
...(spec.touchedByArchives.length === 0
|
|
75
|
+
? [" no archive names this spec"]
|
|
76
|
+
: spec.touchedByArchives.map(
|
|
77
|
+
(archive) => ` publishes via ${archive.anchor} (archive ${archive.state})`,
|
|
78
|
+
)),
|
|
79
|
+
]),
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function renderHuman(result: PendingResult): string[] {
|
|
84
|
+
const pending =
|
|
85
|
+
result.count === 0
|
|
86
|
+
? ["No archived changes have uncommitted content."]
|
|
87
|
+
: [
|
|
88
|
+
`${result.count} archived change${result.count === 1 ? "" : "s"} pending publication:`,
|
|
89
|
+
...result.pending.flatMap((entry, index) => [
|
|
90
|
+
` ${index + 1}. ${entry.name} ${entry.tag} ${entry.path}${entry.coveredByAll ? " [--all]" : ""}`,
|
|
91
|
+
...entry.uncommittedPaths.map((uncommitted) => ` ${uncommitted}`),
|
|
92
|
+
...entry.uncommittedSpecs.map((spec) => ` ${spec}`),
|
|
93
|
+
]),
|
|
94
|
+
];
|
|
95
|
+
return [...pending, ...renderUnattributed(result.unattributedSpecs)];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @command mate artifact pending
|
|
100
|
+
* @description Lists archived OpenSpec changes whose own files — the archive directory or
|
|
101
|
+
* the active directory archiving deleted — are still uncommitted in the companion working
|
|
102
|
+
* tree, each with the uncommitted canonical specs it applied to, so the publication
|
|
103
|
+
* workflow can offer exact selectable entries instead of parsing OpenSpec prose.
|
|
104
|
+
* Uncommitted specs no pending change accounts for are reported separately, each with the
|
|
105
|
+
* archives whose delta specs name it. Every reported entry carries `coveredByAll`, marking
|
|
106
|
+
* what `mate artifact publish --all` would publish. Read-only:
|
|
107
|
+
* nothing is validated, produced, committed, tagged, or pushed.
|
|
108
|
+
* @flags
|
|
109
|
+
* - `--json` — emit a machine-readable {@link PendingResult} instead of human-readable text.
|
|
110
|
+
* @remarks No-ops (with a message on stderr) when the openspec capability is disabled for
|
|
111
|
+
* this repo. Exits non-zero only when the companion, launch context, or Git guard rejects.
|
|
112
|
+
*/
|
|
113
|
+
export async function runArtifactPendingCommand(
|
|
114
|
+
argv: string[],
|
|
115
|
+
deps: PendingCommandDeps = {},
|
|
116
|
+
): Promise<void> {
|
|
117
|
+
const emitOut = deps.stdout ?? ((line: string) => process.stdout.write(`${line}\n`));
|
|
118
|
+
const emitErr = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
|
|
119
|
+
const json = parseFlags(argv, BOOLEAN_FLAGS).json === true;
|
|
120
|
+
|
|
121
|
+
const ensureCompanion = deps.ensureUnambiguousCompanion ?? ensureUnambiguousCompanion;
|
|
122
|
+
if (!(await ensureCompanion(process.cwd()))) {
|
|
123
|
+
process.exitCode = 1;
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const resolveContext = deps.resolveContext ?? ((cwd: string) => resolveForCapability(cwd));
|
|
128
|
+
let context: LaunchContext;
|
|
129
|
+
try {
|
|
130
|
+
context = await resolveContext(process.cwd());
|
|
131
|
+
} catch (err) {
|
|
132
|
+
if (err instanceof WorkingRepoRequiredError) {
|
|
133
|
+
emitErr(err.message);
|
|
134
|
+
process.exitCode = 1;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
throw err;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const loadCapabilities = deps.loadCapabilities ?? defaultLoadCapabilities;
|
|
141
|
+
if (!hasOpenspecCapability(await loadCapabilities(context))) {
|
|
142
|
+
emitErr("mate: the openspec capability must be enabled to list pending artifacts.");
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let git: GitOps;
|
|
147
|
+
try {
|
|
148
|
+
git = (deps.git ?? defaultGitOps)(
|
|
149
|
+
context.companionPath,
|
|
150
|
+
context.repository?.path ?? process.env.MATE_REPO_PATH,
|
|
151
|
+
);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
emitErr(`mate: pending Git guard rejected the target: ${String(err)}`);
|
|
154
|
+
process.exitCode = 1;
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const discover = deps.discover ?? discoverArchives;
|
|
159
|
+
const changed = await git.changedPaths();
|
|
160
|
+
const changeKinds = (await git.changedPathKinds?.()) ?? {};
|
|
161
|
+
const archives = await discover(context.companionPath, changed, changeKinds);
|
|
162
|
+
const pending = pendingArchives(archives);
|
|
163
|
+
const result: PendingResult = {
|
|
164
|
+
type: "openspec",
|
|
165
|
+
companionPath: context.companionPath,
|
|
166
|
+
count: pending.length,
|
|
167
|
+
pending: markCoveredByAll(pending),
|
|
168
|
+
unattributedSpecs: markCoveredByAll(
|
|
169
|
+
await unattributedSpecs(context.companionPath, changed, archives, changeKinds),
|
|
170
|
+
),
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
if (json) emitOut(JSON.stringify(result));
|
|
174
|
+
else for (const line of renderHuman(result)) emitOut(line);
|
|
175
|
+
}
|