@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
|
@@ -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 {
|
|
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 =
|
|
39
|
-
const activeRelative =
|
|
40
|
-
|
|
41
|
-
const canonicalSpecs
|
|
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.
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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(
|
|
83
|
-
if (!parsed || typeof parsed !== "object") return
|
|
84
|
-
const
|
|
85
|
-
if (
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
/**
|
|
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
|
|
156
|
-
*
|
|
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
|
|
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
|
|
180
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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:
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
440
|
-
|
|
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
|
|
526
|
+
if (!(await pathIsDirectory(path.join(archiveDirOf(companionPath), target)))) {
|
|
445
527
|
return {
|
|
446
528
|
ok: false,
|
|
447
|
-
message: `mate: no archive at
|
|
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(
|
|
532
|
+
return resolved(context, anchor.name, target);
|
|
451
533
|
}
|
|
452
534
|
|
|
453
|
-
const
|
|
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,
|
|
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(
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
|
176
|
-
const ownedPaths = [`${ARCHIVE_RELATIVE_DIR}/${anchor}`,
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
/**
|