@soleur/opencode 1.0.0 → 3.354.2
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/commands/go.md +6 -5
- package/commands/sync.md +2 -2
- package/install.js +6 -85
- package/lib/harness.ts +1 -1
- package/lib/heartbeat-manifest.ts +19 -0
- package/lib/pr-merge-poll.ts +2 -2
- package/lib/workflow-fidelity.ts +2 -2
- package/opencode/INSTRUCTIONS.md +19 -0
- package/opencode/plugin.ts +343 -18
- package/package.json +1 -1
- package/scripts/admin-merge-ready.sh +1 -1
- package/scripts/admin-merge-ready.test.sh +0 -0
- package/scripts/affected-tests.sh +0 -0
- package/scripts/alpha-metrics.sh +0 -0
- package/scripts/alpha-metrics.test.sh +0 -0
- package/scripts/check-red-on-main.sh +0 -0
- package/scripts/ci-head-verdict.sh +54 -6
- package/scripts/cloud-detect.sh +0 -0
- package/scripts/delegation-verify.sh +0 -0
- package/scripts/delegation-verify.test.sh +0 -0
- package/scripts/deploy-arm.sh +0 -0
- package/scripts/domain-model-drift.sh +0 -0
- package/scripts/emit-decision.sh +0 -0
- package/scripts/emit-decision.test.sh +0 -0
- package/scripts/ext-filter-divergence.sh +0 -0
- package/scripts/grok-fidelity-gate.sh +0 -0
- package/scripts/grok-pre-push-gate.sh +0 -0
- package/scripts/install-opencode-adapter.ts +118 -129
- package/scripts/lib/affected-tests/common.sh +0 -0
- package/scripts/lib/affected-tests/go.sh +0 -0
- package/scripts/lib/affected-tests/js.sh +0 -0
- package/scripts/lib/affected-tests/nx-turbo.sh +0 -0
- package/scripts/lib/affected-tests/python.sh +0 -0
- package/scripts/lib/session-state.sh +0 -0
- package/scripts/lib/tmp-classify.sh +0 -0
- package/scripts/monitor-pr-checks.sh +1 -1
- package/scripts/pipeline-tally.sh +0 -0
- package/scripts/plugin-version.sh +0 -0
- package/scripts/precommit-guard.sh +3 -1
- package/scripts/precommit-guard.test.sh +0 -0
- package/scripts/render-c4-model.sh +0 -0
- package/scripts/resolve-regenerable-conflicts.sh +0 -0
- package/scripts/run-in-pid-namespace.sh +0 -0
- package/scripts/setup-ralph-loop.sh +0 -0
- package/scripts/sync-npm-package.ts +74 -87
- package/scripts/sync-opencode-adapter.ts +19 -0
- package/scripts/sync-pr-behind.sh +183 -70
- package/scripts/taste-profile-update.sh +0 -0
- package/scripts/taste-profile-update.test.sh +0 -0
- package/scripts/write-budget-marker.sh +0 -0
- package/skills/deepen-plan/SKILL.md +1 -1
- package/skills/deploy/references/hetzner-setup.md +5 -3
- package/skills/drain-prs/SKILL.md +3 -2
- package/skills/fix-issue/SKILL.md +8 -8
- package/skills/fix-issue/references/agent-authored-exclusion.md +4 -2
- package/skills/fix-issue/references/exclude-label-jq-snippet.md +4 -3
- package/skills/merge-pr/SKILL.md +5 -3
- package/skills/model-launch-review/SKILL.md +4 -4
- package/skills/one-shot/SKILL.md +1 -1
- package/skills/plan/SKILL.md +1 -1
- package/skills/plan/references/plan-sharp-edges.md +7 -1
- package/skills/postmerge/SKILL.md +2 -2
- package/skills/postmerge/references/deploy-status-debugging.md +3 -0
- package/skills/review/SKILL.md +14 -15
- package/skills/review/references/review-evidence-comment.md +36 -0
- package/skills/review/references/risk-tier-and-fix-rounds.md +2 -0
- package/skills/ship/SKILL.md +14 -13
- package/skills/ship/references/cleanup-merged-archival.md +39 -0
- package/skills/ship/references/delegated-act-authoring-form.md +7 -0
- package/skills/ship/references/epic-close-sweep.md +62 -0
- package/skills/ship/references/merge-queue-dequeue.md +1 -1
- package/skills/ship/references/operator-holds.md +30 -0
- package/skills/ship/references/queue-mode.md +1 -1
- package/skills/ship/references/ready-run-wait.md +3 -3
- package/skills/test-fix-loop/SKILL.md +1 -1
- package/skills/verification-scaffold/SKILL.md +73 -0
- package/skills/verification-scaffold/references/gitleaks.toml.template +13 -0
- package/skills/verification-scaffold/references/guardrail-audit.md +156 -0
- package/skills/verification-scaffold/references/guards/block-stash-hook.sh.template +72 -0
- package/skills/verification-scaffold/references/guards/commit-on-main-hook.sh.template +82 -0
- package/skills/verification-scaffold/references/guards/orphan-test-check.sh.template +102 -0
- package/skills/verification-scaffold/references/guards/precommit-guard.sh.template +115 -0
- package/skills/verification-scaffold/references/guards/secret-scan-hook.sh.template +176 -0
- package/skills/verification-scaffold/references/guards/verification-gates.yml.template +49 -0
- package/skills/verification-scaffold/references/guards/wrapper-boundary.sh.template +47 -0
- package/skills/verification-scaffold/references/guards.tsv +21 -0
- package/skills/verification-scaffold/references/verification-readme.template +55 -0
- package/skills/verification-scaffold/scripts/verification-scaffold.sh +785 -0
- package/skills/verification-scaffold/test/bite-proof.test.sh +159 -0
- package/skills/verification-scaffold/test/generator.test.sh +319 -0
- package/skills/verification-scaffold/test/manifest.test.sh +144 -0
- package/skills/work/SKILL.md +2 -1
|
@@ -16,9 +16,12 @@
|
|
|
16
16
|
* Shapes here are each forced by a measurement:
|
|
17
17
|
*
|
|
18
18
|
* - `agents/` and `commands/` are copied straight into the config dir, because those are
|
|
19
|
-
* the roots OpenCode discovers (§3.0, §4.0). Skills are **not** copied
|
|
20
|
-
* from the canonical tree through the `skills` key (§2.0), so the
|
|
21
|
-
* checkout and a second skill tree that could drift is never created.
|
|
19
|
+
* the roots OpenCode discovers (§3.0, §4.0). Skills are **not** copied on a checkout
|
|
20
|
+
* install — they register from the canonical tree through the `skills` key (§2.0), so the
|
|
21
|
+
* config points at the checkout and a second skill tree that could drift is never created.
|
|
22
|
+
* - On an npm install (`--vendor-payload`), the canonical payload is mirrored into
|
|
23
|
+
* `<configdir>/soleur/` so every installed reference points at a durable directory that
|
|
24
|
+
* survives `npx` / `bunx` cache eviction.
|
|
22
25
|
* - The config fragment is merged rather than written, so an operator's existing
|
|
23
26
|
* `skills` / `instructions` entries and every other key survive — for a config this script
|
|
24
27
|
* can READ. A config it cannot parse (JSONC comments, a syntax error, a torn write) is
|
|
@@ -28,14 +31,24 @@
|
|
|
28
31
|
* `opencode.json` whose next read takes the unparseable path.
|
|
29
32
|
* - The hook module (`opencode/plugin.ts`, source rather than a generated stub) is copied
|
|
30
33
|
* into `<configdir>/plugins/`, OpenCode's plugin discovery directory (measurement §6.6),
|
|
31
|
-
* with the plugin-root token resolved so the installed copy still reaches the
|
|
32
|
-
* guard scripts.
|
|
34
|
+
* with the plugin-root token resolved so the installed copy still reaches the guard scripts.
|
|
33
35
|
*
|
|
34
36
|
* `--check` writes nothing: it compares the target against `expectedInstalled` and exits 1
|
|
35
37
|
* on a difference.
|
|
36
38
|
*/
|
|
37
39
|
|
|
38
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
chmodSync,
|
|
42
|
+
copyFileSync,
|
|
43
|
+
existsSync,
|
|
44
|
+
mkdirSync,
|
|
45
|
+
readFileSync,
|
|
46
|
+
readdirSync,
|
|
47
|
+
renameSync,
|
|
48
|
+
rmSync,
|
|
49
|
+
statSync,
|
|
50
|
+
writeFileSync,
|
|
51
|
+
} from "fs";
|
|
39
52
|
import { dirname, join, relative, resolve } from "path";
|
|
40
53
|
import {
|
|
41
54
|
ADAPTER_DIR,
|
|
@@ -60,12 +73,6 @@ export const VENDOR_DIR = "soleur";
|
|
|
60
73
|
/**
|
|
61
74
|
* The plugin-root subtrees a vendored payload carries.
|
|
62
75
|
*
|
|
63
|
-
* A vendored payload is the canonical plugin root copied into the config dir. It exists because
|
|
64
|
-
* the npm distribution has **no checkout to point at**: `npx` runs the installer out of an
|
|
65
|
-
* ephemeral cache directory, so a config that named the package path rots the moment that cache
|
|
66
|
-
* is pruned — and the failure is silent, because the `skills` key and the guard scripts simply
|
|
67
|
-
* stop resolving. The copy is the durable root.
|
|
68
|
-
*
|
|
69
76
|
* Each entry is load-bearing: `skills` is what the `skills` key registers, `agents`/`commands`
|
|
70
77
|
* are what the generated stubs name, `hooks` is what the installed hook module spawns its guards
|
|
71
78
|
* from, `scripts` is what the canonical prose calls as `<root>/scripts/<name>.sh` (404 call
|
|
@@ -99,18 +106,22 @@ export function isVendoredRoot(root: string): boolean {
|
|
|
99
106
|
return existsSync(join(root, VENDOR_MARKER));
|
|
100
107
|
}
|
|
101
108
|
|
|
102
|
-
/**
|
|
103
|
-
* Deterministic marker bytes. No version and no timestamp: the payload's own files carry the
|
|
104
|
-
* version (an upgrade changes them and `--check` reports STALE), and a version here would make
|
|
105
|
-
* the marker flap between the two install modes.
|
|
106
|
-
*/
|
|
107
109
|
function vendorMarkerContent(): string {
|
|
108
110
|
return `${JSON.stringify({ format: 1, kind: "soleur-opencode-payload", owns: [...PAYLOAD_DIRS] }, null, 2)}\n`;
|
|
109
111
|
}
|
|
110
112
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
113
|
+
export interface PayloadEntry {
|
|
114
|
+
content: string;
|
|
115
|
+
executable: boolean;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Every regular file under `<root>/<dir>`, keyed `<dir>/<relative path>`, in sorted order.
|
|
120
|
+
* Symlinks are refused so a stray symlink in the source tree can never be dereferenced into
|
|
121
|
+
* the published npm tarball or vendored config directory.
|
|
122
|
+
*/
|
|
123
|
+
export function walkPayloadFiles(root: string, dir: string): Array<[string, PayloadEntry]> {
|
|
124
|
+
const found: Array<[string, PayloadEntry]> = [];
|
|
114
125
|
const visit = (relativeDir: string): void => {
|
|
115
126
|
let entries;
|
|
116
127
|
try {
|
|
@@ -119,32 +130,53 @@ function walkPayloadFiles(root: string, dir: string): Array<[string, string]> {
|
|
|
119
130
|
return;
|
|
120
131
|
}
|
|
121
132
|
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
122
|
-
const path = `${relativeDir
|
|
133
|
+
const path = `${currentPath(relativeDir, entry.name)}`;
|
|
134
|
+
if (entry.isSymbolicLink()) {
|
|
135
|
+
throw new Error(`Refusing symbolic link in payload tree: ${join(root, path)}`);
|
|
136
|
+
}
|
|
123
137
|
if (entry.isDirectory()) {
|
|
124
138
|
visit(path);
|
|
125
139
|
continue;
|
|
126
140
|
}
|
|
127
|
-
|
|
141
|
+
if (!entry.isFile()) continue;
|
|
142
|
+
const full = join(root, path);
|
|
143
|
+
const executable = (statSync(full).mode & 0o111) !== 0;
|
|
144
|
+
found.push([path, { content: readFileSync(full, "utf-8"), executable }]);
|
|
128
145
|
}
|
|
129
146
|
};
|
|
130
147
|
visit(dir);
|
|
131
148
|
return found;
|
|
132
149
|
}
|
|
133
150
|
|
|
151
|
+
function currentPath(dir: string, name: string): string {
|
|
152
|
+
return `${dir}/${name}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Full payload map for `root`, shared with `sync-npm-package.ts`. */
|
|
156
|
+
export function expectedPayloadEntries(root: string): Map<string, PayloadEntry> {
|
|
157
|
+
const expected = new Map<string, PayloadEntry>();
|
|
158
|
+
for (const dir of PAYLOAD_DIRS) {
|
|
159
|
+
for (const [path, entry] of walkPayloadFiles(root, dir)) {
|
|
160
|
+
expected.set(path, entry);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return expected;
|
|
164
|
+
}
|
|
165
|
+
|
|
134
166
|
export interface InstallOptions {
|
|
135
167
|
force?: boolean;
|
|
136
168
|
/**
|
|
137
169
|
* Mirror the canonical payload into `<target>/soleur/` and point every installed file at that
|
|
138
170
|
* copy instead of at `pluginRoot`. Used by the npm distribution, which has no checkout.
|
|
139
|
-
*
|
|
140
|
-
* A vendored payload is also recognised without this flag: a root carrying `VENDOR_MARKER`
|
|
141
|
-
* owns its payload wherever it sits inside the target, so the copy's own `--check` agrees
|
|
142
|
-
* with the install that produced it.
|
|
143
171
|
*/
|
|
144
172
|
vendorPayload?: boolean;
|
|
145
173
|
}
|
|
146
174
|
|
|
147
|
-
|
|
175
|
+
function resolveEffectiveRoot(targetDir: string, pluginRoot: string, options: InstallOptions): string {
|
|
176
|
+
return options.vendorPayload === true ? join(targetDir, VENDOR_DIR) : pluginRoot;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** `__SOLEUR_PLUGIN_ROOT__` → the resolved root. The only substitution in the whole install. */
|
|
148
180
|
function resolveToken(content: string, pluginRoot: string): string {
|
|
149
181
|
return content.split(PLUGIN_ROOT_TOKEN).join(pluginRoot);
|
|
150
182
|
}
|
|
@@ -162,13 +194,6 @@ export function mergeFragment(
|
|
|
162
194
|
return merged;
|
|
163
195
|
}
|
|
164
196
|
|
|
165
|
-
/**
|
|
166
|
-
* A config file that exists but cannot be read as a JSON object.
|
|
167
|
-
*
|
|
168
|
-
* This is not a generic parse error: it is the branch that decides between merging into an
|
|
169
|
-
* operator's config and destroying it. It names the path and the remedy because the caller's
|
|
170
|
-
* only sane action is to look at that file.
|
|
171
|
-
*/
|
|
172
197
|
export class UnparseableConfigError extends Error {
|
|
173
198
|
constructor(
|
|
174
199
|
readonly path: string,
|
|
@@ -181,14 +206,6 @@ export class UnparseableConfigError extends Error {
|
|
|
181
206
|
}
|
|
182
207
|
}
|
|
183
208
|
|
|
184
|
-
/**
|
|
185
|
-
* Where each generated file lands in a config dir, with tokens resolved.
|
|
186
|
-
*
|
|
187
|
-
* The plugin module is the one file here that is **source, not a generated stub**: it is
|
|
188
|
-
* hand-written in the generator's namespace (`ADAPTER_SOURCE_FILES`) and installed into
|
|
189
|
-
* OpenCode's plugin discovery directory, `<configdir>/plugins/`, with the plugin-root token
|
|
190
|
-
* resolved so the checkout's guard scripts stay reachable from the installed copy.
|
|
191
|
-
*/
|
|
192
209
|
export function expectedInstalled(
|
|
193
210
|
targetDir: string,
|
|
194
211
|
pluginRoot = PLUGIN_ROOT,
|
|
@@ -196,79 +213,48 @@ export function expectedInstalled(
|
|
|
196
213
|
options: InstallOptions = {},
|
|
197
214
|
): Map<string, string> {
|
|
198
215
|
const expected = new Map<string, string>();
|
|
199
|
-
|
|
200
|
-
// same for a checkout install. They differ for a vendored payload, where the bytes come from
|
|
201
|
-
// the package and the pointer must name the copy — the only path that outlives the npm cache.
|
|
202
|
-
const effectiveRoot = options.vendorPayload === true ? join(targetDir, VENDOR_DIR) : pluginRoot;
|
|
216
|
+
const effectiveRoot = resolveEffectiveRoot(targetDir, pluginRoot, options);
|
|
203
217
|
for (const [path, content] of expectedOpenCodeAdapter(pluginRoot)) {
|
|
204
|
-
if (path === `${ADAPTER_DIR}/opencode.json`) continue;
|
|
205
|
-
// `opencode/` is the generator's namespace, not a discovery root. INSTRUCTIONS.md keeps a
|
|
206
|
-
// `soleur/` prefix so it cannot collide with anything the operator already has.
|
|
218
|
+
if (path === `${ADAPTER_DIR}/opencode.json`) continue;
|
|
207
219
|
const installed =
|
|
208
220
|
path === `${ADAPTER_DIR}/INSTRUCTIONS.md` ? `soleur/${relative(ADAPTER_DIR, path)}` : relative(ADAPTER_DIR, path);
|
|
209
221
|
expected.set(installed, resolveToken(content, effectiveRoot));
|
|
210
222
|
}
|
|
211
223
|
for (const source of ADAPTER_SOURCE_FILES) {
|
|
212
224
|
if (!source.startsWith(`${ADAPTER_DIR}/`)) continue;
|
|
213
|
-
// Every `*.ts` in `<configdir>/plugins/` loads (measurement §6.6), so the installed name
|
|
214
|
-
// carries the plugin's namespace: a bare `plugin.ts` would collide with an operator's own.
|
|
215
225
|
const installed = `plugins/soleur-${source.slice(ADAPTER_DIR.length + 1)}`;
|
|
216
226
|
expected.set(installed, resolveToken(readFileSync(join(pluginRoot, source), "utf-8"), effectiveRoot));
|
|
217
227
|
}
|
|
218
|
-
for (const [path,
|
|
219
|
-
expected.set(path, content);
|
|
228
|
+
for (const [path, entry] of payloadExpected(targetDir, pluginRoot, effectiveRoot, options)) {
|
|
229
|
+
expected.set(path, entry.content);
|
|
220
230
|
}
|
|
221
|
-
// One definition of the fragment's shape, shared with the generator: the generator emits it
|
|
222
|
-
// with the token, this resolves the token and unions it into the operator's config.
|
|
223
231
|
const fragment = configFragmentValues(effectiveRoot);
|
|
224
232
|
const merged = mergeFragment(config ?? readConfig(join(targetDir, "opencode.json")), {
|
|
225
233
|
skills: fragment.skills,
|
|
226
|
-
// The installed path (`soleur/`), not the generator's `opencode/` namespace.
|
|
227
234
|
instructions: [join(targetDir, "soleur", "INSTRUCTIONS.md")],
|
|
228
235
|
});
|
|
229
236
|
expected.set("opencode.json", `${JSON.stringify(merged, null, 2)}\n`);
|
|
230
237
|
return expected;
|
|
231
238
|
}
|
|
232
239
|
|
|
233
|
-
/**
|
|
234
|
-
* The payload files this install owns, keyed target-relative.
|
|
235
|
-
*
|
|
236
|
-
* Content always comes from `sourceRoot` and is written under `effectiveRoot`, which is what
|
|
237
|
-
* makes the two install modes one code path: a checkout install has no payload inside the target
|
|
238
|
-
* (the checkout is the root, so `effectiveRoot` is outside it and this returns nothing), while a
|
|
239
|
-
* vendored install has `effectiveRoot` inside the target and mirrors the bytes there.
|
|
240
|
-
*
|
|
241
|
-
* Ownership is the point, not the copy: these paths join `expected`, so the extras sweep — which
|
|
242
|
-
* deletes anything under `soleur/` the installer did not write — keeps them instead of deleting
|
|
243
|
-
* the payload one pass after writing it.
|
|
244
|
-
*/
|
|
245
240
|
function payloadExpected(
|
|
246
241
|
targetDir: string,
|
|
247
242
|
sourceRoot: string,
|
|
248
243
|
effectiveRoot: string,
|
|
249
244
|
options: InstallOptions,
|
|
250
|
-
): Map<string,
|
|
245
|
+
): Map<string, PayloadEntry> {
|
|
251
246
|
const owned = options.vendorPayload === true || isVendoredRoot(sourceRoot);
|
|
252
|
-
const result = new Map<string,
|
|
247
|
+
const result = new Map<string, PayloadEntry>();
|
|
253
248
|
if (!owned) return result;
|
|
254
249
|
const prefix = relative(targetDir, effectiveRoot).split("\\").join("/");
|
|
255
|
-
// A root outside the target owns nothing inside it: that is the checkout install.
|
|
256
250
|
if (prefix === "" || prefix.startsWith("..")) return result;
|
|
257
|
-
for (const
|
|
258
|
-
|
|
259
|
-
result.set(`${prefix}/${path}`, content);
|
|
260
|
-
}
|
|
251
|
+
for (const [path, entry] of expectedPayloadEntries(sourceRoot)) {
|
|
252
|
+
result.set(`${prefix}/${path}`, entry);
|
|
261
253
|
}
|
|
262
|
-
result.set(`${prefix}/${VENDOR_MARKER}`, vendorMarkerContent());
|
|
254
|
+
result.set(`${prefix}/${VENDOR_MARKER}`, { content: vendorMarkerContent(), executable: false });
|
|
263
255
|
return result;
|
|
264
256
|
}
|
|
265
257
|
|
|
266
|
-
/**
|
|
267
|
-
* Read an operator config. A **missing** config is an empty one (a first install); anything
|
|
268
|
-
* else that is not a JSON object throws `UnparseableConfigError`. The two cases were one branch
|
|
269
|
-
* (`catch → {}`), which made "first install" and "operator config with JSONC comments"
|
|
270
|
-
* indistinguishable — and the write path then replaced the second with the fragment alone.
|
|
271
|
-
*/
|
|
272
258
|
function readConfig(path: string): Record<string, unknown> {
|
|
273
259
|
let raw: string;
|
|
274
260
|
try {
|
|
@@ -284,12 +270,15 @@ function readConfig(path: string): Record<string, unknown> {
|
|
|
284
270
|
throw new UnparseableConfigError(path, String(error));
|
|
285
271
|
}
|
|
286
272
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
287
|
-
throw new UnparseableConfigError(
|
|
273
|
+
throw new UnparseableConfigError(
|
|
274
|
+
path,
|
|
275
|
+
`top level is ${Array.isArray(parsed) ? "an array" : parsed === null ? "null" : typeof parsed}`,
|
|
276
|
+
);
|
|
288
277
|
}
|
|
289
278
|
return parsed as Record<string, unknown>;
|
|
290
279
|
}
|
|
291
280
|
|
|
292
|
-
/** Target-relative paths of every file under `dir` (empty when
|
|
281
|
+
/** Target-relative paths of every regular file under `dir` (skips symlinks; empty when absent). */
|
|
293
282
|
function walkFiles(targetDir: string, dir: string): string[] {
|
|
294
283
|
const found: string[] = [];
|
|
295
284
|
const visit = (relativeDir: string): void => {
|
|
@@ -300,45 +289,32 @@ function walkFiles(targetDir: string, dir: string): string[] {
|
|
|
300
289
|
return;
|
|
301
290
|
}
|
|
302
291
|
for (const entry of entries) {
|
|
292
|
+
if (entry.isSymbolicLink()) continue;
|
|
303
293
|
const path = `${relativeDir}/${entry.name}`;
|
|
304
294
|
if (entry.isDirectory()) visit(path);
|
|
305
|
-
else found.push(path);
|
|
295
|
+
else if (entry.isFile()) found.push(path);
|
|
306
296
|
}
|
|
307
297
|
};
|
|
308
298
|
visit(dir);
|
|
309
299
|
return found.sort();
|
|
310
300
|
}
|
|
311
301
|
|
|
312
|
-
/**
|
|
313
|
-
* The generated stubs' own signature. `agents/` and `commands/` are SHARED with the operator, so
|
|
314
|
-
* a file there is only removable when it says, in its own text, that this generator wrote it and
|
|
315
|
-
* that it points at the resolved checkout. Anything else in those roots is not ours.
|
|
316
|
-
*/
|
|
317
302
|
const STUB_SIGNATURES = ["Stub for the canonical agent `", "Run the canonical `"] as const;
|
|
318
303
|
|
|
319
304
|
function isGeneratedStub(content: string, pluginRoot: string): boolean {
|
|
320
305
|
return content.includes(pluginRoot) && STUB_SIGNATURES.some((signature) => content.includes(signature));
|
|
321
306
|
}
|
|
322
307
|
|
|
323
|
-
/**
|
|
324
|
-
* Files an install owns but no longer generates. Without this, deleting an agent or renaming a
|
|
325
|
-
* command in the canonical tree leaves its installed stub loading forever — a half-updated
|
|
326
|
-
* install that `--check` would call ok.
|
|
327
|
-
*/
|
|
328
308
|
export function installedExtras(
|
|
329
309
|
targetDir: string,
|
|
330
310
|
expected: Map<string, string>,
|
|
331
311
|
root = PLUGIN_ROOT,
|
|
332
312
|
): string[] {
|
|
333
313
|
const extras: string[] = [];
|
|
334
|
-
// Exclusive namespaces: everything under `soleur/` and `plugins/soleur-*` is ours by name.
|
|
335
|
-
// A vendored payload's own files are in `expected`, so they are not "extras".
|
|
336
314
|
for (const path of walkFiles(targetDir, "soleur")) if (!expected.has(path)) extras.push(path);
|
|
337
315
|
for (const path of walkFiles(targetDir, "plugins")) {
|
|
338
316
|
if (path.startsWith("plugins/soleur-") && !expected.has(path)) extras.push(path);
|
|
339
317
|
}
|
|
340
|
-
// Shared roots: attributed by content, never by location. A stub names the RESOLVED root, so
|
|
341
|
-
// `root` here is the effective root the install wrote into those stubs, not the source.
|
|
342
318
|
for (const root_ of ["agents", "commands"]) {
|
|
343
319
|
for (const path of walkFiles(targetDir, root_)) {
|
|
344
320
|
if (expected.has(path)) continue;
|
|
@@ -354,20 +330,18 @@ export function installedExtras(
|
|
|
354
330
|
return extras.sort();
|
|
355
331
|
}
|
|
356
332
|
|
|
357
|
-
/** Write through a sibling temp file and rename,
|
|
358
|
-
function writeAtomic(full: string, content: string): void {
|
|
333
|
+
/** Write through a sibling temp file and rename, preserving executable mode when needed. */
|
|
334
|
+
function writeAtomic(full: string, content: string, executable = false): void {
|
|
359
335
|
mkdirSync(dirname(full), { recursive: true });
|
|
360
336
|
const temp = `${full}.soleur-tmp-${process.pid}`;
|
|
361
337
|
writeFileSync(temp, content);
|
|
338
|
+
if (executable) chmodSync(temp, 0o755);
|
|
362
339
|
renameSync(temp, full);
|
|
363
340
|
}
|
|
364
341
|
|
|
365
342
|
export interface InstallResult {
|
|
366
|
-
/** Config-dir-relative files written or updated, sorted. */
|
|
367
343
|
written: string[];
|
|
368
|
-
/** Config-dir-relative files that were already correct, sorted. */
|
|
369
344
|
unchanged: string[];
|
|
370
|
-
/** Config-dir-relative stale files removed, sorted. */
|
|
371
345
|
removed: string[];
|
|
372
346
|
}
|
|
373
347
|
|
|
@@ -389,21 +363,25 @@ export function installOpenCodeAdapter(
|
|
|
389
363
|
console.error(`[soleur] --force: replaced ${configPath}; the previous bytes are at ${backup}`);
|
|
390
364
|
config = {};
|
|
391
365
|
}
|
|
366
|
+
const effectiveRoot = resolveEffectiveRoot(targetDir, pluginRoot, options);
|
|
367
|
+
const payload = payloadExpected(targetDir, pluginRoot, effectiveRoot, options);
|
|
392
368
|
const expected = expectedInstalled(targetDir, pluginRoot, config, options);
|
|
393
|
-
const effectiveRoot = options.vendorPayload === true ? join(targetDir, VENDOR_DIR) : pluginRoot;
|
|
394
369
|
for (const [path, content] of expected) {
|
|
395
370
|
const full = join(targetDir, path);
|
|
371
|
+
const wantExec = payload.get(path)?.executable === true;
|
|
396
372
|
let current: string | undefined;
|
|
373
|
+
let currentExec = false;
|
|
397
374
|
try {
|
|
398
375
|
current = readFileSync(full, "utf-8");
|
|
376
|
+
currentExec = (statSync(full).mode & 0o111) !== 0;
|
|
399
377
|
} catch {
|
|
400
378
|
current = undefined;
|
|
401
379
|
}
|
|
402
|
-
if (current === content) {
|
|
380
|
+
if (current === content && (!wantExec || currentExec)) {
|
|
403
381
|
result.unchanged.push(path);
|
|
404
382
|
continue;
|
|
405
383
|
}
|
|
406
|
-
writeAtomic(full, content);
|
|
384
|
+
writeAtomic(full, content, wantExec);
|
|
407
385
|
result.written.push(path);
|
|
408
386
|
}
|
|
409
387
|
for (const path of installedExtras(targetDir, expected, effectiveRoot)) {
|
|
@@ -416,19 +394,24 @@ export function installOpenCodeAdapter(
|
|
|
416
394
|
return result;
|
|
417
395
|
}
|
|
418
396
|
|
|
419
|
-
/** Read-only: paths the target does not already match. Empty means installed and current. */
|
|
420
397
|
export function installProblems(targetDir: string, pluginRoot = PLUGIN_ROOT, options: InstallOptions = {}): string[] {
|
|
421
398
|
const problems: string[] = [];
|
|
399
|
+
const effectiveRoot = resolveEffectiveRoot(targetDir, pluginRoot, options);
|
|
400
|
+
const payload = payloadExpected(targetDir, pluginRoot, effectiveRoot, options);
|
|
422
401
|
const expected = expectedInstalled(targetDir, pluginRoot, undefined, options);
|
|
423
|
-
const effectiveRoot = options.vendorPayload === true ? join(targetDir, VENDOR_DIR) : pluginRoot;
|
|
424
402
|
for (const [path, content] of expected) {
|
|
403
|
+
const full = join(targetDir, path);
|
|
404
|
+
const wantExec = payload.get(path)?.executable === true;
|
|
425
405
|
let current: string | undefined;
|
|
406
|
+
let currentExec = false;
|
|
426
407
|
try {
|
|
427
|
-
current = readFileSync(
|
|
408
|
+
current = readFileSync(full, "utf-8");
|
|
409
|
+
currentExec = (statSync(full).mode & 0o111) !== 0;
|
|
428
410
|
} catch {
|
|
429
411
|
current = undefined;
|
|
430
412
|
}
|
|
431
|
-
if (current
|
|
413
|
+
if (current === undefined) problems.push(`MISSING: ${path}`);
|
|
414
|
+
else if (current !== content || (wantExec && !currentExec)) problems.push(`STALE: ${path}`);
|
|
432
415
|
}
|
|
433
416
|
for (const path of installedExtras(targetDir, expected, effectiveRoot)) {
|
|
434
417
|
problems.push(`EXTRA: ${path}`);
|
|
@@ -439,13 +422,11 @@ export function installProblems(targetDir: string, pluginRoot = PLUGIN_ROOT, opt
|
|
|
439
422
|
function parseArg(argv: readonly string[], flag: string): string | undefined {
|
|
440
423
|
const index = argv.indexOf(flag);
|
|
441
424
|
if (index === -1) return undefined;
|
|
442
|
-
|
|
443
|
-
if (value === undefined) throw new Error(`${flag} requires a directory`);
|
|
444
|
-
return value;
|
|
425
|
+
return argv[index + 1];
|
|
445
426
|
}
|
|
446
427
|
|
|
447
428
|
const USAGE = [
|
|
448
|
-
"Usage: bun plugins/soleur/scripts/install-opencode-adapter.ts [--target DIR] [--plugin-root DIR] [--vendor-payload] [--check] [--force] [--help]",
|
|
429
|
+
"Usage: bun plugins/soleur/scripts/install-opencode-adapter.ts [install] [--target DIR] [--plugin-root DIR] [--vendor-payload] [--check] [--force] [--help]",
|
|
449
430
|
"",
|
|
450
431
|
" --target DIR config dir to install into (default: $HOME/.config/opencode)",
|
|
451
432
|
" --plugin-root DIR checkout (or package) the installed files should point at (default: this checkout)",
|
|
@@ -456,30 +437,38 @@ const USAGE = [
|
|
|
456
437
|
" --help, -h print this text and exit 0 without writing",
|
|
457
438
|
].join("\n");
|
|
458
439
|
|
|
459
|
-
|
|
460
|
-
* Unknown arguments are rejected: a typo (`--chek`) otherwise falls through to the WRITE path,
|
|
461
|
-
* which is the direction that changes an operator's machine.
|
|
462
|
-
*/
|
|
463
|
-
function validateArgs(argv: readonly string[]): void {
|
|
440
|
+
function validateArgs(argv: readonly string[], usage = USAGE): void {
|
|
464
441
|
const withValue = new Set(["--target", "--plugin-root"]);
|
|
465
442
|
const known = new Set([...withValue, "--vendor-payload", "--check", "--force", "--help", "-h"]);
|
|
466
443
|
for (let i = 0; i < argv.length; i += 1) {
|
|
467
444
|
const arg = argv[i];
|
|
468
445
|
if (arg === "--help" || arg === "-h") {
|
|
469
|
-
console.log(
|
|
446
|
+
console.log(usage);
|
|
470
447
|
process.exit(0);
|
|
471
448
|
}
|
|
472
449
|
if (!known.has(arg)) {
|
|
473
|
-
console.error(`unknown argument: ${arg}\n${
|
|
450
|
+
console.error(`unknown argument: ${arg}\n${usage}`);
|
|
474
451
|
process.exit(2);
|
|
475
452
|
}
|
|
476
|
-
if (withValue.has(arg))
|
|
453
|
+
if (withValue.has(arg)) {
|
|
454
|
+
const next = argv[i + 1];
|
|
455
|
+
if (next === undefined || next.startsWith("-")) {
|
|
456
|
+
console.error(`${arg} requires a directory\n${usage}`);
|
|
457
|
+
process.exit(2);
|
|
458
|
+
}
|
|
459
|
+
i += 1;
|
|
460
|
+
}
|
|
477
461
|
}
|
|
478
462
|
}
|
|
479
463
|
|
|
480
|
-
function
|
|
481
|
-
|
|
482
|
-
|
|
464
|
+
export function runCli(
|
|
465
|
+
rawArgv: readonly string[],
|
|
466
|
+
defaultPluginRoot = PLUGIN_ROOT,
|
|
467
|
+
defaultVendorPayload = false,
|
|
468
|
+
usage = USAGE,
|
|
469
|
+
): void {
|
|
470
|
+
const argv = rawArgv[0] === "install" ? rawArgv.slice(1) : rawArgv;
|
|
471
|
+
validateArgs(argv, usage);
|
|
483
472
|
const explicitTarget = parseArg(argv, "--target");
|
|
484
473
|
const home = process.env["HOME"];
|
|
485
474
|
if (explicitTarget === undefined && (home === undefined || home === "")) {
|
|
@@ -489,10 +478,10 @@ function main(): void {
|
|
|
489
478
|
process.exit(2);
|
|
490
479
|
}
|
|
491
480
|
const targetDir = resolve(explicitTarget ?? join(home as string, ".config", "opencode"));
|
|
492
|
-
const pluginRoot = resolve(parseArg(argv, "--plugin-root") ??
|
|
481
|
+
const pluginRoot = resolve(parseArg(argv, "--plugin-root") ?? defaultPluginRoot);
|
|
493
482
|
const options: InstallOptions = {
|
|
494
483
|
force: argv.includes("--force"),
|
|
495
|
-
vendorPayload: argv.includes("--vendor-payload"),
|
|
484
|
+
vendorPayload: defaultVendorPayload || argv.includes("--vendor-payload"),
|
|
496
485
|
};
|
|
497
486
|
try {
|
|
498
487
|
if (argv.includes("--check")) {
|
|
@@ -517,4 +506,4 @@ function main(): void {
|
|
|
517
506
|
}
|
|
518
507
|
}
|
|
519
508
|
|
|
520
|
-
if (import.meta.main)
|
|
509
|
+
if (import.meta.main) runCli(process.argv.slice(2));
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -323,7 +323,7 @@ while :; do
|
|
|
323
323
|
# the draft run is not a recovery (it reuses the cached draft-light output and can cancel the in-flight ready run).
|
|
324
324
|
case "$V_STATE" in
|
|
325
325
|
stalled)
|
|
326
|
-
printf 'READY RUN STALLED — PR #%s was marked ready but no full CI run decided (%s:
|
|
326
|
+
printf 'READY RUN STALLED — PR #%s was marked ready but no full CI run decided (%s: see ci-head-verdict.sh --help for the stall clock); the red test row is the DRAFT run'"'"'s, by design. Do NOT start a fix loop on it. First confirm in the Actions tab that the ready run is not still in flight; recovery with a user token (never GITHUB_TOKEN): gh pr ready --undo %s ; gh pr ready %s\n' "$PR" "$V_WHY" "$PR" "$PR"
|
|
327
327
|
exit 1 ;;
|
|
328
328
|
no-run)
|
|
329
329
|
# A conflicting PR gets no pull_request run (GitHub builds no merge ref), so the PENDING relabel above would watch it
|
|
File without changes
|
|
File without changes
|
|
@@ -104,7 +104,9 @@ while IFS= read -r seg; do
|
|
|
104
104
|
else
|
|
105
105
|
continue # nowhere to resolve — nothing to refuse on
|
|
106
106
|
fi
|
|
107
|
-
|
|
107
|
+
# The alternation is the stamped default-branch set — soleur:verification-scaffold
|
|
108
|
+
# emits this file with `main|master` substituted for the target's resolved default.
|
|
109
|
+
if [[ "$BRANCH" =~ ^(main|master)$ ]]; then
|
|
108
110
|
printf 'BLOCKED: Committing directly to %s is not allowed. Create a feature branch first.\n' "$BRANCH" >&2
|
|
109
111
|
exit 1
|
|
110
112
|
fi
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|