@xiongxianfei/rigorloop 0.1.4 → 0.2.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.
@@ -7,16 +7,13 @@ import { basename, dirname, join, resolve } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
 
9
9
  import { EXIT, exitCodeForResult } from "../lib/command-result.js";
10
+ import { adapterDescriptor, supportedAdapterNames } from "../lib/adapters.js";
10
11
  import { parseLockfile, serializeLockfile, sha256NormalizedText } from "../lib/lockfile.js";
11
12
  import { buildNewChangeDraft, parseNewChangeArgs } from "../lib/new-change.js";
12
13
  import { runNewChangePlan } from "../lib/new-change-filesystem.js";
13
14
  import { validateOfficialArchiveUrl } from "../lib/official-archive-url.js";
14
15
 
15
- const ADAPTER = "codex";
16
- const AGENTS_ROOT = ".agents";
17
- const INSTALL_ROOT = ".agents/skills";
18
16
  const LOCKFILE_PATH = "rigorloop.lock";
19
- const DIRECTORY_PLAN = [AGENTS_ROOT, INSTALL_ROOT];
20
17
 
21
18
  function packageInfo() {
22
19
  const here = dirname(fileURLToPath(import.meta.url));
@@ -113,12 +110,13 @@ function usage() {
113
110
  Usage:
114
111
  rigorloop --help
115
112
  rigorloop version
116
- rigorloop init --adapter codex [--dry-run] [--json]
113
+ rigorloop init --adapter codex|claude|opencode [--dry-run] [--json]
117
114
  rigorloop new-change <change-id> --title <title> [--dry-run] [--json]
118
115
 
119
116
  Commands:
120
117
  version Print package name and version.
121
- init --adapter codex Initialize the first-slice Codex adapter plan.
118
+ init --adapter codex|claude|opencode
119
+ Initialize a verified adapter install plan.
122
120
  new-change Plan a change metadata scaffold.
123
121
  `;
124
122
  }
@@ -127,43 +125,159 @@ function releaseForPackage(version) {
127
125
  return `v${version}`;
128
126
  }
129
127
 
130
- function sourceForFlags(flags, info) {
128
+ function sourceForFlags(flags, info, descriptor) {
131
129
  if (flags.fromArchiveProvided) {
132
130
  return {
133
131
  type: "local-archive",
134
- archive: flags.fromArchive,
132
+ archive: basename(flags.fromArchive),
133
+ inputPath: flags.fromArchive,
135
134
  };
136
135
  }
137
136
 
138
137
  return {
139
138
  type: "release-archive",
140
139
  release: releaseForPackage(info.version),
141
- archive: `rigorloop-adapter-codex-${releaseForPackage(info.version)}.zip`,
140
+ archive: descriptor.archiveName(releaseForPackage(info.version)),
142
141
  };
143
142
  }
144
143
 
145
- function manifestContent(info, source) {
144
+ function manifestAdapterBlock(source, descriptor, artifact) {
146
145
  const sourceLines =
147
146
  source.type === "local-archive"
148
147
  ? [` type: local-archive`, ` archive: "${source.archive}"`]
149
148
  : [` type: release-archive`, ` release: "${source.release}"`];
149
+ const installRoots = rootsForArtifact(descriptor, artifact);
150
+ const rootLines =
151
+ descriptor.name !== "opencode" && Object.keys(installRoots).length === 1
152
+ ? [` install_root: "${Object.values(installRoots)[0]}"`]
153
+ : [
154
+ ` install_roots:`,
155
+ ...Object.entries(installRoots).map(([role, root]) => ` ${role}: "${root}"`),
156
+ ];
157
+
158
+ return ` - name: ${descriptor.name}
159
+ ${rootLines.join("\n")}
160
+ source:
161
+ ${sourceLines.join("\n")}`;
162
+ }
163
+
164
+ function parseManifestAdapterBlocks(content) {
165
+ if (!content.includes("schema_version: 1") || !content.includes("adapters:")) {
166
+ return { error: { code: "invalid-config", message: "Existing rigorloop.yaml is not compatible with the init contract." } };
167
+ }
168
+ const lines = content.replace(/\r\n?/g, "\n").split("\n");
169
+ const adapterStart = lines.findIndex((line) => line === "adapters:");
170
+ const blocks = [];
171
+ let current = [];
172
+ for (const line of lines.slice(adapterStart + 1)) {
173
+ if (line.startsWith(" - name: ")) {
174
+ if (current.length) {
175
+ blocks.push(current);
176
+ }
177
+ current = [line];
178
+ } else if (current.length) {
179
+ current.push(line);
180
+ } else if (line.trim() !== "") {
181
+ return { error: { code: "invalid-config", message: "Existing rigorloop.yaml has malformed adapter entries." } };
182
+ }
183
+ }
184
+ if (current.length) {
185
+ blocks.push(current);
186
+ }
187
+ const adapters = [];
188
+ for (const block of blocks) {
189
+ const name = block[0].slice(" - name: ".length).trim();
190
+ const descriptor = adapterDescriptor(name);
191
+ if (!descriptor) {
192
+ return { error: { code: "invalid-config", message: `Existing rigorloop.yaml includes unsupported adapter ${name}.` } };
193
+ }
194
+ adapters.push({ name, block: block.join("\n").replace(/\n+$/, "") });
195
+ }
196
+ return { adapters };
197
+ }
150
198
 
199
+ function manifestContent(info, source, descriptor, existingContent) {
200
+ const selectedBlock = manifestAdapterBlock(source, descriptor, source.artifact);
201
+ if (existingContent) {
202
+ const parsed = parseManifestAdapterBlocks(existingContent);
203
+ if (!parsed.error) {
204
+ const preserved = parsed.adapters.filter((entry) => entry.name !== descriptor.name).map((entry) => entry.block);
205
+ return `schema_version: 1
206
+ rigorloop:
207
+ package: "${info.name}"
208
+ package_version: "${info.version}"
209
+ adapters:
210
+ ${[...preserved, selectedBlock].join("\n")}
211
+ `;
212
+ }
213
+ }
151
214
  return `schema_version: 1
152
215
  rigorloop:
153
216
  package: "${info.name}"
154
217
  package_version: "${info.version}"
155
218
  adapters:
156
- - name: codex
157
- install_root: "${INSTALL_ROOT}"
158
- source:
159
- ${sourceLines.join("\n")}
219
+ ${selectedBlock}
160
220
  `;
161
221
  }
162
222
 
163
- function plannedLockfile(info, source, manifest) {
223
+ function rootsForArtifact(descriptor, artifact) {
224
+ if (artifact?.install_roots) {
225
+ return artifact.install_roots;
226
+ }
227
+ if (artifact?.install_root) {
228
+ return { skills: artifact.install_root };
229
+ }
230
+ return descriptor.installRoots;
231
+ }
232
+
233
+ function rootHashesForArtifact(descriptor, artifact) {
234
+ if (artifact?.root_hashes) {
235
+ return artifact.root_hashes;
236
+ }
237
+ return Object.fromEntries(
238
+ Object.keys(rootsForArtifact(descriptor, artifact)).map((role) => [
239
+ role,
240
+ {
241
+ tree_sha256: artifact?.tree_sha256 ?? "<planned-after-install>",
242
+ file_count: artifact?.file_count ?? "<planned-after-install>",
243
+ },
244
+ ]),
245
+ );
246
+ }
247
+
248
+ function usesMultiRootLockfile(descriptor, artifact) {
249
+ return descriptor.name === "opencode" || Object.keys(rootsForArtifact(descriptor, artifact)).length > 1;
250
+ }
251
+
252
+ function lockfileEntryForAdapter(info, source, artifact, descriptor, rootHashes = rootHashesForArtifact(descriptor, artifact)) {
253
+ const entry = {
254
+ adapter: descriptor.name,
255
+ release: releaseForPackage(info.version),
256
+ source: source.type,
257
+ archive: source.archive,
258
+ archive_sha256: artifact?.sha256 ?? "<planned>",
259
+ tree_hash_algorithm: "rigorloop-tree-hash-v1",
260
+ };
261
+ if (usesMultiRootLockfile(descriptor, artifact)) {
262
+ return {
263
+ ...entry,
264
+ installed_roots: rootsForArtifact(descriptor, artifact),
265
+ root_hashes: rootHashes,
266
+ };
267
+ }
268
+ const [role] = Object.keys(rootsForArtifact(descriptor, artifact));
269
+ return {
270
+ ...entry,
271
+ installed_root: rootsForArtifact(descriptor, artifact)[role],
272
+ tree_sha256: rootHashes[role].tree_sha256,
273
+ file_count: rootHashes[role].file_count,
274
+ };
275
+ }
276
+
277
+ function plannedLockfile(info, source, manifest, descriptor) {
164
278
  const artifact = source.artifact;
165
279
  return {
166
- schema_version: 1,
280
+ schema_version: 2,
167
281
  rigorloop: {
168
282
  package: info.name,
169
283
  version: info.version,
@@ -173,26 +287,30 @@ function plannedLockfile(info, source, manifest) {
173
287
  sha256: sha256NormalizedText(manifest),
174
288
  },
175
289
  generated: {
176
- adapters: [
177
- {
178
- adapter: ADAPTER,
179
- release: releaseForPackage(info.version),
180
- source: source.type,
181
- archive: source.type === "local-archive" ? basename(source.archive) : source.archive,
182
- archive_sha256: artifact?.sha256 ?? "<planned>",
183
- installed_root: INSTALL_ROOT,
184
- tree_hash_algorithm: "rigorloop-tree-hash-v1",
185
- tree_sha256: artifact?.tree_sha256 ?? "<planned-after-install>",
186
- file_count: "<planned-after-install>",
187
- },
188
- ],
290
+ adapters: [lockfileEntryForAdapter(info, source, artifact, descriptor)],
189
291
  },
190
292
  };
191
293
  }
192
294
 
193
- function lockfileForVerifiedInstall(info, source, manifest, artifact, treeHash, fileCount) {
295
+ function existingLockfileEntries(selectedAdapter) {
296
+ const lockfileAbsolutePath = resolve(process.cwd(), LOCKFILE_PATH);
297
+ if (!existsSync(lockfileAbsolutePath)) {
298
+ return [];
299
+ }
300
+ const parsed = parseLockfile(readFileSync(lockfileAbsolutePath, "utf8"));
301
+ if (!parsed.ok) {
302
+ return [];
303
+ }
304
+ return parsed.lockfile.generated.adapters.filter((entry) => entry.adapter !== selectedAdapter);
305
+ }
306
+
307
+ function lockfileForVerifiedInstall(info, source, manifest, artifact, rootHashes, descriptor) {
308
+ const adapters = [
309
+ ...existingLockfileEntries(descriptor.name),
310
+ lockfileEntryForAdapter(info, source, artifact, descriptor, rootHashes),
311
+ ];
194
312
  return {
195
- schema_version: 1,
313
+ schema_version: 2,
196
314
  rigorloop: {
197
315
  package: info.name,
198
316
  version: info.version,
@@ -202,28 +320,19 @@ function lockfileForVerifiedInstall(info, source, manifest, artifact, treeHash,
202
320
  sha256: sha256NormalizedText(manifest),
203
321
  },
204
322
  generated: {
205
- adapters: [
206
- {
207
- adapter: ADAPTER,
208
- release: releaseForPackage(info.version),
209
- source: source.type,
210
- archive: source.type === "local-archive" ? basename(source.archive) : source.archive,
211
- archive_sha256: artifact.sha256,
212
- installed_root: INSTALL_ROOT,
213
- tree_hash_algorithm: "rigorloop-tree-hash-v1",
214
- tree_sha256: treeHash,
215
- file_count: fileCount,
216
- },
217
- ],
323
+ adapters,
218
324
  },
219
325
  };
220
326
  }
221
327
 
222
- function compatibleManifest(content) {
328
+ function compatibleManifest(content, descriptor, artifact) {
329
+ if (descriptor.name === "opencode" && !rootsForArtifact(descriptor, artifact).commands && content.includes(".opencode/commands")) {
330
+ return false;
331
+ }
223
332
  return (
224
333
  content.includes("schema_version: 1") &&
225
- content.includes("name: codex") &&
226
- content.includes(`install_root: "${INSTALL_ROOT}"`)
334
+ content.includes(`name: ${descriptor.name}`) &&
335
+ content.includes(`"${Object.values(rootsForArtifact(descriptor, artifact))[0]}"`)
227
336
  );
228
337
  }
229
338
 
@@ -234,8 +343,11 @@ function pathState(path) {
234
343
  return statSync(path).isDirectory() ? "directory" : "file";
235
344
  }
236
345
 
237
- function directoryKind(path) {
238
- return path === AGENTS_ROOT ? "codex-agent-root" : "codex-install-root";
346
+ function directoryKind(path, descriptor, artifact) {
347
+ if (Object.values(rootsForArtifact(descriptor, artifact)).includes(path)) {
348
+ return `${descriptor.name}-install-root`;
349
+ }
350
+ return `${descriptor.name}-adapter-root`;
239
351
  }
240
352
 
241
353
  function sha256(bytes) {
@@ -338,11 +450,77 @@ function loadJsonFile(path) {
338
450
  async function fetchBytes(url) {
339
451
  const response = await fetch(url);
340
452
  if (!response.ok) {
341
- throw new Error(`HTTP ${response.status}`);
453
+ throw Object.assign(new Error(`HTTP ${response.status}`), { downloadFailureClass: "http-status" });
342
454
  }
343
455
  return Buffer.from(await response.arrayBuffer());
344
456
  }
345
457
 
458
+ const PROXY_ENV_VAR_ALLOWLIST = ["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy"];
459
+ const DOWNLOAD_FAILURE_CLASSES = new Set(["dns", "tls", "timeout", "http-status", "proxy", "network", "unknown"]);
460
+
461
+ function detectedProxyEnvVars(env = process.env) {
462
+ return PROXY_ENV_VAR_ALLOWLIST.filter((name) => Object.prototype.hasOwnProperty.call(env, name) && env[name]);
463
+ }
464
+
465
+ function nodeEnvProxyStatus(env = process.env, execArgv = process.execArgv) {
466
+ const nodeOptions = String(env.NODE_OPTIONS ?? "");
467
+ const useEnvProxy = String(env.NODE_USE_ENV_PROXY ?? "").toLowerCase();
468
+ // CR-M4-R1-F1: Node can enable fetch env-proxy via env vars or the runtime flag.
469
+ if (nodeOptions.includes("--use-env-proxy") || execArgv.includes("--use-env-proxy") || ["1", "true", "yes"].includes(useEnvProxy)) {
470
+ return "enabled";
471
+ }
472
+ if (detectedProxyEnvVars(env).length > 0) {
473
+ return "disabled";
474
+ }
475
+ return "unknown";
476
+ }
477
+
478
+ function downloadFailureClass(error) {
479
+ if (DOWNLOAD_FAILURE_CLASSES.has(error?.downloadFailureClass)) {
480
+ return error.downloadFailureClass;
481
+ }
482
+ const code = String(error?.code ?? error?.cause?.code ?? "").toUpperCase();
483
+ const message = String(error?.message ?? "").toLowerCase();
484
+ if (["ENOTFOUND", "EAI_AGAIN"].includes(code)) {
485
+ return "dns";
486
+ }
487
+ if (code.includes("CERT") || code.includes("TLS") || message.includes("certificate") || message.includes("tls")) {
488
+ return "tls";
489
+ }
490
+ if (code.includes("TIMEOUT") || code === "ABORT_ERR" || message.includes("timeout") || message.includes("timed out")) {
491
+ return "timeout";
492
+ }
493
+ if (code.includes("PROXY") || message.includes("proxy")) {
494
+ return "proxy";
495
+ }
496
+ if (["ECONNRESET", "ECONNREFUSED", "EHOSTUNREACH", "ENETUNREACH"].includes(code) || message.includes("fetch failed")) {
497
+ return "network";
498
+ }
499
+ return "unknown";
500
+ }
501
+
502
+ function downloadFailureDiagnostics(error, artifact, descriptor, metadata) {
503
+ return {
504
+ adapter: descriptor.name,
505
+ release: metadata.release.version,
506
+ archive_url: artifact.url,
507
+ download_failure_class: downloadFailureClass(error),
508
+ node_env_proxy_status: nodeEnvProxyStatus(),
509
+ proxy_env_vars_detected: detectedProxyEnvVars(),
510
+ };
511
+ }
512
+
513
+ function downloadFailureBlocker(error, artifact, descriptor, metadata) {
514
+ const diagnostics = downloadFailureDiagnostics(error, artifact, descriptor, metadata);
515
+ return {
516
+ code: "release-download-failed",
517
+ message: `Network download failed for adapter ${descriptor.name} release ${metadata.release.version} (failure class ${diagnostics.download_failure_class}).`,
518
+ path: artifact.url,
519
+ next_action: `Download ${artifact.url} and rerun with --from-archive ./${artifact.archive}.`,
520
+ diagnostics,
521
+ };
522
+ }
523
+
346
524
  function parseVerifiedMetadataBytes(bytes, expectedSha256) {
347
525
  const actualSha256 = sha256(bytes);
348
526
  if (actualSha256 !== expectedSha256) {
@@ -382,7 +560,15 @@ function isSha256(value) {
382
560
  return typeof value === "string" && /^[0-9a-f]{64}$/i.test(value);
383
561
  }
384
562
 
385
- function validateMetadata(metadata, info) {
563
+ function releaseListedForSkillsOnlyCompatibility(marker, release) {
564
+ if (!marker) {
565
+ return false;
566
+ }
567
+ const releases = Array.isArray(marker) ? marker : marker.releases;
568
+ return Array.isArray(releases) && releases.includes(release);
569
+ }
570
+
571
+ function validateMetadata(metadata, info, descriptor) {
386
572
  const release = releaseForPackage(info.version);
387
573
  if (!metadata || metadata.schema_version !== 1) {
388
574
  return { error: { code: "metadata-invalid", message: "Adapter metadata schema_version must be 1." } };
@@ -407,26 +593,76 @@ function validateMetadata(metadata, info) {
407
593
  if (!isNonEmptyString(metadata.validation?.command)) {
408
594
  return { error: { code: "metadata-invalid", message: "Adapter metadata validation command is missing." } };
409
595
  }
410
- const artifact = metadata.artifacts?.find((entry) => entry.adapter === ADAPTER);
596
+ const artifact = metadata.artifacts?.find((entry) => entry.adapter === descriptor.name);
411
597
  if (!artifact) {
412
- return { blocker: metadataBlocker("adapter-unknown", "Adapter metadata does not include Codex.") };
598
+ return { blocker: metadataBlocker("metadata-unavailable", `Adapter metadata does not include ${descriptor.displayName}.`) };
413
599
  }
414
- if (
415
- !isNonEmptyString(artifact.archive) ||
416
- !isNonEmptyString(artifact.url) ||
417
- !isSha256(artifact.sha256) ||
418
- !Number.isInteger(artifact.size_bytes) ||
419
- artifact.size_bytes < 0 ||
420
- !isSha256(artifact.tree_sha256)
421
- ) {
422
- return { error: { code: "metadata-invalid", message: "Codex adapter artifact metadata is incomplete." } };
423
- }
424
- if ((artifact.install_root ?? "").replace(/\/$/, "") !== INSTALL_ROOT) {
425
- return { error: { code: "metadata-invalid", message: "Codex adapter install root is not .agents/skills." } };
600
+ if (!isNonEmptyString(artifact.archive) || !isNonEmptyString(artifact.url) || !isSha256(artifact.sha256) || !Number.isInteger(artifact.size_bytes) || artifact.size_bytes < 0) {
601
+ return { error: { code: "metadata-invalid", message: `${descriptor.displayName} adapter artifact metadata is incomplete.` } };
426
602
  }
427
603
  if (artifact.tree_hash_algorithm && artifact.tree_hash_algorithm !== "rigorloop-tree-hash-v1") {
428
604
  return { error: { code: "metadata-invalid", message: "Unsupported tree hash algorithm in adapter metadata." } };
429
605
  }
606
+ if (artifact.install_roots || artifact.root_hashes) {
607
+ if (!artifact.install_roots || !artifact.root_hashes) {
608
+ return { error: { code: "metadata-invalid", message: `${descriptor.displayName} multi-root metadata is incomplete.` } };
609
+ }
610
+ for (const [role, root] of Object.entries(artifact.install_roots)) {
611
+ if (descriptor.installRoots[role] !== root) {
612
+ return { error: { code: "metadata-invalid", message: `${descriptor.displayName} adapter install root for ${role} is not supported.` } };
613
+ }
614
+ const rootHash = artifact.root_hashes[role];
615
+ if (!rootHash || !isSha256(rootHash.tree_sha256) || !Number.isInteger(rootHash.file_count) || rootHash.file_count < 0) {
616
+ return { error: { code: "metadata-invalid", message: `${descriptor.displayName} root hash metadata is incomplete.` } };
617
+ }
618
+ }
619
+ } else {
620
+ if (!isSha256(artifact.tree_sha256) || !Number.isInteger(artifact.file_count) || artifact.file_count < 0) {
621
+ return { error: { code: "metadata-invalid", message: `${descriptor.displayName} adapter single-root metadata is incomplete.` } };
622
+ }
623
+ if ((artifact.install_root ?? "").replace(/\/$/, "") !== descriptor.primaryInstallRoot()) {
624
+ return { error: { code: "metadata-invalid", message: `${descriptor.displayName} adapter install root is not ${descriptor.primaryInstallRoot()}.` } };
625
+ }
626
+ }
627
+ if (artifact.command_aliases?.opencode) {
628
+ if (descriptor.name !== "opencode" || !artifact.install_roots?.commands) {
629
+ return { error: { code: "metadata-invalid", message: "opencode command alias metadata requires the opencode commands root." } };
630
+ }
631
+ const aliasPaths = opencodeCommandAliasPaths(artifact);
632
+ if (
633
+ !Number.isInteger(artifact.command_aliases.opencode.count) ||
634
+ !Array.isArray(aliasPaths) ||
635
+ artifact.command_aliases.opencode.count !== aliasPaths.length ||
636
+ aliasPaths.some((aliasPath) => !isNonEmptyString(aliasPath) || !aliasPath.startsWith(`${descriptor.installRoots.commands}/`))
637
+ ) {
638
+ return { error: { code: "metadata-invalid", message: "opencode command alias metadata is incomplete." } };
639
+ }
640
+ }
641
+ // CR-M3-R2-F1: opencode commands root is valid only with declared command aliases.
642
+ if (descriptor.name === "opencode" && artifact.install_roots?.commands && !artifact.command_aliases?.opencode) {
643
+ return {
644
+ blocker: metadataBlocker(
645
+ "opencode-command-aliases-missing",
646
+ "Opencode commands root metadata requires command_aliases.opencode.",
647
+ artifact.archive,
648
+ "Use opencode metadata that declares command aliases, or use explicitly compatible skills-only metadata without the commands root.",
649
+ ),
650
+ };
651
+ }
652
+ // CR-M3-R1-F1: skills-only opencode compatibility must be explicit in trusted metadata.
653
+ if (descriptor.name === "opencode" && !artifact.command_aliases?.opencode && !rootsForArtifact(descriptor, artifact).commands) {
654
+ const marker = artifact.skills_only_compatibility ?? metadata.compatibility?.opencode_skills_only;
655
+ if (!releaseListedForSkillsOnlyCompatibility(marker, release)) {
656
+ return {
657
+ blocker: metadataBlocker(
658
+ "opencode-skills-only-compatibility-unmarked",
659
+ "Opencode skills-only archive metadata is not explicitly marked compatible.",
660
+ artifact.archive,
661
+ "Use an opencode archive with command alias metadata or bundled trusted skills-only compatibility metadata.",
662
+ ),
663
+ };
664
+ }
665
+ }
430
666
  return { artifact };
431
667
  }
432
668
 
@@ -500,14 +736,15 @@ function parseZipEntries(buffer) {
500
736
  return entries;
501
737
  }
502
738
 
503
- function unsafePathCode(name) {
739
+ function unsafePathCode(name, descriptor, artifact) {
504
740
  if (!name || name.startsWith("/") || name.startsWith("\\") || /^[A-Za-z]:/.test(name) || name.includes("\\")) {
505
741
  return "archive-path-traversal";
506
742
  }
507
743
  if (name.split("/").some((part) => part === ".." || part === "")) {
508
744
  return "archive-path-traversal";
509
745
  }
510
- if (!name.startsWith(`${INSTALL_ROOT}/`)) {
746
+ const allowedRoots = Object.values(rootsForArtifact(descriptor, artifact));
747
+ if (!allowedRoots.some((root) => name.startsWith(`${root}/`))) {
511
748
  return "archive-install-root-invalid";
512
749
  }
513
750
  return undefined;
@@ -517,19 +754,48 @@ function isArchiveSupportEntry(name) {
517
754
  return name === "AGENTS.md";
518
755
  }
519
756
 
520
- function fileRowsForTree(entries) {
757
+ function fileRowsForTreeRoot(entries, installRoot) {
521
758
  return entries
522
- .filter((entry) => !entry.directory)
759
+ .filter((entry) => !entry.directory && entry.name.startsWith(`${installRoot}/`))
523
760
  .map((entry) => {
524
- const relativePath = entry.name.slice(`${INSTALL_ROOT}/`.length);
761
+ const relativePath = entry.name.slice(`${installRoot}/`.length);
525
762
  const bytes = relativePath.endsWith(".md") ? normalizeText(entry.bytes) : entry.bytes;
526
763
  return [relativePath, sha256(bytes)];
527
764
  })
528
765
  .sort(([left], [right]) => left.localeCompare(right));
529
766
  }
530
767
 
531
- function treeHashForEntries(entries) {
532
- return treeHashForRows(fileRowsForTree(entries));
768
+ function opencodeCommandAliasPaths(artifact) {
769
+ const aliases = artifact?.command_aliases?.opencode;
770
+ if (!aliases) {
771
+ return undefined;
772
+ }
773
+ if (Array.isArray(aliases.paths)) {
774
+ return aliases.paths;
775
+ }
776
+ if (aliases.aliases && typeof aliases.aliases === "object") {
777
+ return Object.values(aliases.aliases);
778
+ }
779
+ return [];
780
+ }
781
+
782
+ function treeHashForEntries(entries, descriptor) {
783
+ return treeHashForRows(fileRowsForTreeRoot(entries, descriptor.primaryInstallRoot()));
784
+ }
785
+
786
+ function rootHashesForEntries(entries, descriptor, artifact) {
787
+ return Object.fromEntries(
788
+ Object.entries(rootsForArtifact(descriptor, artifact)).map(([role, root]) => {
789
+ const rows = fileRowsForTreeRoot(entries, root);
790
+ return [
791
+ role,
792
+ {
793
+ tree_sha256: treeHashForRows(rows),
794
+ file_count: rows.length,
795
+ },
796
+ ];
797
+ }),
798
+ );
533
799
  }
534
800
 
535
801
  function treeHashForRows(rows) {
@@ -573,22 +839,26 @@ function treeHashForFilesystem(root) {
573
839
  };
574
840
  }
575
841
 
576
- function currentCodexLockfileEntry() {
842
+ function currentLockfileEntries() {
577
843
  const lockfileAbsolutePath = resolve(process.cwd(), LOCKFILE_PATH);
578
844
  if (!existsSync(lockfileAbsolutePath)) {
579
- return undefined;
845
+ return [];
580
846
  }
581
847
  const parsed = parseLockfile(readFileSync(lockfileAbsolutePath, "utf8"));
582
848
  if (!parsed.ok) {
583
- return undefined;
849
+ return [];
584
850
  }
585
- return parsed.lockfile.generated.adapters.find((entry) => entry.adapter === ADAPTER && entry.installed_root === INSTALL_ROOT);
851
+ return parsed.lockfile.generated.adapters;
852
+ }
853
+
854
+ function currentLockfileEntry(descriptor) {
855
+ return currentLockfileEntries().find((entry) => entry.adapter === descriptor.name);
586
856
  }
587
857
 
588
858
  function installedTreeMismatchError(actualTree, expectedTreeHash, expectedFileCount) {
589
859
  return {
590
860
  code: "installed-tree-mismatch",
591
- message: "Installed Codex adapter tree does not match trusted metadata.",
861
+ message: "Installed adapter tree does not match trusted metadata.",
592
862
  expected_tree_sha256: expectedTreeHash,
593
863
  actual_tree_sha256: actualTree.treeHash,
594
864
  expected_file_count: expectedFileCount,
@@ -596,23 +866,29 @@ function installedTreeMismatchError(actualTree, expectedTreeHash, expectedFileCo
596
866
  };
597
867
  }
598
868
 
599
- function verifyInstalledTree(entries, artifact, { allowMissingOrEmpty = false } = {}) {
600
- const expectedRows = fileRowsForTree(entries);
601
- const expectedTreeHash = artifact.tree_sha256;
602
- const expectedFileCount = expectedRows.length;
869
+ function verifyInstalledTree(entries, artifact, descriptor, { allowMissingOrEmpty = false } = {}) {
870
+ const expectedRootHashes = rootHashesForEntries(entries, descriptor, artifact);
871
+ for (const [role, root] of Object.entries(rootsForArtifact(descriptor, artifact))) {
872
+ const expectedRows = fileRowsForTreeRoot(entries, root);
873
+ const expectedTreeHash = artifact.root_hashes?.[role]?.tree_sha256 ?? artifact.tree_sha256;
874
+ const expectedFileCount = artifact.root_hashes?.[role]?.file_count ?? expectedRows.length;
603
875
 
604
- if (!existsSync(resolve(process.cwd(), INSTALL_ROOT))) {
605
- return allowMissingOrEmpty ? { ok: true, expectedRows, expectedFileCount } : { error: installedTreeMismatchError({ treeHash: "<missing>", fileCount: 0 }, expectedTreeHash, expectedFileCount) };
606
- }
876
+ if (!existsSync(resolve(process.cwd(), root))) {
877
+ if (allowMissingOrEmpty) {
878
+ continue;
879
+ }
880
+ return { error: installedTreeMismatchError({ treeHash: "<missing>", fileCount: 0 }, expectedTreeHash, expectedFileCount) };
881
+ }
607
882
 
608
- const actualTree = treeHashForFilesystem(INSTALL_ROOT);
609
- if (allowMissingOrEmpty && actualTree.fileCount === 0) {
610
- return { ok: true, expectedRows, expectedFileCount };
611
- }
612
- if (!rowsEqual(actualTree.rows, expectedRows) || actualTree.treeHash !== expectedTreeHash) {
613
- return { error: installedTreeMismatchError(actualTree, expectedTreeHash, expectedFileCount) };
883
+ const actualTree = treeHashForFilesystem(root);
884
+ if (allowMissingOrEmpty && actualTree.fileCount === 0) {
885
+ continue;
886
+ }
887
+ if (!rowsEqual(actualTree.rows, expectedRows) || actualTree.treeHash !== expectedTreeHash || actualTree.fileCount !== expectedFileCount) {
888
+ return { error: installedTreeMismatchError(actualTree, expectedTreeHash, expectedFileCount) };
889
+ }
614
890
  }
615
- return { ok: true, expectedRows, expectedFileCount, treeHash: actualTree.treeHash };
891
+ return { ok: true, rootHashes: expectedRootHashes, expectedFileCount: expectedRootHashes.skills?.file_count ?? 0, treeHash: expectedRootHashes.skills?.tree_sha256 };
616
892
  }
617
893
 
618
894
  function generatedOutputConflictBlocker(entries) {
@@ -655,6 +931,21 @@ function lockfileDriftBlocker(lockfileEntry) {
655
931
  if (!lockfileEntry) {
656
932
  return undefined;
657
933
  }
934
+ if (lockfileEntry.installed_roots) {
935
+ for (const [role, root] of Object.entries(lockfileEntry.installed_roots)) {
936
+ const rootHash = lockfileEntry.root_hashes[role];
937
+ const blocker = lockfileDriftBlocker({
938
+ adapter: lockfileEntry.adapter,
939
+ installed_root: root,
940
+ tree_sha256: rootHash.tree_sha256,
941
+ file_count: rootHash.file_count,
942
+ });
943
+ if (blocker) {
944
+ return blocker;
945
+ }
946
+ }
947
+ return undefined;
948
+ }
658
949
 
659
950
  const rootState = pathState(resolve(process.cwd(), lockfileEntry.installed_root));
660
951
  if (rootState === "absent") {
@@ -695,7 +986,17 @@ function lockfileDriftBlocker(lockfileEntry) {
695
986
  return undefined;
696
987
  }
697
988
 
698
- function inspectArchive(archiveBytes, artifact) {
989
+ function firstLockfileDriftBlocker() {
990
+ for (const entry of currentLockfileEntries()) {
991
+ const blocker = lockfileDriftBlocker(entry);
992
+ if (blocker) {
993
+ return blocker;
994
+ }
995
+ }
996
+ return undefined;
997
+ }
998
+
999
+ function inspectArchive(archiveBytes, artifact, descriptor) {
699
1000
  if (artifact.size_bytes !== undefined && archiveBytes.length !== artifact.size_bytes) {
700
1001
  return { error: { code: "archive-size-mismatch", message: "Archive size does not match metadata." } };
701
1002
  }
@@ -716,7 +1017,7 @@ function inspectArchive(archiveBytes, artifact) {
716
1017
  if (isArchiveSupportEntry(entry.name)) {
717
1018
  continue;
718
1019
  }
719
- const pathCode = unsafePathCode(entry.name);
1020
+ const pathCode = unsafePathCode(entry.name, descriptor, artifact);
720
1021
  if (pathCode) {
721
1022
  return { error: { code: pathCode, message: `Archive entry is not allowed: ${entry.name}`, path: entry.name } };
722
1023
  }
@@ -727,14 +1028,34 @@ function inspectArchive(archiveBytes, artifact) {
727
1028
  }
728
1029
 
729
1030
  const files = installEntries.filter((entry) => !entry.directory);
730
- const treeHash = treeHashForEntries(files);
731
- if (artifact.tree_sha256 && treeHash !== artifact.tree_sha256) {
732
- return { error: { code: "tree-hash-mismatch", message: "Installed tree hash does not match metadata." } };
1031
+ const rootHashes = rootHashesForEntries(files, descriptor, artifact);
1032
+ for (const [role, hash] of Object.entries(rootHashes)) {
1033
+ const expected = artifact.root_hashes?.[role] ?? { tree_sha256: artifact.tree_sha256, file_count: artifact.file_count };
1034
+ if (expected.tree_sha256 && hash.tree_sha256 !== expected.tree_sha256) {
1035
+ return { error: { code: "tree-hash-mismatch", message: "Installed tree hash does not match metadata." } };
1036
+ }
1037
+ if (expected.file_count !== undefined && hash.file_count !== expected.file_count) {
1038
+ return { error: { code: "tree-hash-mismatch", message: "Installed tree file count does not match metadata." } };
1039
+ }
1040
+ }
1041
+ if (descriptor.name === "opencode") {
1042
+ for (const aliasPath of opencodeCommandAliasPaths(artifact) ?? []) {
1043
+ if (!files.some((entry) => entry.name === aliasPath)) {
1044
+ return {
1045
+ error: {
1046
+ code: "opencode-command-alias-missing",
1047
+ message: "Declared opencode command alias is missing from archive.",
1048
+ path: aliasPath,
1049
+ },
1050
+ };
1051
+ }
1052
+ }
733
1053
  }
734
- return { entries: files, archiveHash, treeHash, fileCount: files.length };
1054
+ return { entries: files, archiveHash, rootHashes, treeHash: rootHashes.skills?.tree_sha256, fileCount: rootHashes.skills?.file_count ?? files.length };
735
1055
  }
736
1056
 
737
- function addArchiveActions(plan, entries) {
1057
+ function addArchiveActions(plan, entries, descriptor) {
1058
+ const installRoot = descriptor.primaryInstallRoot();
738
1059
  const directories = new Set();
739
1060
  for (const entry of entries) {
740
1061
  const parts = entry.name.split("/");
@@ -770,7 +1091,7 @@ function addArchiveActions(plan, entries) {
770
1091
  const state = pathState(resolve(process.cwd(), entry.name));
771
1092
  let existingMatches = false;
772
1093
  if (state === "file") {
773
- const relativePath = entry.name.slice(`${INSTALL_ROOT}/`.length);
1094
+ const relativePath = entry.name.slice(`${installRoot}/`.length);
774
1095
  const existingBytes = relativePath.endsWith(".md")
775
1096
  ? normalizeText(readFileSync(resolve(process.cwd(), entry.name)))
776
1097
  : readFileSync(resolve(process.cwd(), entry.name));
@@ -783,9 +1104,9 @@ function addArchiveActions(plan, entries) {
783
1104
  status: state === "absent" ? "pending" : existingMatches ? "skipped" : "blocked",
784
1105
  reason:
785
1106
  state === "absent"
786
- ? "Install verified Codex adapter file."
1107
+ ? `Install verified ${descriptor.displayName} adapter file.`
787
1108
  : existingMatches
788
- ? `${entry.name} already matches verified Codex adapter content.`
1109
+ ? `${entry.name} already matches verified ${descriptor.displayName} adapter content.`
789
1110
  : `${entry.name} already exists.`,
790
1111
  });
791
1112
  plan.artifacts.push({
@@ -804,23 +1125,47 @@ function addArchiveActions(plan, entries) {
804
1125
  }
805
1126
  }
806
1127
 
807
- function writeArchiveEntries(entries) {
1128
+ function writeArchiveEntries(entries, descriptor) {
1129
+ const installRoot = descriptor.primaryInstallRoot();
808
1130
  for (const entry of entries) {
809
1131
  const outputPath = resolve(process.cwd(), entry.name);
810
1132
  mkdirSync(dirname(outputPath), { recursive: true });
811
- const relativePath = entry.name.slice(`${INSTALL_ROOT}/`.length);
1133
+ const relativePath = entry.name.slice(`${installRoot}/`.length);
812
1134
  const bytes = relativePath.endsWith(".md") ? normalizeText(entry.bytes) : entry.bytes;
813
1135
  writeFileSync(outputPath, bytes);
814
1136
  }
815
1137
  }
816
1138
 
817
- function planDirectoryActions(flags) {
1139
+ function initWarnings(descriptor, artifact) {
1140
+ if (
1141
+ descriptor.name === "opencode" &&
1142
+ artifact &&
1143
+ !artifact.command_aliases?.opencode &&
1144
+ !rootsForArtifact(descriptor, artifact).commands
1145
+ ) {
1146
+ return [
1147
+ {
1148
+ code: "opencode-command-aliases-not-declared",
1149
+ message: "Selected opencode archive metadata does not declare command aliases; only skills were installed.",
1150
+ },
1151
+ ];
1152
+ }
1153
+ return [];
1154
+ }
1155
+
1156
+ function directoryPlanForRoots(roots) {
1157
+ return [...new Set(Object.values(roots).flatMap((root) => [root.split("/").slice(0, -1).join("/"), root]).filter(Boolean))];
1158
+ }
1159
+
1160
+ function planDirectoryActions(flags, descriptor, artifact) {
818
1161
  const actions = [];
819
1162
  const artifacts = [];
820
1163
  const blockers = [];
821
1164
  let parentBlocked = false;
822
1165
 
823
- for (const relativePath of DIRECTORY_PLAN) {
1166
+ const directoryPlan = directoryPlanForRoots(rootsForArtifact(descriptor, artifact));
1167
+ const rootParent = directoryPlan[0];
1168
+ for (const relativePath of directoryPlan) {
824
1169
  const state = parentBlocked ? "blocked-by-parent" : pathState(resolve(process.cwd(), relativePath));
825
1170
  if (state === "absent") {
826
1171
  actions.push({
@@ -831,7 +1176,7 @@ function planDirectoryActions(flags) {
831
1176
  });
832
1177
  artifacts.push({
833
1178
  path: relativePath,
834
- kind: directoryKind(relativePath),
1179
+ kind: directoryKind(relativePath, descriptor, artifact),
835
1180
  status: flags.dryRun ? "planned" : "pending",
836
1181
  });
837
1182
  } else if (state === "directory") {
@@ -843,7 +1188,7 @@ function planDirectoryActions(flags) {
843
1188
  });
844
1189
  artifacts.push({
845
1190
  path: relativePath,
846
- kind: directoryKind(relativePath),
1191
+ kind: directoryKind(relativePath, descriptor, artifact),
847
1192
  status: "existing",
848
1193
  });
849
1194
  } else {
@@ -853,12 +1198,12 @@ function planDirectoryActions(flags) {
853
1198
  status: "blocked",
854
1199
  reason:
855
1200
  state === "blocked-by-parent"
856
- ? `${relativePath} cannot be created because ${AGENTS_ROOT} is not a directory.`
1201
+ ? `${relativePath} cannot be created because ${rootParent} is not a directory.`
857
1202
  : `${relativePath} exists and is not a directory.`,
858
1203
  });
859
1204
  artifacts.push({
860
1205
  path: relativePath,
861
- kind: directoryKind(relativePath),
1206
+ kind: directoryKind(relativePath, descriptor, artifact),
862
1207
  status: "blocked",
863
1208
  });
864
1209
  if (state !== "blocked-by-parent") {
@@ -869,7 +1214,7 @@ function planDirectoryActions(flags) {
869
1214
  next_action: `Move the existing file before running init.`,
870
1215
  });
871
1216
  }
872
- if (relativePath === AGENTS_ROOT) {
1217
+ if (relativePath === rootParent) {
873
1218
  parentBlocked = true;
874
1219
  }
875
1220
  }
@@ -944,15 +1289,16 @@ function addLockfilePlan(flags, actions, artifacts, blockers, errors) {
944
1289
  }
945
1290
  }
946
1291
 
947
- function buildInitPlan(flags, artifact) {
1292
+ function buildInitPlan(flags, descriptor, artifact) {
948
1293
  const info = packageInfo();
949
- const source = sourceForFlags(flags, info);
1294
+ const source = sourceForFlags(flags, info, descriptor);
950
1295
  if (artifact) {
951
1296
  source.artifact = artifact;
952
1297
  }
953
1298
  const manifestPath = "rigorloop.yaml";
954
1299
  const manifestAbsolutePath = resolve(process.cwd(), manifestPath);
955
- const manifest = manifestContent(info, source);
1300
+ const existingManifest = existsSync(manifestAbsolutePath) ? readFileSync(manifestAbsolutePath, "utf8") : undefined;
1301
+ const manifest = manifestContent(info, source, descriptor, existingManifest);
956
1302
  const actions = [];
957
1303
  const artifacts = [];
958
1304
  const blockers = [];
@@ -963,25 +1309,39 @@ function buildInitPlan(flags, artifact) {
963
1309
  code: "invalid-archive-path",
964
1310
  message: "Missing required value for --from-archive.",
965
1311
  path: "--from-archive",
966
- next_action: "Provide an existing Codex adapter archive path or omit --from-archive.",
1312
+ next_action: `Provide an existing ${descriptor.displayName} adapter archive path or omit --from-archive.`,
967
1313
  });
968
1314
  } else if (flags.fromArchiveProvided && !existsSync(resolve(process.cwd(), flags.fromArchive))) {
969
1315
  errors.push({
970
1316
  code: "invalid-archive-path",
971
1317
  message: `Local archive path does not exist: ${flags.fromArchive}`,
972
1318
  path: flags.fromArchive,
973
- next_action: "Provide an existing Codex adapter archive path or omit --from-archive.",
1319
+ next_action: `Provide an existing ${descriptor.displayName} adapter archive path or omit --from-archive.`,
974
1320
  });
975
1321
  }
976
1322
 
977
- const directoryPlan = planDirectoryActions(flags);
1323
+ const directoryPlan = planDirectoryActions(flags, descriptor, artifact);
978
1324
  actions.push(...directoryPlan.actions);
979
1325
  artifacts.push(...directoryPlan.artifacts);
980
1326
  blockers.push(...directoryPlan.blockers);
981
1327
 
982
- if (existsSync(manifestAbsolutePath)) {
983
- const existingManifest = readFileSync(manifestAbsolutePath, "utf8");
984
- if (compatibleManifest(existingManifest)) {
1328
+ if (existingManifest !== undefined) {
1329
+ const parsedManifest = parseManifestAdapterBlocks(existingManifest);
1330
+ if (parsedManifest.error) {
1331
+ errors.push({
1332
+ code: parsedManifest.error.code,
1333
+ message: parsedManifest.error.message,
1334
+ path: manifestPath,
1335
+ next_action: "Review or move the existing file before running init.",
1336
+ });
1337
+ } else if (parsedManifest.adapters.filter((entry) => entry.name === descriptor.name).length > 1) {
1338
+ blockers.push({
1339
+ code: "duplicate-adapter-entry",
1340
+ message: `Existing rigorloop.yaml contains duplicate ${descriptor.displayName} adapter entries.`,
1341
+ path: manifestPath,
1342
+ next_action: "Remove duplicate adapter entries before running init.",
1343
+ });
1344
+ } else if (compatibleManifest(existingManifest, descriptor, artifact)) {
985
1345
  actions.push({
986
1346
  type: "write",
987
1347
  path: manifestPath,
@@ -994,11 +1354,16 @@ function buildInitPlan(flags, artifact) {
994
1354
  status: "existing",
995
1355
  });
996
1356
  } else {
997
- errors.push({
998
- code: "invalid-config",
999
- message: "Existing rigorloop.yaml is not compatible with the first-slice Codex init contract.",
1357
+ actions.push({
1358
+ type: "write",
1000
1359
  path: manifestPath,
1001
- next_action: "Review or move the existing file before running init.",
1360
+ status: flags.dryRun ? "planned" : "pending",
1361
+ reason: `Update rigorloop.yaml for ${descriptor.displayName} adapter.`,
1362
+ });
1363
+ artifacts.push({
1364
+ path: manifestPath,
1365
+ kind: "project-manifest",
1366
+ status: flags.dryRun ? "planned" : "pending",
1002
1367
  });
1003
1368
  }
1004
1369
  } else {
@@ -1025,7 +1390,7 @@ function buildInitPlan(flags, artifact) {
1025
1390
  artifacts,
1026
1391
  blockers,
1027
1392
  errors,
1028
- planned_lockfile: plannedLockfile(info, source, manifest),
1393
+ planned_lockfile: plannedLockfile(info, source, manifest, descriptor),
1029
1394
  };
1030
1395
  }
1031
1396
 
@@ -1111,19 +1476,19 @@ function invalidArchivePath(message, flags) {
1111
1476
  code: "invalid-archive-path",
1112
1477
  message,
1113
1478
  path: flags.fromArchive,
1114
- next_action: "Provide an existing Codex adapter archive path or omit --from-archive.",
1479
+ next_action: "Provide an existing supported adapter archive path or omit --from-archive.",
1115
1480
  });
1116
1481
  }
1117
1482
 
1118
1483
  function unsupportedAdapter(adapter, flags) {
1119
1484
  const result = envelope("init", flags, {
1120
1485
  status: "blocked",
1121
- summary: `Adapter '${adapter}' is not supported in this slice.`,
1486
+ summary: `Adapter '${adapter}' is not supported.`,
1122
1487
  blockers: [
1123
1488
  {
1124
- code: "adapter-unsupported",
1125
- message: `Adapter '${adapter}' is not supported in this slice.`,
1126
- next_action: "Use --adapter codex.",
1489
+ code: "adapter-unknown",
1490
+ message: `Adapter '${adapter}' is not supported.`,
1491
+ next_action: `Use one of: ${supportedAdapterNames().join(", ")}.`,
1127
1492
  },
1128
1493
  ],
1129
1494
  });
@@ -1131,7 +1496,7 @@ function unsupportedAdapter(adapter, flags) {
1131
1496
  if (flags.json) {
1132
1497
  writeJson(result);
1133
1498
  } else {
1134
- process.stderr.write(`${result.summary}\nUse --adapter codex.\n`);
1499
+ process.stderr.write(`${result.summary}\nUse one of: ${supportedAdapterNames().join(", ")}.\n`);
1135
1500
  }
1136
1501
  return exitCodeForResult({ ...result, exit_class: "blocked" });
1137
1502
  }
@@ -1160,6 +1525,9 @@ function writeBlockedResult(flags, plan, summary, blockers, exitClass = "blocked
1160
1525
  },
1161
1526
  planned_lockfile: plan.planned_lockfile,
1162
1527
  });
1528
+ if (blockers[0]?.diagnostics) {
1529
+ result.diagnostics = { ...result.diagnostics, ...blockers[0].diagnostics };
1530
+ }
1163
1531
  if (flags.json) {
1164
1532
  writeJson(result);
1165
1533
  } else {
@@ -1204,18 +1572,27 @@ function writeValidationErrorResult(flags, plan, error) {
1204
1572
  return exitCodeForResult({ ...result, exit_class: "validation_failed" });
1205
1573
  }
1206
1574
 
1207
- async function archiveWorkForInit(flags, info) {
1208
- if (flags.dryRun) {
1575
+ async function archiveWorkForInit(flags, info, descriptor) {
1576
+ if (flags.dryRun && !flags.fromArchiveProvided) {
1209
1577
  return {};
1210
1578
  }
1211
1579
 
1212
1580
  const bundledMetadata = loadVerifiedBundledMetadata(info);
1213
1581
  if (bundledMetadata.blocker || bundledMetadata.error) {
1582
+ if (flags.dryRun) {
1583
+ return {};
1584
+ }
1214
1585
  return bundledMetadata;
1215
1586
  }
1216
1587
  const metadata = bundledMetadata.metadata;
1217
- const validation = validateMetadata(metadata, info);
1588
+ const validation = validateMetadata(metadata, info, descriptor);
1218
1589
  if (validation.blocker || validation.error) {
1590
+ if (
1591
+ flags.dryRun &&
1592
+ !["opencode-command-aliases-missing", "opencode-skills-only-compatibility-unmarked"].includes(validation.blocker?.code)
1593
+ ) {
1594
+ return {};
1595
+ }
1219
1596
  return validation;
1220
1597
  }
1221
1598
  const artifact = validation.artifact;
@@ -1223,17 +1600,31 @@ async function archiveWorkForInit(flags, info) {
1223
1600
  if (flags.fromArchiveProvided) {
1224
1601
  const archiveName = basename(flags.fromArchive);
1225
1602
  if (archiveName !== artifact.archive || !archiveName.includes(metadata.release.version)) {
1603
+ if (!archiveName.startsWith(`rigorloop-adapter-${descriptor.name}-`)) {
1604
+ return {
1605
+ error: {
1606
+ code: "adapter-archive-mismatch",
1607
+ message: `Local archive ${archiveName} is not a ${descriptor.displayName} adapter archive.`,
1608
+ path: flags.fromArchive,
1609
+ },
1610
+ artifact,
1611
+ };
1612
+ }
1226
1613
  return {
1227
1614
  blocker: metadataBlocker(
1228
1615
  "release-version-incompatible",
1229
1616
  `Local archive ${archiveName} is not compatible with ${metadata.release.version}.`,
1230
1617
  flags.fromArchive,
1231
- "Use the Codex adapter archive matching the installed CLI package version.",
1618
+ `Use the ${descriptor.displayName} adapter archive matching the installed CLI package version.`,
1232
1619
  ),
1233
1620
  };
1234
1621
  }
1622
+ // CR-M2-R2-F1: dry-run planning must use trusted metadata roots without reading or extracting archive bytes.
1623
+ if (flags.dryRun) {
1624
+ return { artifact };
1625
+ }
1235
1626
  const archiveBytes = readFileSync(resolve(process.cwd(), flags.fromArchive));
1236
- const inspected = inspectArchive(archiveBytes, artifact);
1627
+ const inspected = inspectArchive(archiveBytes, artifact, descriptor);
1237
1628
  if (inspected.error) {
1238
1629
  return { error: inspected.error, artifact };
1239
1630
  }
@@ -1258,17 +1649,12 @@ async function archiveWorkForInit(flags, info) {
1258
1649
  }
1259
1650
  try {
1260
1651
  archiveBytes = await fetchBytes(artifact.url);
1261
- } catch {
1652
+ } catch (error) {
1262
1653
  return {
1263
- blocker: metadataBlocker(
1264
- "release-unavailable",
1265
- "Official Codex adapter archive is unavailable.",
1266
- artifact.url,
1267
- "Retry later or use --from-archive with a compatible local archive.",
1268
- ),
1654
+ blocker: downloadFailureBlocker(error, artifact, descriptor, metadata),
1269
1655
  };
1270
1656
  }
1271
- const inspected = inspectArchive(archiveBytes, artifact);
1657
+ const inspected = inspectArchive(archiveBytes, artifact, descriptor);
1272
1658
  if (inspected.error) {
1273
1659
  return { error: inspected.error, artifact };
1274
1660
  }
@@ -1277,9 +1663,10 @@ async function archiveWorkForInit(flags, info) {
1277
1663
 
1278
1664
  async function handleInit(flags) {
1279
1665
  if (!flags.adapter) {
1280
- return invalidUsage("Missing required option: --adapter codex.", flags, "init");
1666
+ return invalidUsage("Missing required option: --adapter codex|claude|opencode.", flags, "init");
1281
1667
  }
1282
- if (flags.adapter !== "codex") {
1668
+ const descriptor = adapterDescriptor(flags.adapter);
1669
+ if (!descriptor) {
1283
1670
  return unsupportedAdapter(flags.adapter, flags);
1284
1671
  }
1285
1672
  if (flags.fromArchiveProvided && (!flags.fromArchive || flags.fromArchive.startsWith("--"))) {
@@ -1290,7 +1677,7 @@ async function handleInit(flags) {
1290
1677
  }
1291
1678
 
1292
1679
  const info = packageInfo();
1293
- const plan = buildInitPlan(flags);
1680
+ let plan = buildInitPlan(flags, descriptor);
1294
1681
  if (plan.errors.length > 0) {
1295
1682
  const result = envelope("init", flags, {
1296
1683
  status: "error",
@@ -1311,21 +1698,30 @@ async function handleInit(flags) {
1311
1698
  }
1312
1699
  return exitCodeForResult({ ...result, exit_class: "invalid_usage" });
1313
1700
  }
1314
- if (plan.blockers.length > 0) {
1701
+ const deferrableRootBlockers =
1702
+ descriptor.name === "opencode" &&
1703
+ plan.blockers.length > 0 &&
1704
+ plan.blockers.every((blocker) => String(blocker.path ?? "").startsWith(".opencode/commands"));
1705
+ if (plan.blockers.length > 0 && !deferrableRootBlockers) {
1315
1706
  return writeBlockedResult(flags, plan, plan.blockers[0].message, plan.blockers, exitClassForBlockers(plan.blockers));
1316
1707
  }
1317
1708
 
1318
- const archiveWork = await archiveWorkForInit(flags, info);
1319
- if (archiveWork.artifact) {
1320
- plan.source.artifact = archiveWork.artifact;
1321
- plan.planned_lockfile = plannedLockfile(plan.info, plan.source, plan.manifest);
1709
+ const archiveWork = await archiveWorkForInit(flags, info, descriptor);
1710
+ if (archiveWork.artifact && !archiveWork.blocker && !archiveWork.error) {
1711
+ plan = buildInitPlan(flags, descriptor, archiveWork.artifact);
1712
+ if (plan.errors.length > 0) {
1713
+ return writeValidationErrorResult(flags, plan, plan.errors[0]);
1714
+ }
1715
+ if (plan.blockers.length > 0) {
1716
+ return writeBlockedResult(flags, plan, plan.blockers[0].message, plan.blockers, exitClassForBlockers(plan.blockers));
1717
+ }
1322
1718
  }
1323
1719
  if (archiveWork.entries) {
1324
1720
  const conflict = generatedOutputConflictBlocker(archiveWork.entries);
1325
1721
  if (conflict) {
1326
1722
  return writeBlockedResult(flags, plan, conflict.message, [conflict], "mutation_conflict");
1327
1723
  }
1328
- const drift = lockfileDriftBlocker(currentCodexLockfileEntry());
1724
+ const drift = firstLockfileDriftBlocker();
1329
1725
  if (drift) {
1330
1726
  return writeBlockedResult(
1331
1727
  flags,
@@ -1335,11 +1731,11 @@ async function handleInit(flags) {
1335
1731
  drift.code === "overwrite-refused" ? "mutation_conflict" : "blocked",
1336
1732
  );
1337
1733
  }
1338
- const installedTree = verifyInstalledTree(archiveWork.entries, archiveWork.artifact, { allowMissingOrEmpty: true });
1734
+ const installedTree = verifyInstalledTree(archiveWork.entries, archiveWork.artifact, descriptor, { allowMissingOrEmpty: true });
1339
1735
  if (installedTree.error) {
1340
1736
  return writeValidationErrorResult(flags, plan, installedTree.error);
1341
1737
  }
1342
- addArchiveActions(plan, archiveWork.entries);
1738
+ addArchiveActions(plan, archiveWork.entries, descriptor);
1343
1739
  }
1344
1740
 
1345
1741
  if (archiveWork.blocker) {
@@ -1370,7 +1766,7 @@ async function handleInit(flags) {
1370
1766
  const pendingCopyPaths = new Set(
1371
1767
  plan.actions.filter((action) => action.type === "copy" && action.status === "pending").map((action) => action.path),
1372
1768
  );
1373
- writeArchiveEntries(archiveWork.entries.filter((entry) => pendingCopyPaths.has(entry.name)));
1769
+ writeArchiveEntries(archiveWork.entries.filter((entry) => pendingCopyPaths.has(entry.name)), descriptor);
1374
1770
  for (const action of plan.actions.filter((action) => action.type === "copy" && action.status === "pending")) {
1375
1771
  action.status = "done";
1376
1772
  plan.artifacts.find((artifact) => artifact.path === action.path).status = "created";
@@ -1407,7 +1803,7 @@ async function handleInit(flags) {
1407
1803
  const lockfileArtifact = plan.artifacts.find((artifact) => artifact.path === LOCKFILE_PATH);
1408
1804
  if (lockfileAction?.status === "pending") {
1409
1805
  const lockfilePreviouslyExists = existsSync(resolve(process.cwd(), LOCKFILE_PATH));
1410
- const verifiedInstalledTree = verifyInstalledTree(archiveWork.entries, archiveWork.artifact);
1806
+ const verifiedInstalledTree = verifyInstalledTree(archiveWork.entries, archiveWork.artifact, descriptor);
1411
1807
  if (verifiedInstalledTree.error) {
1412
1808
  return writeValidationErrorResult(flags, plan, verifiedInstalledTree.error);
1413
1809
  }
@@ -1416,15 +1812,15 @@ async function handleInit(flags) {
1416
1812
  plan.source,
1417
1813
  plan.manifest,
1418
1814
  archiveWork.artifact,
1419
- archiveWork.artifact.tree_sha256,
1420
- verifiedInstalledTree.expectedFileCount,
1815
+ verifiedInstalledTree.rootHashes,
1816
+ descriptor,
1421
1817
  );
1422
1818
  writeFileSync(resolve(process.cwd(), LOCKFILE_PATH), serializeLockfile(lockfile), "utf8");
1423
1819
  plan.planned_lockfile = lockfile;
1424
1820
  lockfileAction.status = "done";
1425
1821
  lockfileAction.reason = lockfilePreviouslyExists
1426
- ? "Updated durable lockfile for verified Codex adapter install."
1427
- : "Wrote durable lockfile for verified Codex adapter install.";
1822
+ ? `Updated durable lockfile for verified ${descriptor.displayName} adapter install.`
1823
+ : `Wrote durable lockfile for verified ${descriptor.displayName} adapter install.`;
1428
1824
  if (lockfileArtifact) {
1429
1825
  lockfileArtifact.status = lockfilePreviouslyExists ? "updated" : "created";
1430
1826
  }
@@ -1432,14 +1828,14 @@ async function handleInit(flags) {
1432
1828
  }
1433
1829
  }
1434
1830
 
1435
- const warnings = [];
1831
+ const warnings = initWarnings(descriptor, archiveWork.artifact);
1436
1832
  const result = envelope("init", flags, {
1437
1833
  status: warnings.length > 0 ? "warning" : "success",
1438
1834
  summary: flags.dryRun
1439
1835
  ? "RigorLoop init dry run completed. No files were written."
1440
1836
  : archiveWork.entries
1441
- ? "RigorLoop initialized with verified Codex adapter files."
1442
- : "RigorLoop initialized with Codex scaffold.",
1837
+ ? `RigorLoop initialized with verified ${descriptor.displayName} adapter files.`
1838
+ : `RigorLoop initialized with ${descriptor.displayName} scaffold.`,
1443
1839
  actions: plan.actions,
1444
1840
  artifacts: plan.artifacts,
1445
1841
  warnings,
@@ -1457,10 +1853,13 @@ async function handleInit(flags) {
1457
1853
  ? ["RigorLoop init dry run completed.", "No files were written."]
1458
1854
  : [
1459
1855
  archiveWork.entries
1460
- ? "RigorLoop initialized with verified Codex adapter files."
1461
- : "RigorLoop initialized with Codex scaffold.",
1856
+ ? `RigorLoop initialized with verified ${descriptor.displayName} adapter files.`
1857
+ : `RigorLoop initialized with ${descriptor.displayName} scaffold.`,
1462
1858
  archiveWork.entries ? "rigorloop.lock was written." : "No adapter files were installed.",
1463
1859
  ];
1860
+ for (const warning of warnings) {
1861
+ lines.push(`warning ${warning.code}: ${warning.message}`);
1862
+ }
1464
1863
  writeHuman(`${lines.join("\n")}\n`, flags);
1465
1864
  }
1466
1865
  return exitCodeForResult({ ...result, exit_class: "success" });