@soleur/opencode 1.0.0 → 3.354.3

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.
Files changed (92) hide show
  1. package/commands/go.md +6 -5
  2. package/commands/sync.md +2 -2
  3. package/install.js +6 -85
  4. package/lib/harness.ts +1 -1
  5. package/lib/heartbeat-manifest.ts +19 -0
  6. package/lib/pr-merge-poll.ts +2 -2
  7. package/lib/workflow-fidelity.ts +2 -2
  8. package/opencode/INSTRUCTIONS.md +19 -0
  9. package/opencode/plugin.ts +343 -18
  10. package/package.json +1 -1
  11. package/scripts/admin-merge-ready.sh +1 -1
  12. package/scripts/admin-merge-ready.test.sh +0 -0
  13. package/scripts/affected-tests.sh +0 -0
  14. package/scripts/alpha-metrics.sh +0 -0
  15. package/scripts/alpha-metrics.test.sh +0 -0
  16. package/scripts/check-red-on-main.sh +0 -0
  17. package/scripts/ci-head-verdict.sh +54 -6
  18. package/scripts/cloud-detect.sh +0 -0
  19. package/scripts/delegation-verify.sh +0 -0
  20. package/scripts/delegation-verify.test.sh +0 -0
  21. package/scripts/deploy-arm.sh +0 -0
  22. package/scripts/domain-model-drift.sh +0 -0
  23. package/scripts/emit-decision.sh +0 -0
  24. package/scripts/emit-decision.test.sh +0 -0
  25. package/scripts/ext-filter-divergence.sh +0 -0
  26. package/scripts/grok-fidelity-gate.sh +0 -0
  27. package/scripts/grok-pre-push-gate.sh +0 -0
  28. package/scripts/install-opencode-adapter.ts +118 -129
  29. package/scripts/lib/affected-tests/common.sh +0 -0
  30. package/scripts/lib/affected-tests/go.sh +0 -0
  31. package/scripts/lib/affected-tests/js.sh +0 -0
  32. package/scripts/lib/affected-tests/nx-turbo.sh +0 -0
  33. package/scripts/lib/affected-tests/python.sh +0 -0
  34. package/scripts/lib/session-state.sh +0 -0
  35. package/scripts/lib/tmp-classify.sh +0 -0
  36. package/scripts/monitor-pr-checks.sh +1 -1
  37. package/scripts/pipeline-tally.sh +0 -0
  38. package/scripts/plugin-version.sh +0 -0
  39. package/scripts/precommit-guard.sh +3 -1
  40. package/scripts/precommit-guard.test.sh +0 -0
  41. package/scripts/render-c4-model.sh +0 -0
  42. package/scripts/resolve-regenerable-conflicts.sh +0 -0
  43. package/scripts/run-in-pid-namespace.sh +0 -0
  44. package/scripts/setup-ralph-loop.sh +0 -0
  45. package/scripts/sync-npm-package.ts +74 -87
  46. package/scripts/sync-opencode-adapter.ts +19 -0
  47. package/scripts/sync-pr-behind.sh +183 -70
  48. package/scripts/taste-profile-update.sh +0 -0
  49. package/scripts/taste-profile-update.test.sh +0 -0
  50. package/scripts/write-budget-marker.sh +0 -0
  51. package/skills/deepen-plan/SKILL.md +1 -1
  52. package/skills/deploy/references/hetzner-setup.md +5 -3
  53. package/skills/drain-prs/SKILL.md +3 -2
  54. package/skills/fix-issue/SKILL.md +8 -8
  55. package/skills/fix-issue/references/agent-authored-exclusion.md +4 -2
  56. package/skills/fix-issue/references/exclude-label-jq-snippet.md +4 -3
  57. package/skills/merge-pr/SKILL.md +5 -3
  58. package/skills/model-launch-review/SKILL.md +4 -4
  59. package/skills/one-shot/SKILL.md +1 -1
  60. package/skills/plan/SKILL.md +1 -1
  61. package/skills/plan/references/plan-sharp-edges.md +7 -1
  62. package/skills/postmerge/SKILL.md +2 -2
  63. package/skills/postmerge/references/deploy-status-debugging.md +3 -0
  64. package/skills/review/SKILL.md +14 -15
  65. package/skills/review/references/review-evidence-comment.md +36 -0
  66. package/skills/review/references/risk-tier-and-fix-rounds.md +2 -0
  67. package/skills/ship/SKILL.md +14 -13
  68. package/skills/ship/references/cleanup-merged-archival.md +39 -0
  69. package/skills/ship/references/delegated-act-authoring-form.md +7 -0
  70. package/skills/ship/references/epic-close-sweep.md +62 -0
  71. package/skills/ship/references/merge-queue-dequeue.md +1 -1
  72. package/skills/ship/references/operator-holds.md +30 -0
  73. package/skills/ship/references/queue-mode.md +1 -1
  74. package/skills/ship/references/ready-run-wait.md +3 -3
  75. package/skills/test-fix-loop/SKILL.md +1 -1
  76. package/skills/verification-scaffold/SKILL.md +73 -0
  77. package/skills/verification-scaffold/references/gitleaks.toml.template +13 -0
  78. package/skills/verification-scaffold/references/guardrail-audit.md +156 -0
  79. package/skills/verification-scaffold/references/guards/block-stash-hook.sh.template +72 -0
  80. package/skills/verification-scaffold/references/guards/commit-on-main-hook.sh.template +82 -0
  81. package/skills/verification-scaffold/references/guards/orphan-test-check.sh.template +102 -0
  82. package/skills/verification-scaffold/references/guards/precommit-guard.sh.template +115 -0
  83. package/skills/verification-scaffold/references/guards/secret-scan-hook.sh.template +176 -0
  84. package/skills/verification-scaffold/references/guards/verification-gates.yml.template +49 -0
  85. package/skills/verification-scaffold/references/guards/wrapper-boundary.sh.template +47 -0
  86. package/skills/verification-scaffold/references/guards.tsv +21 -0
  87. package/skills/verification-scaffold/references/verification-readme.template +55 -0
  88. package/skills/verification-scaffold/scripts/verification-scaffold.sh +785 -0
  89. package/skills/verification-scaffold/test/bite-proof.test.sh +159 -0
  90. package/skills/verification-scaffold/test/generator.test.sh +319 -0
  91. package/skills/verification-scaffold/test/manifest.test.sh +144 -0
  92. 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 — they register
