@uniqbit/mate-core 0.15.5 → 0.16.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/.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 +6 -4
- 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/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/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 +147 -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 +147 -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 +47 -0
- 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
|
@@ -3,229 +3,23 @@ import path from "node:path";
|
|
|
3
3
|
|
|
4
4
|
import type { Hooks, Plugin, PluginInput } from "@opencode-ai/plugin";
|
|
5
5
|
|
|
6
|
+
import {
|
|
7
|
+
companionForkRefusal,
|
|
8
|
+
syncCompanionUnattended,
|
|
9
|
+
unattendedSyncStalenessLines,
|
|
10
|
+
} from "../runtime/companion-sync";
|
|
11
|
+
import { hasLaunchEnvironment } from "../runtime/env";
|
|
6
12
|
import {
|
|
7
13
|
buildArtifactError,
|
|
8
14
|
extractPatchPaths,
|
|
15
|
+
isArtifactPath,
|
|
16
|
+
normalizeTargetPath,
|
|
9
17
|
readContext,
|
|
10
18
|
shouldBlockArtifactWrite,
|
|
11
19
|
type CompanionContext,
|
|
12
20
|
} from "./companion-policy";
|
|
13
21
|
|
|
14
|
-
const
|
|
15
|
-
const ARCHIVE_PATH_PATTERN =
|
|
16
|
-
/(?:^|[\s/"'`,(])openspec\/changes\/archive\/(\d{4}-\d{2}-\d{2}-[^/\s"'`,)]+)/;
|
|
17
|
-
|
|
18
|
-
function readArchiveEntries(archiveDir: string): Set<string> {
|
|
19
|
-
try {
|
|
20
|
-
const entries = new Set<string>();
|
|
21
|
-
for (const entry of fs.readdirSync(archiveDir, { withFileTypes: true })) {
|
|
22
|
-
if (entry.isDirectory() && ARCHIVE_ENTRY_PATTERN.test(entry.name)) entries.add(entry.name);
|
|
23
|
-
}
|
|
24
|
-
return entries;
|
|
25
|
-
} catch {
|
|
26
|
-
return new Set();
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function shellSplit(command: string): string[] {
|
|
31
|
-
const parts: string[] = [];
|
|
32
|
-
let current = "";
|
|
33
|
-
let quote: "'" | '"' | null = null;
|
|
34
|
-
let escaping = false;
|
|
35
|
-
|
|
36
|
-
for (let index = 0; index < command.length; index += 1) {
|
|
37
|
-
const char = command[index];
|
|
38
|
-
if (escaping) {
|
|
39
|
-
current += char;
|
|
40
|
-
escaping = false;
|
|
41
|
-
continue;
|
|
42
|
-
}
|
|
43
|
-
if (char === "\\" && quote !== "'") {
|
|
44
|
-
const next = command[index + 1] ?? "";
|
|
45
|
-
if (/^[\\\s'"]$/.test(next)) {
|
|
46
|
-
escaping = true;
|
|
47
|
-
continue;
|
|
48
|
-
}
|
|
49
|
-
current += char;
|
|
50
|
-
continue;
|
|
51
|
-
}
|
|
52
|
-
if ((char === "'" || char === '"') && !quote) {
|
|
53
|
-
quote = char;
|
|
54
|
-
continue;
|
|
55
|
-
}
|
|
56
|
-
if (quote === char) {
|
|
57
|
-
quote = null;
|
|
58
|
-
continue;
|
|
59
|
-
}
|
|
60
|
-
if (!quote && /\s/.test(char)) {
|
|
61
|
-
if (current) {
|
|
62
|
-
parts.push(current);
|
|
63
|
-
current = "";
|
|
64
|
-
}
|
|
65
|
-
continue;
|
|
66
|
-
}
|
|
67
|
-
current += char;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
if (current) parts.push(current);
|
|
71
|
-
return parts;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function extractArchivedChangeFromPath(value: string): string | null {
|
|
75
|
-
const normalized = value.replace(/\\/g, "/");
|
|
76
|
-
const match = normalized.match(ARCHIVE_PATH_PATTERN);
|
|
77
|
-
return match ? match[1].replace(/^\d{4}-\d{2}-\d{2}-/, "") : null;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function extractArchiveCommandChange(command: string): string | null {
|
|
81
|
-
const parts = shellSplit(command);
|
|
82
|
-
const archiveIndex = parts.findIndex(
|
|
83
|
-
(part, index) =>
|
|
84
|
-
part === "archive" &&
|
|
85
|
-
index > 0 &&
|
|
86
|
-
(parts[index - 1] === "openspec" || parts[index - 1].endsWith("/openspec")),
|
|
87
|
-
);
|
|
88
|
-
if (archiveIndex < 0) return null;
|
|
89
|
-
|
|
90
|
-
for (let index = archiveIndex + 1; index < parts.length; index += 1) {
|
|
91
|
-
const part = parts[index];
|
|
92
|
-
if (!part || part === "--") continue;
|
|
93
|
-
if (part.startsWith("-")) {
|
|
94
|
-
if (part === "--store") index += 1;
|
|
95
|
-
continue;
|
|
96
|
-
}
|
|
97
|
-
return part;
|
|
98
|
-
}
|
|
99
|
-
return null;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function extractMoveArchiveChange(commandText: string): string | null {
|
|
103
|
-
const parts = shellSplit(commandText);
|
|
104
|
-
const moveCommands = new Set([
|
|
105
|
-
"mv",
|
|
106
|
-
"move",
|
|
107
|
-
"move-item",
|
|
108
|
-
"cmd",
|
|
109
|
-
"cmd.exe",
|
|
110
|
-
"powershell",
|
|
111
|
-
"powershell.exe",
|
|
112
|
-
"pwsh",
|
|
113
|
-
"pwsh.exe",
|
|
114
|
-
]);
|
|
115
|
-
const isPython = (part: string) =>
|
|
116
|
-
/^python(?:\d(?:\.\d+)*)?(?:\.exe)?$/i.test(part.split(/[\\/]/).pop() ?? part);
|
|
117
|
-
|
|
118
|
-
for (let index = 0; index < parts.length; index += 1) {
|
|
119
|
-
const command = parts[index].toLowerCase();
|
|
120
|
-
if (moveCommands.has(command)) {
|
|
121
|
-
for (const part of parts.slice(index + 1)) {
|
|
122
|
-
const change = extractArchivedChangeFromPath(part);
|
|
123
|
-
if (change) return change;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
if (isPython(parts[index]) && /(?:shutil\.move|os\.rename|\.rename\s*\()/.test(commandText)) {
|
|
127
|
-
return extractArchivedChangeFromPath(commandText);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
return null;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function collectCommands(value: unknown): string[] {
|
|
134
|
-
if (!value || typeof value !== "object") return [];
|
|
135
|
-
const record = value as Record<string, unknown>;
|
|
136
|
-
const commands: string[] = [];
|
|
137
|
-
if (typeof record.command === "string") commands.push(record.command);
|
|
138
|
-
for (const key of ["args", "tool_input"] as const) {
|
|
139
|
-
const nested = record[key];
|
|
140
|
-
if (nested && typeof nested === "object") {
|
|
141
|
-
const command = (nested as Record<string, unknown>).command;
|
|
142
|
-
if (typeof command === "string") commands.push(command);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
return commands;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function commandSucceeded(value: unknown): boolean {
|
|
149
|
-
if (!value || typeof value !== "object") return true;
|
|
150
|
-
const record = value as Record<string, unknown>;
|
|
151
|
-
const candidates = [
|
|
152
|
-
record,
|
|
153
|
-
record.result,
|
|
154
|
-
record.output,
|
|
155
|
-
record.tool_output,
|
|
156
|
-
record.tool_response,
|
|
157
|
-
];
|
|
158
|
-
for (const candidate of candidates) {
|
|
159
|
-
if (!candidate || typeof candidate !== "object") continue;
|
|
160
|
-
const result = candidate as Record<string, unknown>;
|
|
161
|
-
if (typeof result.success === "boolean") return result.success;
|
|
162
|
-
for (const key of ["exitCode", "exit_code", "status", "statusCode", "status_code"] as const) {
|
|
163
|
-
const status = result[key];
|
|
164
|
-
if (typeof status === "number") return status === 0;
|
|
165
|
-
if (typeof status === "string" && status.trim()) return Number(status) === 0;
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
return true;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function detectCommandArchive(input: unknown, output: unknown): string | null {
|
|
172
|
-
if (!commandSucceeded(input) || !commandSucceeded(output)) return null;
|
|
173
|
-
for (const command of [...collectCommands(input), ...collectCommands(output)]) {
|
|
174
|
-
const change = extractArchiveCommandChange(command) ?? extractMoveArchiveChange(command);
|
|
175
|
-
if (change) return change;
|
|
176
|
-
}
|
|
177
|
-
return null;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function detectNewlyArchivedChanges(archiveDir: string, snapshot: Set<string>): string[] {
|
|
181
|
-
const current = readArchiveEntries(archiveDir);
|
|
182
|
-
return [...current].filter((entry) => !snapshot.has(entry)).toSorted();
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function appendOpenSpecFinishNudge(
|
|
186
|
-
context: CompanionContext,
|
|
187
|
-
archiveSnapshot: Set<string> | undefined,
|
|
188
|
-
nudgedCommandChanges: Set<string>,
|
|
189
|
-
input: { tool?: unknown },
|
|
190
|
-
output: { output?: string },
|
|
191
|
-
): void {
|
|
192
|
-
if (!context.gitAutoModeEnabled) return;
|
|
193
|
-
|
|
194
|
-
const archiveDir = path.join(context.companionPath, "openspec", "changes", "archive");
|
|
195
|
-
const changes: string[] = [];
|
|
196
|
-
if (archiveSnapshot) {
|
|
197
|
-
for (const entry of detectNewlyArchivedChanges(archiveDir, archiveSnapshot)) {
|
|
198
|
-
changes.push(entry.slice("YYYY-MM-DD-".length));
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
const commandChange =
|
|
202
|
-
String(input.tool ?? "").toLowerCase() === "bash" ? detectCommandArchive(input, output) : null;
|
|
203
|
-
if (commandChange && !nudgedCommandChanges.has(commandChange)) {
|
|
204
|
-
changes.push(commandChange);
|
|
205
|
-
nudgedCommandChanges.add(commandChange);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
const uniqueChanges = [...new Set(changes)];
|
|
209
|
-
if (uniqueChanges.length === 0) return;
|
|
210
|
-
const nudges = uniqueChanges.map(
|
|
211
|
-
(change) =>
|
|
212
|
-
`ACTION REQUIRED: An openspec change (${change}) was just archived. ` +
|
|
213
|
-
`Immediately invoke the mate-artifact-finish skill to complete the finish pipeline ` +
|
|
214
|
-
"(invoke the CLI as `mate`, never through a companion-local wrapper path or as `" +
|
|
215
|
-
"$MATE_COMPANION_BIN_PATH/mate`). " +
|
|
216
|
-
"`mate artifact finish` resumes from the already-archived change and completes the commit, dated tag, and push itself — " +
|
|
217
|
-
"do not hand-commit, do not hand-tag, and do not re-apply delta specs manually. " +
|
|
218
|
-
`Follow the skill's guardrails, completing the non-interactive happy path without asking for confirmation.`,
|
|
219
|
-
);
|
|
220
|
-
output.output = [output.output, ...nudges]
|
|
221
|
-
.flatMap((part) => {
|
|
222
|
-
const trimmed = (part ?? "").trim();
|
|
223
|
-
return trimmed ? [trimmed] : [];
|
|
224
|
-
})
|
|
225
|
-
.join("\n\n");
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
const REACT_DOCTOR_VERSION = "0.8.1";
|
|
22
|
+
const REACT_DOCTOR_VERSION = "0.9.13";
|
|
229
23
|
|
|
230
24
|
const REACT_DOCTOR_NON_LINT_FAILURES = [
|
|
231
25
|
/No React dependency found/i,
|
|
@@ -311,37 +105,97 @@ async function runReactDoctorScan(
|
|
|
311
105
|
}
|
|
312
106
|
}
|
|
313
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Keyed by companion so a double-loaded plugin repairs at most once per
|
|
110
|
+
* session, which is the guarantee the spec asks for.
|
|
111
|
+
*/
|
|
112
|
+
const repairedCompanions = new Set<string>();
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Session start is the only point at which the companion may move without
|
|
116
|
+
* invalidating a read the session has already taken. The launch-environment
|
|
117
|
+
* gate comes first so a Managed Session does no Git work at all; the consent
|
|
118
|
+
* gate is the operation's own.
|
|
119
|
+
*/
|
|
120
|
+
export async function repairCompanionGitOnce(
|
|
121
|
+
context: CompanionContext,
|
|
122
|
+
client: PluginInput["client"] | undefined,
|
|
123
|
+
env: Record<string, string | undefined> = process.env,
|
|
124
|
+
): Promise<string[]> {
|
|
125
|
+
if (!context.companionPath || hasLaunchEnvironment(env)) return [];
|
|
126
|
+
if (repairedCompanions.has(context.companionPath)) return [];
|
|
127
|
+
repairedCompanions.add(context.companionPath);
|
|
128
|
+
|
|
129
|
+
const notes = unattendedSyncStalenessLines(syncCompanionUnattended(context.companionPath));
|
|
130
|
+
if (notes.length === 0) return [];
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Operator-facing only: a model told to run the command would run it
|
|
134
|
+
* unattended. Delivery is best-effort in both directions — a client that
|
|
135
|
+
* throws rather than rejecting must not cost the caller its notes.
|
|
136
|
+
*/
|
|
137
|
+
try {
|
|
138
|
+
await client?.tui?.showToast({
|
|
139
|
+
query: { directory: context.repositoryPath },
|
|
140
|
+
body: {
|
|
141
|
+
title: `${context.frameworkName} companion`,
|
|
142
|
+
message: notes.join("\n"),
|
|
143
|
+
variant: "warning",
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
} catch {
|
|
147
|
+
/** The note survives in the persisted record and the TUI's staleness lines. */
|
|
148
|
+
}
|
|
149
|
+
return notes;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Test seam: the once-per-session guard is process-wide by design. */
|
|
153
|
+
export function resetCompanionGitRepairGuard(): void {
|
|
154
|
+
repairedCompanions.clear();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Writing artifacts on top of a forked companion is the state in which the
|
|
159
|
+
* eventual reconciliation destroys work. The verdict comes from `runtime/`, so
|
|
160
|
+
* the Claude hook and this middleware refuse the same writes for the same
|
|
161
|
+
* reason — including the launch-environment gate that keeps a Managed
|
|
162
|
+
* Launch's Git decision authoritative for the session it started.
|
|
163
|
+
*/
|
|
164
|
+
export function refuseForkedCompanionWrite(
|
|
165
|
+
context: CompanionContext,
|
|
166
|
+
filePath: string,
|
|
167
|
+
env: Record<string, string | undefined> = process.env,
|
|
168
|
+
): void {
|
|
169
|
+
if (!filePath || !isArtifactPath(filePath)) return;
|
|
170
|
+
if (!normalizeTargetPath(context, filePath).startsWith(path.normalize(context.companionPath))) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const refusal = companionForkRefusal(env, context.companionPath);
|
|
174
|
+
if (refusal) throw new Error(refusal);
|
|
175
|
+
}
|
|
176
|
+
|
|
314
177
|
type PluginEventInput = Parameters<NonNullable<Hooks["event"]>>[0];
|
|
315
178
|
type ToolBeforeInput = Parameters<NonNullable<Hooks["tool.execute.before"]>>[0];
|
|
316
179
|
type ToolBeforeOutput = Parameters<NonNullable<Hooks["tool.execute.before"]>>[1];
|
|
317
180
|
type ToolAfterInput = Parameters<NonNullable<Hooks["tool.execute.after"]>>[0];
|
|
318
|
-
type ToolAfterOutput = Parameters<NonNullable<Hooks["tool.execute.after"]>>[1];
|
|
319
|
-
|
|
320
|
-
interface ArchiveCallSnapshot {
|
|
321
|
-
sessionID: string;
|
|
322
|
-
entries: Set<string>;
|
|
323
|
-
}
|
|
324
181
|
|
|
325
182
|
export const CompanionHooksPlugin: Plugin = async (pluginInput = {} as PluginInput) => {
|
|
326
183
|
const { client, $ } = pluginInput;
|
|
327
|
-
const context = readContext(
|
|
184
|
+
const context = readContext();
|
|
328
185
|
if (!context.companionPath || !context.repositoryPath) return {};
|
|
329
186
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
187
|
+
/**
|
|
188
|
+
* The repair sits above the returned hooks, so anything escaping it would
|
|
189
|
+
* cost the session every guardrail below — the artifact guard, the React
|
|
190
|
+
* Doctor scan — to save a synchronization that is optional by design.
|
|
191
|
+
*/
|
|
192
|
+
await repairCompanionGitOnce(context, client).catch(() => []);
|
|
193
|
+
|
|
333
194
|
const dirtyReactDoctorSessions = new Set<string>();
|
|
334
195
|
const reactDoctorScansInFlight = new Set<string>();
|
|
335
196
|
|
|
336
197
|
return {
|
|
337
198
|
event: async ({ event }: PluginEventInput) => {
|
|
338
|
-
if (event.type === "session.deleted") {
|
|
339
|
-
const sessionID = event.properties.info.id;
|
|
340
|
-
for (const [callID, snapshot] of archiveCallSnapshots) {
|
|
341
|
-
if (snapshot.sessionID === sessionID) archiveCallSnapshots.delete(callID);
|
|
342
|
-
}
|
|
343
|
-
return;
|
|
344
|
-
}
|
|
345
199
|
if (event.type !== "session.idle") return;
|
|
346
200
|
const sessionID = event.properties.sessionID;
|
|
347
201
|
if (
|
|
@@ -361,31 +215,21 @@ export const CompanionHooksPlugin: Plugin = async (pluginInput = {} as PluginInp
|
|
|
361
215
|
if (filePath && shouldBlockArtifactWrite(context, filePath)) {
|
|
362
216
|
throw new Error(buildArtifactError(context, filePath));
|
|
363
217
|
}
|
|
218
|
+
refuseForkedCompanionWrite(context, filePath);
|
|
364
219
|
}
|
|
365
220
|
if (toolName === "apply_patch") {
|
|
366
221
|
for (const filePath of extractPatchPaths(String(args.patchText ?? ""))) {
|
|
367
222
|
if (shouldBlockArtifactWrite(context, filePath)) {
|
|
368
223
|
throw new Error(buildArtifactError(context, filePath));
|
|
369
224
|
}
|
|
225
|
+
refuseForkedCompanionWrite(context, filePath);
|
|
370
226
|
}
|
|
371
227
|
}
|
|
372
|
-
if (context.gitAutoModeEnabled) {
|
|
373
|
-
archiveCallSnapshots.set(input.callID, {
|
|
374
|
-
sessionID: input.sessionID,
|
|
375
|
-
entries: readArchiveEntries(archiveDir),
|
|
376
|
-
});
|
|
377
|
-
}
|
|
378
228
|
},
|
|
379
|
-
"tool.execute.after": async (input: ToolAfterInput
|
|
229
|
+
"tool.execute.after": async (input: ToolAfterInput) => {
|
|
380
230
|
if (context.reactDoctorEnabled && REACT_DOCTOR_EDIT_TOOLS.has(input.tool)) {
|
|
381
231
|
dirtyReactDoctorSessions.add(input.sessionID);
|
|
382
232
|
}
|
|
383
|
-
const archiveSnapshot = archiveCallSnapshots.get(input.callID)?.entries;
|
|
384
|
-
archiveCallSnapshots.delete(input.callID);
|
|
385
|
-
appendOpenSpecFinishNudge(context, archiveSnapshot, nudgedCommandChanges, input, output);
|
|
386
|
-
},
|
|
387
|
-
dispose: async () => {
|
|
388
|
-
archiveCallSnapshots.clear();
|
|
389
233
|
},
|
|
390
234
|
};
|
|
391
235
|
};
|
|
@@ -2,10 +2,18 @@ import { spawnSync } from "node:child_process";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { persistedCompanionGitStalenessLines } from "../runtime/companion-sync";
|
|
6
|
+
import { resolveCompanionRuntime, type CompanionRuntimeContext } from "../runtime/env";
|
|
7
|
+
import { projectionFreshness, projectionStalenessLines } from "../runtime/freshness";
|
|
6
8
|
|
|
7
9
|
export type CompanionContext = CompanionRuntimeContext & {
|
|
8
10
|
agentsMd: string;
|
|
11
|
+
/**
|
|
12
|
+
* Operator-facing session state: a stale projection, and an unfinished
|
|
13
|
+
* companion Git synchronization. Never reaches a model payload — the TUI is
|
|
14
|
+
* its only reader.
|
|
15
|
+
*/
|
|
16
|
+
stalenessLines: string[];
|
|
9
17
|
};
|
|
10
18
|
|
|
11
19
|
const ARTIFACT_BASENAMES = new Set([
|
|
@@ -158,20 +166,37 @@ export function buildArtifactError(context: CompanionContext, filePath: string):
|
|
|
158
166
|
].join("\n");
|
|
159
167
|
}
|
|
160
168
|
|
|
161
|
-
|
|
162
|
-
|
|
169
|
+
/**
|
|
170
|
+
* Resolves its own companion — launch environment first, Projection Root
|
|
171
|
+
* second — rather than taking one from the caller. `AGENTS.md` follows the
|
|
172
|
+
* resolved companion so the two cannot name different repositories.
|
|
173
|
+
*/
|
|
174
|
+
export function readContext(
|
|
175
|
+
env: Record<string, string | undefined> = process.env,
|
|
176
|
+
cwd: string = process.cwd(),
|
|
177
|
+
): CompanionContext {
|
|
178
|
+
const { context, projection } = resolveCompanionRuntime(env, cwd);
|
|
179
|
+
|
|
163
180
|
let agentsMd = "";
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
181
|
+
if (context.companionPath) {
|
|
182
|
+
const agentsMdPath = path.join(context.companionPath, "AGENTS.md");
|
|
183
|
+
try {
|
|
184
|
+
if (fs.existsSync(agentsMdPath)) {
|
|
185
|
+
agentsMd = fs.readFileSync(agentsMdPath, "utf8");
|
|
186
|
+
}
|
|
187
|
+
} catch {
|
|
188
|
+
// Ignore read errors; AGENTS.md is optional
|
|
167
189
|
}
|
|
168
|
-
} catch {
|
|
169
|
-
// Ignore read errors; AGENTS.md is optional
|
|
170
190
|
}
|
|
171
191
|
|
|
172
192
|
return {
|
|
173
|
-
...
|
|
174
|
-
companionPath,
|
|
193
|
+
...context,
|
|
175
194
|
agentsMd,
|
|
195
|
+
stalenessLines: projection
|
|
196
|
+
? [
|
|
197
|
+
...projectionStalenessLines(projection, projectionFreshness(projection)),
|
|
198
|
+
...persistedCompanionGitStalenessLines(context.companionPath),
|
|
199
|
+
]
|
|
200
|
+
: [],
|
|
176
201
|
};
|
|
177
202
|
}
|
package/src/opencode/index.ts
CHANGED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { buildOpenCodeGuidance } from "../runtime/companion-guidance";
|
|
2
|
+
import { MATE_ENV } from "../runtime/env-names";
|
|
3
|
+
import { hasLaunchEnvironment } from "../runtime/env";
|
|
4
|
+
import { parseGuidanceContent, type MateGuidanceFile } from "../runtime/guidance";
|
|
5
|
+
import { readCompanionPolicy } from "../runtime/policy";
|
|
6
|
+
import { resolveProjection } from "../runtime/projection";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `null` guidance with no errors is the inert case — no companion resolves, so
|
|
10
|
+
* the plugin contributes nothing. Errors are startup failures: a managed launch
|
|
11
|
+
* that failed to inject its payload is broken and must say so.
|
|
12
|
+
*/
|
|
13
|
+
export interface OpenCodeGuidanceResolution {
|
|
14
|
+
guidance: MateGuidanceFile | null;
|
|
15
|
+
errors: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The OpenCode guidance payload, from the launch environment when Mate started
|
|
20
|
+
* the session and from the Projection Root otherwise — the same env-first,
|
|
21
|
+
* projection-second contract `readCompanionRuntimeContext` already follows.
|
|
22
|
+
*
|
|
23
|
+
* The projected branch needs nothing but the enabled Capability names: the
|
|
24
|
+
* payload carries `$MATE_*` placeholders that the plugin materializes from its
|
|
25
|
+
* own resolved context, so no path is resolved here. Reading the names live
|
|
26
|
+
* means a Capability toggled after a wrap changes the guidance on the next
|
|
27
|
+
* session with no re-wrap.
|
|
28
|
+
*
|
|
29
|
+
* A missing payload while a launch environment *is* present stays a hard error.
|
|
30
|
+
* That combination means a managed launch failed to inject, which the projection
|
|
31
|
+
* must not paper over.
|
|
32
|
+
*/
|
|
33
|
+
export function resolveOpenCodeGuidance(
|
|
34
|
+
env: Record<string, string | undefined> = process.env,
|
|
35
|
+
cwd: string = process.cwd(),
|
|
36
|
+
): OpenCodeGuidanceResolution {
|
|
37
|
+
const raw = env[MATE_ENV.guidanceJson];
|
|
38
|
+
if (raw?.trim()) {
|
|
39
|
+
const { guidance, errors } = parseGuidanceContent(raw);
|
|
40
|
+
return { guidance: errors.length > 0 ? null : guidance, errors };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (hasLaunchEnvironment(env)) {
|
|
44
|
+
return {
|
|
45
|
+
guidance: null,
|
|
46
|
+
errors: [`missing ${MATE_ENV.guidanceJson} in the launch environment`],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const projection = resolveProjection(cwd);
|
|
51
|
+
if (!projection) return { guidance: null, errors: [] };
|
|
52
|
+
|
|
53
|
+
const policy = readCompanionPolicy(projection.companionPath);
|
|
54
|
+
const guidance = buildOpenCodeGuidance(policy.enabledCapabilities.map((name) => ({ name })));
|
|
55
|
+
return { guidance: guidance.errors.length > 0 ? null : guidance, errors: guidance.errors };
|
|
56
|
+
}
|
package/src/opencode/tui.tsx
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
|
-
/* oxlint-disable react/no-unknown-property, react/react-in-jsx-scope */
|
|
2
|
+
/* oxlint-disable react/no-unknown-property, react/react-in-jsx-scope, react/jsx-key, react-doctor/jsx-key */
|
|
3
|
+
/**
|
|
4
|
+
* Solid JSX, not React: `key` is not a reconciliation prop here and `TextProps`
|
|
5
|
+
* does not accept it, so the jsx-key rules cannot be satisfied — only suppressed.
|
|
6
|
+
* Static arrays render through `.map`; a reactive one would need `<For>`.
|
|
7
|
+
*/
|
|
3
8
|
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui";
|
|
4
9
|
|
|
10
|
+
import { mateVersion } from "../runtime/install";
|
|
5
11
|
import { readContext } from "./companion-policy";
|
|
6
12
|
|
|
7
13
|
const MIDNIGHT_PURPLE_BRIGHT = "#c084fc";
|
|
8
14
|
const NARROW_TERMINAL_WIDTH = 80;
|
|
9
|
-
const MATE_VERSION = process.env.MATE_VERSION ??
|
|
15
|
+
const MATE_VERSION = process.env.MATE_VERSION ?? mateVersion();
|
|
10
16
|
|
|
11
17
|
function SessionContext({
|
|
12
18
|
api,
|
|
@@ -17,7 +23,7 @@ function SessionContext({
|
|
|
17
23
|
compact?: boolean;
|
|
18
24
|
sidebar?: boolean;
|
|
19
25
|
}) {
|
|
20
|
-
const context = readContext(
|
|
26
|
+
const context = readContext();
|
|
21
27
|
const theme = api.theme.current;
|
|
22
28
|
|
|
23
29
|
if (compact) {
|
|
@@ -35,12 +41,15 @@ function SessionContext({
|
|
|
35
41
|
<text fg={MIDNIGHT_PURPLE_BRIGHT}>mate v{MATE_VERSION}</text>
|
|
36
42
|
<text fg={theme.textMuted}>repo: {context.repositoryPath}</text>
|
|
37
43
|
<text fg={theme.textMuted}>mate: {context.companionPath}</text>
|
|
44
|
+
{context.stalenessLines.map((note) => (
|
|
45
|
+
<text fg={theme.warning ?? theme.textMuted}>{note}</text>
|
|
46
|
+
))}
|
|
38
47
|
</box>
|
|
39
48
|
);
|
|
40
49
|
}
|
|
41
50
|
|
|
42
51
|
const tui: TuiPlugin = async (api) => {
|
|
43
|
-
const context = readContext(
|
|
52
|
+
const context = readContext();
|
|
44
53
|
if (!context.companionPath || !context.repositoryPath) {
|
|
45
54
|
return;
|
|
46
55
|
}
|