@uniqbit/mate-core 0.18.3-canary.1 → 0.18.4
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/hooks.json +10 -1
- package/claude-plugin/hooks/session-banner.mjs +4 -3
- package/claude-plugin/hooks/session-prepare.mjs +9 -0
- package/opencode-plugin/src/add-dir.ts +10 -76
- package/opencode-plugin/src/audience-layer.ts +91 -0
- package/opencode-plugin/src/companion.ts +70 -18
- package/opencode-plugin/src/launch-environment.ts +29 -0
- package/opencode-plugin/src/server.ts +60 -8
- package/package.json +3 -3
- package/src/cli/commands/artifact/finish/openspec.ts +1 -1
- package/src/cli/commands/artifact/pending/discovery.ts +60 -21
- package/src/cli/commands/cap/index-cmd.ts +2 -11
- package/src/cli/commands/companion/link.ts +16 -11
- package/src/cli/commands/companion/list.ts +4 -5
- package/src/cli/commands/companion/open.ts +4 -5
- package/src/cli/commands/companion/tui.ts +1 -1
- package/src/cli/commands/doctor.ts +87 -5
- package/src/cli/commands/install.ts +8 -0
- package/src/cli/commands/report/renderer.ts +9 -19
- package/src/cli/commands/session/prepare.ts +119 -0
- package/src/cli/commands/shared/companion-selection.ts +2 -2
- package/src/cli/commands/studio/reports.ts +0 -1
- package/src/cli/commands/studio/server.ts +6 -2
- package/src/cli/commands/studio/terminal-launch.ts +16 -7
- package/src/cli/commands/unwrap.ts +28 -3
- package/src/cli/commands/wrap.ts +79 -60
- package/src/cli/companion-selector.tsx +1 -1
- package/src/cli/main.ts +17 -0
- package/src/cli/usage.ts +5 -2
- package/src/hooks/session-banner.ts +19 -10
- package/src/hooks/session-guidance.ts +20 -2
- package/src/hooks/session-prepare.ts +101 -0
- package/src/lib/fs-utils.ts +13 -0
- package/src/lib/orchestrator/adapters/base.ts +45 -9
- package/src/lib/orchestrator/adapters/claude.ts +241 -30
- package/src/lib/orchestrator/adapters/opencode.ts +72 -34
- package/src/lib/orchestrator/companion-git-sync.ts +28 -1
- package/src/lib/orchestrator/companion-hub.ts +2 -1
- package/src/lib/orchestrator/config-store.ts +83 -1
- package/src/lib/orchestrator/framework-context.ts +5 -29
- package/src/lib/orchestrator/launcher.ts +36 -171
- package/src/lib/orchestrator/openspec-store.ts +202 -0
- package/src/lib/orchestrator/projection-claude-entry.ts +17 -6
- package/src/lib/orchestrator/projection-claude-skills.ts +126 -41
- package/src/lib/orchestrator/projection-entries.ts +124 -11
- package/src/lib/orchestrator/projection-opencode-copies.ts +230 -0
- package/src/lib/orchestrator/projection-openspec-store.ts +164 -0
- package/src/lib/orchestrator/projection-record.ts +7 -1
- package/src/lib/orchestrator/projection-runtime-documents.ts +83 -130
- package/src/lib/orchestrator/projection-types.ts +47 -34
- package/src/lib/orchestrator/repository-link.ts +473 -0
- package/src/lib/orchestrator/root-context.ts +6 -2
- package/src/lib/orchestrator/session-prepare.ts +503 -0
- package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
- package/src/lib/orchestrator/setup-preflight.ts +3 -1
- package/src/lib/orchestrator/working-repo-projection.ts +73 -160
- package/src/lib/orchestrator/workspace-inventory.ts +2 -2
- package/src/lib/orchestrator/wrap-pass.ts +222 -0
- package/src/lib/runtime-directory-refresh.ts +186 -0
- package/src/lib/update-checker.ts +6 -0
- package/src/opencode/companion-hooks.ts +30 -135
- package/src/opencode/companion-policy.ts +26 -10
- package/src/opencode/index.ts +1 -7
- package/src/opencode/projected-guidance.ts +6 -13
- package/src/opencode/session-notes.ts +76 -0
- package/src/opencode/session-prepare.ts +139 -0
- package/src/opencode/tui.tsx +56 -9
- package/src/runtime/audience-layer.ts +84 -0
- package/src/runtime/claude-delivery-plan.ts +73 -0
- package/src/runtime/claude-hook-group.ts +21 -0
- package/src/runtime/companion-lock.ts +121 -0
- package/src/runtime/companion-sync.ts +66 -9
- package/src/runtime/guidance.ts +17 -0
- package/src/runtime/opencode-audience-layer.ts +176 -0
- package/src/runtime/policy.ts +14 -1
- package/src/runtime/projection.ts +1 -1
- package/src/runtime/runtime-directory.ts +43 -0
- package/src/runtime/runtime-document-manifest.ts +352 -0
- package/src/runtime/session-prepare-runner.ts +148 -0
- package/src/runtime/wrap-state.ts +94 -0
- package/src/runtime/write-atomic.ts +27 -0
- package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
- package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +2 -1
- package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +5 -0
- package/src/templates/capabilities/react-doctor/mate-react-doctor/SKILL.md +84 -0
- package/src/templates/capabilities/react-doctor/{skill → mate-react-doctor}/references/explain.md +18 -15
- package/src/templates/mate-skills/agents/mate-openspec-backfill/SKILL.md +45 -29
- package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +1 -1
- package/src/templates/mate-skills/claude/mate-openspec-backfill/SKILL.md +45 -29
- package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +1 -1
- package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +850 -499
- package/src/tools/setup/audience-isolation.fixture.ts +325 -0
- package/src/tools/setup/audience-layer.ts +207 -0
- package/src/tools/setup/audience-registrations.ts +52 -0
- package/src/tools/setup/capabilities/openspec.ts +9 -0
- package/src/tools/setup/capabilities/react-doctor.ts +34 -50
- package/src/tools/setup/capabilities/tokensave.ts +11 -0
- package/src/tools/setup/contribution-ledger.ts +207 -0
- package/src/tools/setup/dynamic-plugins/audiences.ts +89 -9
- package/src/tools/setup/dynamic-plugins/declarations.ts +3 -2
- package/src/tools/setup/dynamic-plugins/hydrate.ts +8 -5
- package/src/tools/setup/engine.ts +152 -10
- package/src/tools/setup/isolation-diagnostics.ts +139 -0
- package/src/tools/setup/mate.ts +1 -1
- package/src/tools/setup/plugin.ts +19 -4
- package/src/tools/setup/plugins/gitignore.ts +7 -1
- package/src/tools/setup/providers/claude-format.ts +11 -18
- package/src/tools/setup/providers/claude-plugin-hooks.ts +61 -24
- package/src/tools/setup/providers/claude.ts +50 -8
- package/src/tools/setup/providers/opencode.ts +10 -0
- package/src/tools/setup/registry.ts +24 -2
- package/src/tools/setup/runtime-documents.ts +152 -32
- package/src/tools/setup/surface-target.ts +13 -4
- package/src/tools/setup/upgrade-sweep.ts +153 -0
- package/src/tools/setup/working-repo-cleanup.ts +2 -0
- package/src/tools/setup/working-repo-local-state.ts +91 -14
- package/src/tools/setup.ts +11 -8
- package/wrappers/bin/openspec +42 -1
- package/src/lib/orchestrator/companion-registry-reader.ts +0 -37
- package/src/lib/orchestrator/companion-resolver.ts +0 -179
- package/src/lib/orchestrator/companion-store.ts +0 -94
- package/src/lib/orchestrator/repo-local-registry.ts +0 -143
- package/src/templates/capabilities/react-doctor/claude/hooks/react-doctor.sh +0 -144
- package/src/templates/capabilities/react-doctor/skill/SKILL.md +0 -51
- package/src/templates/report-assets/README.md +0 -32
- package/src/templates/report-assets/mermaid.LICENSE +0 -21
- package/src/templates/report-assets/mermaid.min.js +0 -4376
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import type { Dirent } from "node:fs";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
copies,
|
|
8
|
+
readManifest,
|
|
9
|
+
updateManifest,
|
|
10
|
+
withCopies,
|
|
11
|
+
type CopyRecord,
|
|
12
|
+
} from "../../runtime/runtime-document-manifest";
|
|
13
|
+
import { writeAtomic } from "../../runtime/write-atomic";
|
|
14
|
+
import { pruneEmptyAncestors } from "../../tools/setup/utils";
|
|
15
|
+
import { reconcileWorkingRepoCopyExcludes } from "../../tools/setup/working-repo-local-state";
|
|
16
|
+
import type { ProjectionWriteReport } from "./projection-types";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Copies the companion's `.opencode/` into the Working Repository's own
|
|
20
|
+
* `.opencode/`, the one slot OpenCode reads beside the user's global config.
|
|
21
|
+
* Regular files, not links: OpenCode does not live-reload a linked folder.
|
|
22
|
+
* Mate touches only the files its manifest records as owned.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const OPENCODE_DIR = ".opencode";
|
|
26
|
+
/** `opencode.json` is merged into the wrapped document instead of copied. */
|
|
27
|
+
const SKIPPED_TOP_LEVEL = new Set(["opencode.json"]);
|
|
28
|
+
const SKIPPED_NAMES = new Set(["node_modules", ".DS_Store"]);
|
|
29
|
+
|
|
30
|
+
interface CopySource {
|
|
31
|
+
absolute: string;
|
|
32
|
+
content: Buffer;
|
|
33
|
+
sha256: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function sha256(content: Buffer): string {
|
|
37
|
+
return crypto.createHash("sha256").update(content).digest("hex");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function walk(dir: string, relative: string, into: Map<string, CopySource>): Promise<void> {
|
|
41
|
+
let entries: Dirent[];
|
|
42
|
+
try {
|
|
43
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
for (const entry of entries.toSorted((a, b) => a.name.localeCompare(b.name))) {
|
|
49
|
+
if (SKIPPED_NAMES.has(entry.name)) continue;
|
|
50
|
+
if (relative === "" && SKIPPED_TOP_LEVEL.has(entry.name)) continue;
|
|
51
|
+
const absolute = path.join(dir, entry.name);
|
|
52
|
+
const rel = relative ? `${relative}/${entry.name}` : entry.name;
|
|
53
|
+
// oxlint-disable-next-line no-await-in-loop -- sequential walk keeps the order stable
|
|
54
|
+
const stat = await fs.stat(absolute).catch(() => null);
|
|
55
|
+
if (!stat) continue;
|
|
56
|
+
if (stat.isDirectory()) {
|
|
57
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
58
|
+
await walk(absolute, rel, into);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (!stat.isFile()) continue;
|
|
62
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
63
|
+
const content = await fs.readFile(absolute);
|
|
64
|
+
into.set(`${OPENCODE_DIR}/${rel}`, { absolute, content, sha256: sha256(content) });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function companionSources(companionPath: string): Promise<Map<string, CopySource>> {
|
|
69
|
+
const sources = new Map<string, CopySource>();
|
|
70
|
+
await walk(path.join(companionPath, OPENCODE_DIR), "", sources);
|
|
71
|
+
return sources;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function destination(repoPath: string, relativePath: string): string {
|
|
75
|
+
return path.join(path.resolve(repoPath), ...relativePath.split("/"));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** `null` when absent; a directory or other non-file reads as a foreign entry. */
|
|
79
|
+
async function readDestination(target: string): Promise<Buffer | "foreign" | null> {
|
|
80
|
+
const stat = await fs.lstat(target).catch(() => null);
|
|
81
|
+
if (!stat) return null;
|
|
82
|
+
if (!stat.isFile()) return "foreign";
|
|
83
|
+
return fs.readFile(target);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function writeCopy(target: string, content: Buffer): Promise<void> {
|
|
87
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
88
|
+
await writeAtomic(target, content);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function deleteCopy(repoPath: string, relativePath: string): Promise<void> {
|
|
92
|
+
const target = destination(repoPath, relativePath);
|
|
93
|
+
await fs.rm(target, { force: true });
|
|
94
|
+
await pruneEmptyAncestors(path.dirname(target), path.resolve(repoPath));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface CopyPlan {
|
|
98
|
+
write: Array<{ relativePath: string; source: CopySource; warning?: string }>;
|
|
99
|
+
remove: string[];
|
|
100
|
+
shadowed: Array<{ relativePath: string; source: CopySource }>;
|
|
101
|
+
records: Record<string, CopyRecord>;
|
|
102
|
+
previous: Record<string, CopyRecord>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function planCopies(repoPath: string, companionPath: string): Promise<CopyPlan> {
|
|
106
|
+
const previous = copies(await readManifest(repoPath), "opencode");
|
|
107
|
+
const sources = await companionSources(companionPath);
|
|
108
|
+
const plan: CopyPlan = { write: [], remove: [], shadowed: [], records: {}, previous };
|
|
109
|
+
|
|
110
|
+
for (const [relativePath, source] of sources) {
|
|
111
|
+
const owned = previous[relativePath];
|
|
112
|
+
// oxlint-disable-next-line no-await-in-loop -- one stat per file
|
|
113
|
+
const current = await readDestination(destination(repoPath, relativePath));
|
|
114
|
+
if (current === "foreign" || (!owned && current !== null)) {
|
|
115
|
+
plan.shadowed.push({ relativePath, source });
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const currentSha = current === null ? null : sha256(current);
|
|
119
|
+
const edited = owned !== undefined && currentSha !== null && currentSha !== owned.sha256;
|
|
120
|
+
/** Local edit survives until its companion source changes. */
|
|
121
|
+
const keepEdit = edited && source.sha256 === owned.sha256;
|
|
122
|
+
if (currentSha !== source.sha256 && !keepEdit) {
|
|
123
|
+
plan.write.push({
|
|
124
|
+
relativePath,
|
|
125
|
+
source,
|
|
126
|
+
warning: edited
|
|
127
|
+
? `${relativePath} was edited locally; the edit was replaced from the companion. Edit ${source.absolute} instead.`
|
|
128
|
+
: undefined,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
plan.records[relativePath] = { runtime: "opencode", sha256: source.sha256 };
|
|
132
|
+
}
|
|
133
|
+
for (const relativePath of Object.keys(previous)) {
|
|
134
|
+
if (!sources.has(relativePath)) plan.remove.push(relativePath);
|
|
135
|
+
}
|
|
136
|
+
return plan;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function sameRecords(a: Record<string, CopyRecord>, b: Record<string, CopyRecord>): boolean {
|
|
140
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function shadowWarning(relativePath: string, source: CopySource): string {
|
|
144
|
+
return `${relativePath} in the working repository shadows the companion file ${source.absolute}; the repository file wins.`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Adds, updates and deletes owned copies; an unowned path is never touched.
|
|
149
|
+
* Excludes widen and new paths are recorded before any write, so an
|
|
150
|
+
* interrupted reconcile leaves no unrecorded copy and none Git would list.
|
|
151
|
+
*/
|
|
152
|
+
export async function reconcileCompanionCopies(
|
|
153
|
+
repoPath: string,
|
|
154
|
+
companionPath: string,
|
|
155
|
+
): Promise<ProjectionWriteReport> {
|
|
156
|
+
const plan = await planCopies(repoPath, companionPath);
|
|
157
|
+
const warnings = plan.shadowed.map(({ relativePath, source }) =>
|
|
158
|
+
shadowWarning(relativePath, source),
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const covering = [...new Set([...Object.keys(plan.previous), ...Object.keys(plan.records)])];
|
|
162
|
+
const excludesWidened = await reconcileWorkingRepoCopyExcludes(repoPath, covering);
|
|
163
|
+
/** Previous records win: a path not yet rewritten keeps the hash on disk. */
|
|
164
|
+
const interim = { ...plan.records, ...plan.previous };
|
|
165
|
+
if (!sameRecords(interim, plan.previous)) {
|
|
166
|
+
await updateManifest(repoPath, (manifest) => withCopies(manifest, "opencode", interim));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const { relativePath, source, warning } of plan.write) {
|
|
170
|
+
// oxlint-disable-next-line no-await-in-loop -- bounded by the companion folder
|
|
171
|
+
await writeCopy(destination(repoPath, relativePath), source.content);
|
|
172
|
+
if (warning) warnings.push(warning);
|
|
173
|
+
}
|
|
174
|
+
for (const relativePath of plan.remove) {
|
|
175
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
176
|
+
await deleteCopy(repoPath, relativePath);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const recordsWritten = !sameRecords(plan.records, plan.previous);
|
|
180
|
+
if (recordsWritten) {
|
|
181
|
+
await updateManifest(repoPath, (manifest) => withCopies(manifest, "opencode", plan.records));
|
|
182
|
+
}
|
|
183
|
+
const excludesNarrowed = await reconcileWorkingRepoCopyExcludes(
|
|
184
|
+
repoPath,
|
|
185
|
+
Object.keys(plan.records),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const written = plan.write.map(({ relativePath }) => relativePath);
|
|
189
|
+
return {
|
|
190
|
+
state:
|
|
191
|
+
written.length > 0 ||
|
|
192
|
+
plan.remove.length > 0 ||
|
|
193
|
+
recordsWritten ||
|
|
194
|
+
excludesWidened ||
|
|
195
|
+
excludesNarrowed
|
|
196
|
+
? "written"
|
|
197
|
+
: "current",
|
|
198
|
+
warnings,
|
|
199
|
+
changed: { written, removed: plan.remove },
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Deletes every owned copy, then its exclude line, and prunes what that
|
|
205
|
+
* emptied: a copy is never on disk without the line that hides it.
|
|
206
|
+
*/
|
|
207
|
+
export async function withdrawCompanionCopies(repoPath: string): Promise<ProjectionWriteReport> {
|
|
208
|
+
const owned = Object.keys(copies(await readManifest(repoPath), "opencode"));
|
|
209
|
+
for (const relativePath of owned) {
|
|
210
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
211
|
+
await deleteCopy(repoPath, relativePath);
|
|
212
|
+
}
|
|
213
|
+
if (owned.length > 0) {
|
|
214
|
+
await updateManifest(repoPath, (manifest) => withCopies(manifest, "opencode", {}));
|
|
215
|
+
}
|
|
216
|
+
const excludesChanged = await reconcileWorkingRepoCopyExcludes(repoPath, []);
|
|
217
|
+
return {
|
|
218
|
+
state: owned.length > 0 || excludesChanged ? "written" : "current",
|
|
219
|
+
changed: { written: [], removed: owned },
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function removeCompanionCopies(repoPath: string): Promise<"removed" | "absent"> {
|
|
224
|
+
return (await withdrawCompanionCopies(repoPath)).state === "written" ? "removed" : "absent";
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Whether any owned copy is recorded. */
|
|
228
|
+
export async function companionCopiesPresent(repoPath: string): Promise<boolean> {
|
|
229
|
+
return Object.keys(copies(await readManifest(repoPath), "opencode")).length > 0;
|
|
230
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
|
|
6
|
+
import { generatedHeader } from "../../runtime/projection";
|
|
7
|
+
import { reconcileWorkingRepoCapabilityExcludes } from "../../tools/setup/working-repo-local-state";
|
|
8
|
+
import { ensureStoreRegistration } from "./openspec-store";
|
|
9
|
+
import type { ProjectionWriteReport, ProjectionWriteState } from "./projection-types";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The store pointer: an `openspec/config.yaml` in the Working Repository that
|
|
13
|
+
* declares the companion's OpenSpec store. Run from inside the repository,
|
|
14
|
+
* OpenSpec then plans in the store and names the repository as the edit root.
|
|
15
|
+
*
|
|
16
|
+
* The pointer's whole `openspec/` folder is hidden only through
|
|
17
|
+
* `.git/info/exclude`, never the repository's own `.gitignore`: all planning
|
|
18
|
+
* lives in the store, so nothing under it belongs to the repository. Mate
|
|
19
|
+
* never writes it where the repository already owns an OpenSpec root.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const execFileAsync = promisify(execFile);
|
|
23
|
+
|
|
24
|
+
export const OPENSPEC_POINTER_PATH = path.join("openspec", "config.yaml");
|
|
25
|
+
/** The exclusion line, outside the managed block so a core-block rewrite keeps it. */
|
|
26
|
+
export const OPENSPEC_POINTER_EXCLUDE = "/openspec/";
|
|
27
|
+
/** The earlier file-only line; still managed so a refresh replaces it. */
|
|
28
|
+
const LEGACY_POINTER_EXCLUDE = "/openspec/config.yaml";
|
|
29
|
+
const MANAGED_POINTER_EXCLUDES = [OPENSPEC_POINTER_EXCLUDE, LEGACY_POINTER_EXCLUDE];
|
|
30
|
+
|
|
31
|
+
export type StorePointer = { kind: "absent" } | { kind: "foreign" } | { kind: "mate"; id: string };
|
|
32
|
+
|
|
33
|
+
function pointerFile(repoPath: string): string {
|
|
34
|
+
return path.join(repoPath, OPENSPEC_POINTER_PATH);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function renderPointer(id: string): string {
|
|
38
|
+
return `${generatedHeader()}\nstore: ${id}\n`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function exists(candidate: string): Promise<boolean> {
|
|
42
|
+
return fs
|
|
43
|
+
.access(candidate)
|
|
44
|
+
.then(() => true)
|
|
45
|
+
.catch(() => false);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function readStorePointer(repoPath: string): Promise<StorePointer> {
|
|
49
|
+
const content = await fs.readFile(pointerFile(repoPath), "utf8").catch(() => null);
|
|
50
|
+
if (content === null) return { kind: "absent" };
|
|
51
|
+
const [first, ...rest] = content.split(/\r?\n/);
|
|
52
|
+
if (first !== generatedHeader()) return { kind: "foreign" };
|
|
53
|
+
const store = rest.map((line) => /^store:\s*(\S+)\s*$/.exec(line)?.[1]).find(Boolean);
|
|
54
|
+
return { kind: "mate", id: store ?? "" };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Why Mate may not point this repository at a store, or `null` when it may: a
|
|
59
|
+
* config Mate did not write, or a planning root of the repository's own.
|
|
60
|
+
*/
|
|
61
|
+
export async function storePointerIneligibility(repoPath: string): Promise<string | null> {
|
|
62
|
+
if ((await readStorePointer(repoPath)).kind === "foreign") {
|
|
63
|
+
return `${pointerFile(repoPath)} is not Mate's`;
|
|
64
|
+
}
|
|
65
|
+
for (const dir of ["specs", "changes"]) {
|
|
66
|
+
const planningRoot = path.join(repoPath, "openspec", dir);
|
|
67
|
+
// oxlint-disable-next-line no-await-in-loop -- two probes, first hit wins
|
|
68
|
+
if (await exists(planningRoot)) return `${planningRoot} is the repository's own OpenSpec root`;
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Whether Git would show the pointer, asked of Git itself so a re-including
|
|
75
|
+
* rule anywhere above the exclude file counts. `null` when the pointer stays
|
|
76
|
+
* out of every diff, including outside a Git work tree.
|
|
77
|
+
*/
|
|
78
|
+
export async function pointerIgnoreProblem(repoPath: string): Promise<string | null> {
|
|
79
|
+
const pointer = OPENSPEC_POINTER_PATH.split(path.sep).join("/");
|
|
80
|
+
try {
|
|
81
|
+
await execFileAsync("git", ["check-ignore", "-q", pointer], { cwd: repoPath });
|
|
82
|
+
return null;
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if ((error as { code?: unknown }).code !== 1) return null;
|
|
85
|
+
}
|
|
86
|
+
const { stdout } = await execFileAsync("git", ["check-ignore", "-v", "-n", pointer], {
|
|
87
|
+
cwd: repoPath,
|
|
88
|
+
}).catch(() => ({ stdout: "" }));
|
|
89
|
+
const [source, line, pattern] = stdout.split("\t")[0]?.split(":") ?? [];
|
|
90
|
+
return pattern
|
|
91
|
+
? `${pointer} is re-included by "${pattern}" (${source}:${line})`
|
|
92
|
+
: `${pointer} is not ignored by Git`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function persistPointer(repoPath: string, id: string): Promise<void> {
|
|
96
|
+
await fs.mkdir(path.dirname(pointerFile(repoPath)), { recursive: true });
|
|
97
|
+
await fs.writeFile(pointerFile(repoPath), renderPointer(id), "utf8");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function writeStorePointer(
|
|
101
|
+
repoPath: string,
|
|
102
|
+
id: string,
|
|
103
|
+
): Promise<"written" | "current"> {
|
|
104
|
+
const current = await readStorePointer(repoPath);
|
|
105
|
+
if (current.kind === "mate" && current.id === id) return "current";
|
|
106
|
+
await persistPointer(repoPath, id);
|
|
107
|
+
return "written";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Only Mate's pointer; `openspec/` goes too when nothing else is left in it. */
|
|
111
|
+
export async function removeStorePointer(repoPath: string): Promise<"removed" | "absent"> {
|
|
112
|
+
if ((await readStorePointer(repoPath)).kind !== "mate") return "absent";
|
|
113
|
+
await fs.rm(pointerFile(repoPath));
|
|
114
|
+
await fs.rmdir(path.dirname(pointerFile(repoPath))).catch(() => {});
|
|
115
|
+
return "removed";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function storePointerPresent(repoPath: string): Promise<boolean> {
|
|
119
|
+
return (await readStorePointer(repoPath)).kind === "mate";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Keeps the exclusion line exactly while the repository may carry Mate's pointer. */
|
|
123
|
+
export async function reconcileStorePointerExclude(repoPath: string): Promise<boolean> {
|
|
124
|
+
const eligible = (await storePointerIneligibility(repoPath)) === null;
|
|
125
|
+
return reconcileWorkingRepoCapabilityExcludes(
|
|
126
|
+
repoPath,
|
|
127
|
+
eligible ? [OPENSPEC_POINTER_EXCLUDE] : [],
|
|
128
|
+
MANAGED_POINTER_EXCLUDES,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function removeStorePointerExclude(repoPath: string): Promise<"removed" | "absent"> {
|
|
133
|
+
return (await reconcileWorkingRepoCapabilityExcludes(repoPath, [], MANAGED_POINTER_EXCLUDES))
|
|
134
|
+
? "removed"
|
|
135
|
+
: "absent";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function pointerNotWritten(reason: string): ProjectionWriteReport {
|
|
139
|
+
return { state: "skipped", warnings: [`OpenSpec store pointer not written: ${reason}`] };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Eligibility, then registration, then the pointer: a pointer naming a store
|
|
144
|
+
* OpenSpec cannot resolve would break every root-resolving command. Git is
|
|
145
|
+
* asked about the pointer only before it is created or rewritten, so a
|
|
146
|
+
* steady-state refresh spawns nothing.
|
|
147
|
+
*/
|
|
148
|
+
export async function projectStorePointer(
|
|
149
|
+
repoPath: string,
|
|
150
|
+
companionPath: string,
|
|
151
|
+
): Promise<ProjectionWriteState | ProjectionWriteReport> {
|
|
152
|
+
/** A companion without OpenSpec has no store to point at; nothing to say. */
|
|
153
|
+
if (!(await exists(path.join(companionPath, "openspec")))) return "skipped";
|
|
154
|
+
const ineligible = await storePointerIneligibility(repoPath);
|
|
155
|
+
if (ineligible) return pointerNotWritten(ineligible);
|
|
156
|
+
const registration = await ensureStoreRegistration(companionPath);
|
|
157
|
+
if (registration.state === "skipped") return pointerNotWritten(registration.reason);
|
|
158
|
+
const current = await readStorePointer(repoPath);
|
|
159
|
+
if (current.kind === "mate" && current.id === registration.id) return registration.state;
|
|
160
|
+
const ignoreProblem = await pointerIgnoreProblem(repoPath);
|
|
161
|
+
if (ignoreProblem) return pointerNotWritten(ignoreProblem);
|
|
162
|
+
await persistPointer(repoPath, registration.id);
|
|
163
|
+
return "written";
|
|
164
|
+
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
GRAPHIFY_OUTPUT_SUBDIR,
|
|
11
11
|
GRAPHIFY_STORE_SEGMENT,
|
|
12
12
|
} from "../../tools/setup/capabilities/graphify";
|
|
13
|
+
import { runtimeWrapperBinPath } from "../../runtime/runtime-directory";
|
|
13
14
|
import { getReactDoctorBinPath, getWrapperBinPath } from "../package-paths";
|
|
14
15
|
import type { LinkedRepository } from "./types";
|
|
15
16
|
|
|
@@ -25,17 +26,22 @@ const COMPANION_GRAPHIFY_REPOSITORY_ID = "__companion__";
|
|
|
25
26
|
* Both bin paths and `GRAPHIFY_OUT` are unconditional: a path exists whether or
|
|
26
27
|
* not its Capability is enabled, and enablement is a predicate, read live from
|
|
27
28
|
* the companion rather than cached here.
|
|
29
|
+
*
|
|
30
|
+
* The persisted record names the runtime directory so it survives a reinstall;
|
|
31
|
+
* a launch passes the live install path, since its runtime-directory refresh
|
|
32
|
+
* may only have warned.
|
|
28
33
|
*/
|
|
29
34
|
export function buildProjection(
|
|
30
35
|
companionPath: string,
|
|
31
36
|
repository: LinkedRepository,
|
|
37
|
+
wrapperBinPath: string = runtimeWrapperBinPath(),
|
|
32
38
|
): MateProjection {
|
|
33
39
|
return {
|
|
34
40
|
version,
|
|
35
41
|
companionPath,
|
|
36
42
|
repositoryPath: repository.path,
|
|
37
43
|
repositoryId: repository.id,
|
|
38
|
-
wrapperBinPath
|
|
44
|
+
wrapperBinPath,
|
|
39
45
|
reactDoctorBinPath: getReactDoctorBinPath(),
|
|
40
46
|
graphifyOut: path.join(
|
|
41
47
|
companionPath,
|