20
- * from the canonical tree through the `skills` key (§2.0), so the config points at 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 checkout's
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 { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "fs";
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
- /** Every file under `<root>/<dir>`, keyed `<dir>/<relative path>`, in sorted order. */
112
- function walkPayloadFiles(root: string, dir: string): Array<[string, string]> {
113
- const found: Array<[string, string]> = [];
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}/${entry.name}`;
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
- found.push([path, readFileSync(join(root, path), "utf-8")]);
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
- /** `__SOLEUR_PLUGIN_ROOT__` → the real checkout. The only substitution in the whole install. */
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
- // Where the installed files POINT, as opposed to where their bytes come from. The two are the
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; // merged below, never copied verbatim
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, content] of payloadExpected(targetDir, pluginRoot, effectiveRoot, options)) {
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, string> {
245
+ ): Map<string, PayloadEntry> {
251
246
  const owned = options.vendorPayload === true || isVendoredRoot(sourceRoot);
252
- const result = new Map<string, 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 dir of PAYLOAD_DIRS) {
258
- for (const [path, content] of walkPayloadFiles(sourceRoot, dir)) {
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(path, `top level is ${Array.isArray(parsed) ? "an array" : parsed === null ? "null" : typeof parsed}`);
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 the directory is absent). */
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, so a reader never sees a partial file. */
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(join(targetDir, path), "utf-8");
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 !== content) problems.push(current === undefined ? `MISSING: ${path}` : `STALE: ${path}`);
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
- const value = argv[index + 1];
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(USAGE);
446
+ console.log(usage);
470
447
  process.exit(0);
471
448
  }
472
449
  if (!known.has(arg)) {
473
- console.error(`unknown argument: ${arg}\n${USAGE}`);
450
+ console.error(`unknown argument: ${arg}\n${usage}`);
474
451
  process.exit(2);
475
452
  }
476
- if (withValue.has(arg)) i += 1;
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 main(): void {
481
- const argv = process.argv.slice(2);
482
- validateArgs(argv);
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") ?? 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) 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: the stall window counts from the ready event); 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"
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
- if [[ "$BRANCH" == "main" || "$BRANCH" == "master" ]]; then
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