run402 4.66.1 → 4.67.1

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 (45) hide show
  1. package/README.md +4 -2
  2. package/gitvault-surface.json +10 -2
  3. package/lib/cold-start.mjs +77 -0
  4. package/lib/command-manifest.mjs +6 -1
  5. package/lib/doctor.mjs +10 -0
  6. package/lib/gitvault-capabilities.mjs +12 -0
  7. package/lib/org-context.mjs +26 -0
  8. package/lib/path-lookup.mjs +24 -0
  9. package/lib/path-lookup.test.mjs +66 -0
  10. package/lib/repos.mjs +307 -10
  11. package/lib/rooms-context.mjs +17 -1
  12. package/package.json +1 -1
  13. package/sdk/dist/errors.d.ts +1 -1
  14. package/sdk/dist/errors.d.ts.map +1 -1
  15. package/sdk/dist/errors.js.map +1 -1
  16. package/sdk/dist/index.d.ts +1 -1
  17. package/sdk/dist/index.d.ts.map +1 -1
  18. package/sdk/dist/index.js +1 -1
  19. package/sdk/dist/index.js.map +1 -1
  20. package/sdk/dist/namespaces/gitvault.d.ts +152 -4
  21. package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
  22. package/sdk/dist/namespaces/gitvault.js +296 -8
  23. package/sdk/dist/namespaces/gitvault.js.map +1 -1
  24. package/sdk/dist/node/gitvault-address.d.ts +2 -0
  25. package/sdk/dist/node/gitvault-address.d.ts.map +1 -1
  26. package/sdk/dist/node/gitvault-address.js +13 -2
  27. package/sdk/dist/node/gitvault-address.js.map +1 -1
  28. package/sdk/dist/node/gitvault-handoff.d.ts +111 -0
  29. package/sdk/dist/node/gitvault-handoff.d.ts.map +1 -0
  30. package/sdk/dist/node/gitvault-handoff.js +277 -0
  31. package/sdk/dist/node/gitvault-handoff.js.map +1 -0
  32. package/sdk/dist/node/gitvault-keystore.d.ts +1 -1
  33. package/sdk/dist/node/gitvault-keystore.d.ts.map +1 -1
  34. package/sdk/dist/node/gitvault-restore.d.ts +46 -0
  35. package/sdk/dist/node/gitvault-restore.d.ts.map +1 -0
  36. package/sdk/dist/node/gitvault-restore.js +123 -0
  37. package/sdk/dist/node/gitvault-restore.js.map +1 -0
  38. package/sdk/dist/node/gitvault-snapshot.d.ts +87 -0
  39. package/sdk/dist/node/gitvault-snapshot.d.ts.map +1 -1
  40. package/sdk/dist/node/gitvault-snapshot.js +216 -11
  41. package/sdk/dist/node/gitvault-snapshot.js.map +1 -1
  42. package/sdk/dist/node/index.d.ts +6 -2
  43. package/sdk/dist/node/index.d.ts.map +1 -1
  44. package/sdk/dist/node/index.js +8 -1
  45. package/sdk/dist/node/index.js.map +1 -1
@@ -29,7 +29,7 @@
29
29
  * never persisted into an agent-surface result store, and never logged.
30
30
  */
31
31
  import { LocalError, isRun402Error } from "../errors.js";
32
- import { GITVAULT_BYO_UNMIRRORED_REMEDY_STATEMENT, GITVAULT_DEGRADED_READ_STATEMENT, GITVAULT_DURABILITY_STATEMENT, GITVAULT_MIRROR_KEYSTORE_STILL_REQUIRED_STATEMENT, GITVAULT_MIRROR_VALIDITY_NOT_FRESHNESS_STATEMENT, GITVAULT_TERMINAL_LOSS_DOCTOR_TEXT, GITVAULT_TERMINAL_LOSS_STATEMENT, GITVAULT_UNMIRRORED_FINDING_STATEMENT, } from "./gitvault.crypto.js";
32
+ import { GITVAULT_BYO_UNMIRRORED_REMEDY_STATEMENT, GITVAULT_DEGRADED_READ_STATEMENT, GITVAULT_DURABILITY_STATEMENT, GITVAULT_MIRROR_KEYSTORE_STILL_REQUIRED_STATEMENT, GITVAULT_MIRROR_VALIDITY_NOT_FRESHNESS_STATEMENT, GITVAULT_TERMINAL_LOSS_DOCTOR_TEXT, GITVAULT_TERMINAL_LOSS_STATEMENT, GITVAULT_UNMIRRORED_FINDING_STATEMENT, bytesToHex, hexToBytes, parseGitvaultStrict, randomBytes, sha256Hex, verifyGitvaultObject, } from "./gitvault.crypto.js";
33
33
  /** A keystore path, or `null` when there is no id to derive it from (or it is malformed). */
34
34
  function safePath(derive, repoId) {
35
35
  if (!repoId)
@@ -53,6 +53,39 @@ async function nodeOnly(load, verb) {
53
53
  function mib(bytes) {
54
54
  return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
55
55
  }
56
+ /**
57
+ * A client-generated handoff id (RFC 4122 v4), minted BEFORE the mint call
58
+ * (kygit-handoff design D3): `auth_secret`/`wrap_key` derive off it, so it
59
+ * cannot be gateway-assigned the way `internal.gitvault_claims.id` alone
60
+ * would suggest — this SDK supplies it, mirroring the SAME
61
+ * client_creation_id/client_open_id convention this protocol family
62
+ * already uses elsewhere for idempotent creation. `handoff()` refuses if
63
+ * the gateway's minted `handoff_id` disagrees.
64
+ */
65
+ function randomHandoffUuid() {
66
+ const b = randomBytes(16);
67
+ b[6] = ((b[6] ?? 0) & 0x0f) | 0x40;
68
+ b[8] = ((b[8] ?? 0) & 0x3f) | 0x80;
69
+ const hex = bytesToHex(b);
70
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
71
+ }
72
+ /**
73
+ * The gateway names the vault by its three ids on the wire — `repo_id`,
74
+ * `org_id`, `project_id` (docs/style.md's API-boundary vocabulary) — on
75
+ * BOTH the handoff mint (`POST /gitvault/v1/vaults/:vault_id/handoffs`) and
76
+ * claim (`POST /gitvault/v1/handoffs/:handoff_id/claim`) responses. The SDK
77
+ * groups them under `vault` with the `organization_id` spelling every other
78
+ * SDK result uses. Neither response carries a slug-form address, so
79
+ * `address` is `null` unless the caller already knows one (a slug-form
80
+ * remote at mint time). Pure; exported for tests.
81
+ */
82
+ export function handoffVaultFromWire(wire, address = null) {
83
+ return { vault_id: wire.repo_id, address, organization_id: wire.org_id, project_id: wire.project_id };
84
+ }
85
+ /** The claim response's `membership` block (`org_id` on the wire) in the SDK's `organization_id` spelling. Pure; exported for tests. */
86
+ export function handoffMembershipFromWire(wire) {
87
+ return { organization_id: wire.org_id, role: wire.role, status: wire.status };
88
+ }
56
89
  /**
57
90
  * gitvault-byo-primary-bucket task 3.3 — the number of `{key, object_kind}`
58
91
  * entries `GITVAULT_BYO_OBJECT_MISSING`'s `details.missing` lists before
@@ -1111,6 +1144,237 @@ export class Gitvault {
1111
1144
  const reconcileRecipients = await this.#tryReconcileEnvelopeRecipients(handle.vault);
1112
1145
  return { ...result, snapshot, gitvault_commit: snapshot.oid, gitvault_commit_line: line, mirror_push: mirrorPush, byo_chain_copy: byoChainCopy, reconcile_recipients: reconcileRecipients };
1113
1146
  }
1147
+ // ── Handoff / resume (kygit-handoff design D1-D10) ─────────────────────────
1148
+ /**
1149
+ * Mint a Handoff Key: capture a stash-shaped checkpoint (design D1),
1150
+ * push it (retained, on no branch — {@link GITVAULT_DEPLOY_REF} carries
1151
+ * it exactly like an ordinary `push()`, never `refs/heads/*`), seal the
1152
+ * vault's current epoch key under a fresh `wrap_key`, and mint through
1153
+ * the gateway. The assembled `kgh1_…` key is returned exactly ONCE —
1154
+ * nothing here or downstream persists it.
1155
+ *
1156
+ * `options.note` omits `capture` — this method fills it with the real
1157
+ * capture figures and runs the client-side secret scan BEFORE the
1158
+ * handoff commit is written (design D10: no override flag).
1159
+ */
1160
+ async handoff(options) {
1161
+ const [{ deployRefTransaction }, { captureHandoffSnapshot, snapshotCommitment }, ho] = await Promise.all([this.#publication(), this.#snapshot(), this.#handoff()]);
1162
+ const { assembleHandoffKey, deriveHandoffSecrets, sealHandoffEnvelope, assertHandoffNoteHasNoSecret, HANDOFF_ENVELOPE_KIND } = ho;
1163
+ const handle = options.address ? (await this.resolveOrCreateAddress({ ...options, address: options.address, allow_create: false })).handle : await this.open(options);
1164
+ const repoDir = options.repo_dir ?? process.cwd();
1165
+ const repoFile = handle.keystore.readRepo(handle.repo_id);
1166
+ if (!repoFile) {
1167
+ throw new LocalError(`no local key material for ${handle.repo_id} — this principal is not yet a member with a materialized envelope (push once first)`, "minting a handoff", { code: "GITVAULT_VAULT_UNRESOLVED" });
1168
+ }
1169
+ const kRepo = hexToBytes(repoFile.k_repo_hex);
1170
+ const snapshot = await captureHandoffSnapshot({
1171
+ dir: repoDir,
1172
+ ...(options.includeSensitive !== undefined ? { includeSensitive: options.includeSensitive } : {}),
1173
+ message: (stats) => {
1174
+ const note = {
1175
+ ...options.note,
1176
+ capture: {
1177
+ base_head: stats.base_head_oid,
1178
+ branch: stats.branch,
1179
+ modified_captured: stats.modified_captured.length,
1180
+ untracked_captured: stats.untracked_captured.length,
1181
+ sensitive_excluded: stats.sensitive_excluded,
1182
+ ignored_not_transferred_count: stats.ignored_not_transferred_count,
1183
+ },
1184
+ };
1185
+ assertHandoffNoteHasNoSecret(note);
1186
+ return JSON.stringify(note);
1187
+ },
1188
+ });
1189
+ options.onCommitLine?.(`handoff checkpoint ${snapshot.oid}`);
1190
+ const materialized = await handle.vault.materialize();
1191
+ const pushResult = await handle.vault.push({
1192
+ transaction: deployRefTransaction(materialized.refs, snapshot.oid),
1193
+ head_target: snapshot.head,
1194
+ protocol_refs: "allow",
1195
+ }).catch((e) => { throw this.#enrichEpochRotationRequired(e, handle.repo_id); });
1196
+ const snapshotOidHmac = snapshotCommitment(kRepo, handle.repo_id, repoFile.epoch, snapshot.oid);
1197
+ // Client-generated handoff_id (mirrors this protocol family's own
1198
+ // client_creation_id/client_open_id convention): needed to derive
1199
+ // auth_secret/wrap_key and seal the envelope BEFORE the mint call, so
1200
+ // it cannot be gateway-assigned. The gateway's own `handoff_id` in the
1201
+ // response is authoritative; a disagreement (an id collision the
1202
+ // gateway resolved differently) is refused rather than silently
1203
+ // trusted, since a mismatched id would make the recipient's derived
1204
+ // secrets useless anyway.
1205
+ const handoffId = randomHandoffUuid();
1206
+ const { key, handoff_id_bytes, master_secret } = assembleHandoffKey(handoffId, randomBytes(32));
1207
+ const secrets = deriveHandoffSecrets(handoff_id_bytes, master_secret);
1208
+ const sealed = sealHandoffEnvelope(handoff_id_bytes, secrets.wrap_key, {
1209
+ v: 1,
1210
+ kind: "handoff",
1211
+ repo_id: handle.repo_id,
1212
+ epoch: repoFile.epoch,
1213
+ k_e_hex: repoFile.k_repo_hex,
1214
+ checkpoint: { generation: pushResult.generation, commit_oid: snapshot.oid },
1215
+ note_schema: "kygit.handoff-note.v1",
1216
+ });
1217
+ // The wire shape is the gateway's documented one (llms-full.txt
1218
+ // "Handoff / resume"): `role` (the minted role), `repo_id` / `org_id` /
1219
+ // `project_id`, a verbatim `warning` sentence plus its machine-readable
1220
+ // `warnings[]` twin. Read those names exactly — an SDK-side spelling
1221
+ // that the gateway never sends surfaces as "role undefined" at the CLI.
1222
+ const response = await this.#client.request(`/gitvault/v1/vaults/${encodeURIComponent(handle.repo_id)}/handoffs`, {
1223
+ method: "POST",
1224
+ body: {
1225
+ handoff_id: handoffId,
1226
+ ...(options.role !== undefined ? { role: options.role } : {}),
1227
+ ...(options.ttlSeconds !== undefined ? { expires_in_seconds: options.ttlSeconds } : {}),
1228
+ checkpoint: { generation: pushResult.generation, snapshot_oid_hmac: snapshotOidHmac },
1229
+ sealed_envelope: sealed.sealed_envelope,
1230
+ envelope_kind: sealed.envelope_kind ?? HANDOFF_ENVELOPE_KIND,
1231
+ auth_hash: secrets.auth_hash_hex,
1232
+ },
1233
+ context: "minting a handoff key",
1234
+ });
1235
+ if (response.handoff_id !== handoffId) {
1236
+ throw new LocalError(`the gateway minted a different handoff_id (${response.handoff_id}) than requested (${handoffId}) — the assembled key would not match; retry`, "minting a handoff key", { code: "HANDOFF_ID_MISMATCH", details: { requested: handoffId, minted: response.handoff_id } });
1237
+ }
1238
+ return {
1239
+ handoff_key: key,
1240
+ handoff_id: response.handoff_id,
1241
+ kind: response.kind,
1242
+ minted_role: response.role,
1243
+ expires_at: response.expires_at,
1244
+ // A slug-form remote is the one address the minter already knows;
1245
+ // an id-form one carries nothing a directory name should be built from.
1246
+ vault: handoffVaultFromWire(response, options.address && gitvaultRemoteAddressForm(options.address) === "slug" ? `${options.address.org_id}/${options.address.project_id}` : null),
1247
+ checkpoint: response.checkpoint,
1248
+ capture: {
1249
+ modified_captured: snapshot.modified_captured.length,
1250
+ untracked_captured: snapshot.untracked_captured.length,
1251
+ sensitive_excluded: snapshot.sensitive_excluded,
1252
+ ignored_not_transferred_count: snapshot.ignored_not_transferred_count,
1253
+ },
1254
+ snapshot,
1255
+ warnings: response.warnings ?? [],
1256
+ next_actions: response.next_actions ?? [],
1257
+ };
1258
+ }
1259
+ /** List a vault's handoffs (ids, kind, state, role, expiry, claimed_by — never the hash or envelope). */
1260
+ async listHandoffs(options) {
1261
+ const repoId = await this.#resolveRepoId(options);
1262
+ return this.#client.request(`/gitvault/v1/vaults/${encodeURIComponent(repoId)}/handoffs`, { context: "listing handoffs" });
1263
+ }
1264
+ /** Revoke a handoff (idempotent — a second revoke of an already-revoked/claimed/expired row still answers `200`). */
1265
+ async revokeHandoff(handoffId, options) {
1266
+ const repoId = await this.#resolveRepoId(options);
1267
+ return this.#client.request(`/gitvault/v1/vaults/${encodeURIComponent(repoId)}/handoffs/${encodeURIComponent(handoffId)}`, { method: "DELETE", context: "revoking a handoff" });
1268
+ }
1269
+ /**
1270
+ * Resume a Handoff Key: parse → claim (SIWX wallet, no payment; the
1271
+ * default Node credentials provider creates the allowance file on a
1272
+ * fresh machine automatically — see `createLazyPaidFetch`) → open the
1273
+ * sealed envelope → write the repo file to the keystore BEFORE touching
1274
+ * disk → clone at the base HEAD → `git stash apply --index` → local
1275
+ * git-config pins only → the session-start reconcile so a principal
1276
+ * envelope supersedes the bearer one.
1277
+ */
1278
+ async resume(options) {
1279
+ const [ho, { GitvaultKeystore }, { createGitvaultHttpTransport }, restore] = await Promise.all([this.#handoff(), this.#keystore(), this.#publication(), this.#restore()]);
1280
+ const { parseHandoffKey, deriveHandoffSecrets, openHandoffEnvelope } = ho;
1281
+ const { cloneGitvaultRemote, applyHandoffCheckpoint, resolveResumeTargetDir, readGitCommitMessage } = restore;
1282
+ const parsed = parseHandoffKey(options.key);
1283
+ const secrets = deriveHandoffSecrets(parsed.handoff_id_bytes, parsed.master_secret);
1284
+ const claim = await this.#client.request(`/gitvault/v1/handoffs/${encodeURIComponent(parsed.handoff_id)}/claim`, {
1285
+ method: "POST",
1286
+ body: { auth_secret: bytesToHex(secrets.auth_secret) },
1287
+ context: "claiming a handoff key",
1288
+ });
1289
+ // The claim names the vault by id only (no slug-form address rides the
1290
+ // wire), so the default target directory falls back to the vault id —
1291
+ // `--to <dir>` names it explicitly.
1292
+ const vault = handoffVaultFromWire(claim);
1293
+ const payload = openHandoffEnvelope(parsed.handoff_id_bytes, secrets.wrap_key, claim.sealed_envelope, claim.envelope_kind);
1294
+ if (payload.repo_id !== vault.vault_id) {
1295
+ throw new LocalError("the opened envelope's repo_id does not match the claim response's vault — refusing", "resuming a handoff", { code: "HANDOFF_ENVELOPE_INVALID" });
1296
+ }
1297
+ const keystore = new GitvaultKeystore(options.keystore_root !== undefined ? { rootDir: options.keystore_root } : {});
1298
+ keystore.ensureIdentity();
1299
+ // Genesis must be pinned before ANY materialize call can succeed
1300
+ // (`GitvaultVault.genesis()` requires a keystore repo file — this is
1301
+ // the one read that happens BEFORE one exists, via the transport
1302
+ // directly, mirroring `restoreRepoFromEnvelope`'s own signature check).
1303
+ const transport = createGitvaultHttpTransport(this.#client);
1304
+ const genesisBytes = await transport.getGenesis({ repo_id: vault.vault_id });
1305
+ if (!genesisBytes) {
1306
+ throw new LocalError("the vault has no admitted genesis", "resuming a handoff", { code: "CHAIN_BROKEN", details: { repo_id: vault.vault_id } });
1307
+ }
1308
+ const genesis = parseGitvaultStrict(new TextDecoder().decode(genesisBytes));
1309
+ if (!verifyGitvaultObject(genesis, genesis.creator_signing_pubkey)) {
1310
+ throw new LocalError("vault_genesis signature does not verify", "resuming a handoff", { code: "GITVAULT_SIGNATURE_INVALID", details: { repo_id: vault.vault_id } });
1311
+ }
1312
+ const genesisSha = sha256Hex(genesisBytes);
1313
+ // Write the repo file to the keystore BEFORE touching disk (design D10).
1314
+ keystore.saveRepo({
1315
+ repo_id: vault.vault_id,
1316
+ org_id: vault.organization_id,
1317
+ project_id: vault.project_id ?? "",
1318
+ k_repo_hex: payload.k_e_hex,
1319
+ epoch: payload.epoch,
1320
+ epoch_keys: { [payload.epoch]: payload.k_e_hex },
1321
+ genesis_sha256: genesisSha,
1322
+ head_pin: null,
1323
+ last_ref_transaction: null,
1324
+ provenance: "restored_from_handoff",
1325
+ });
1326
+ const targetDir = await resolveResumeTargetDir(options.to, vault.address, vault.vault_id);
1327
+ options.onLine?.(`resuming into ${targetDir}`);
1328
+ const remoteUrl = gitvaultRemoteUrl(vault.organization_id, vault.project_id);
1329
+ await cloneGitvaultRemote(remoteUrl, targetDir);
1330
+ const restored = await applyHandoffCheckpoint({ dir: targetDir, stash_oid: payload.checkpoint.commit_oid });
1331
+ // Local-only pins (design D10) — never a worktree file, never the
1332
+ // global active project. Reuses the SAME pin-writer every other
1333
+ // gitvault resolution path uses, which also writes `r402.room`.
1334
+ const { pinGitvaultRepo } = await this.#address();
1335
+ const addressParts = vault.address ? vault.address.split("/") : null;
1336
+ await pinGitvaultRepo(targetDir, vault.vault_id, addressParts && addressParts.length === 2 ? { org_slug: addressParts[0], repo_name: addressParts[1] } : undefined, { project_id: vault.project_id, org_id: vault.organization_id });
1337
+ // The bearer envelope is superseded within minutes of use — run the
1338
+ // same reconcile `push()` runs, best-effort (never a `resume()` throw).
1339
+ const handle = await this.open({ repo_id: vault.vault_id, repo_dir: targetDir, keystore_root: options.keystore_root });
1340
+ const reconcile = await this.#tryReconcileEnvelopeRecipients(handle.vault);
1341
+ const senderIsOwner = claim.membership.role === "owner";
1342
+ const nextActions = [...(claim.next_actions ?? [])];
1343
+ if (claim.kind === "handoff" && senderIsOwner && !nextActions.some((a) => a.type === "remove_member")) {
1344
+ nextActions.push({
1345
+ type: "remove_member",
1346
+ why: "The previous agent is still an owner; if its environment is gone for good, remove it.",
1347
+ destructive: true,
1348
+ requires_approval: true,
1349
+ });
1350
+ }
1351
+ if (!nextActions.some((a) => a.type === "push_repo")) {
1352
+ nextActions.push({ type: "push_repo", command: "git push origin main", why: "Publish continued work back to the vault." });
1353
+ }
1354
+ let note = null;
1355
+ let noteRaw = null;
1356
+ try {
1357
+ noteRaw = (await readGitCommitMessage(targetDir, payload.checkpoint.commit_oid)) ?? null;
1358
+ if (noteRaw)
1359
+ note = JSON.parse(noteRaw);
1360
+ }
1361
+ catch {
1362
+ note = null;
1363
+ }
1364
+ return {
1365
+ handoff_id: claim.handoff_id,
1366
+ kind: claim.kind,
1367
+ deduplicated: claim.deduplicated,
1368
+ note,
1369
+ note_raw: noteRaw,
1370
+ restored: { dir: targetDir, branch: restored.branch, base_head_oid: restored.base_head_oid, stash_oid: restored.stash_oid },
1371
+ membership: handoffMembershipFromWire(claim.membership),
1372
+ members: claim.members ?? [],
1373
+ expires_at: claim.expires_at,
1374
+ reconcile_recipients: reconcile,
1375
+ next_actions: nextActions,
1376
+ };
1377
+ }
1114
1378
  /** Best-effort dual-push: catches EVERYTHING, including the lazy module import itself, so a mirror problem can never surface as a `push()` throw. */
1115
1379
  /**
1116
1380
  * `EPOCH_ROTATION_REQUIRED` (D193) is left THROWN — never swallowed into a
@@ -2446,6 +2710,12 @@ export class Gitvault {
2446
2710
  #recovery() {
2447
2711
  return nodeOnly(() => import("../node/gitvault-recover.js"), "recover");
2448
2712
  }
2713
+ #handoff() {
2714
+ return nodeOnly(() => import("../node/gitvault-handoff.js"), "handoff");
2715
+ }
2716
+ #restore() {
2717
+ return nodeOnly(() => import("../node/gitvault-restore.js"), "resume");
2718
+ }
2449
2719
  #degradedRead() {
2450
2720
  return nodeOnly(() => import("../node/gitvault-degraded-read.js"), "list");
2451
2721
  }
@@ -2615,24 +2885,42 @@ export function gitvaultUnmirroredFinding(state) {
2615
2885
  export function gitvaultDegradedReadNote(source) {
2616
2886
  return `degraded read from ${source.destination}: ${GITVAULT_DEGRADED_READ_STATEMENT}`;
2617
2887
  }
2618
- /** `run402::<org_id>/<project_id>` — what `git-remote-run402` resolves. */
2888
+ /**
2889
+ * The remote door (kygit-handoff design D8): `"run402"` (the canonical,
2890
+ * plumbing spelling — accepted forever) or `"kygit"` (what the
2891
+ * `@kychee/kygit` shim renders once it sets `RUN402_REMOTE_SCHEME=kygit`
2892
+ * before exec). The gateway never sees this — `address` and every registry
2893
+ * `next_actions` command stay `run402::`; only client-side RENDERING reads
2894
+ * it. Any other value falls back to `"run402"` rather than emitting an
2895
+ * unparseable scheme.
2896
+ */
2897
+ export function gitvaultRemoteScheme() {
2898
+ return typeof process !== "undefined" && process.env?.RUN402_REMOTE_SCHEME === "kygit" ? "kygit" : "run402";
2899
+ }
2900
+ /** `<door>::<org_id>/<project_id>` — what `git-remote-run402`/`git-remote-kygit` resolves. */
2619
2901
  export function gitvaultRemoteUrl(orgId, projectId) {
2620
- return `run402::${orgId}/${projectId}`;
2902
+ return `${gitvaultRemoteScheme()}::${orgId}/${projectId}`;
2621
2903
  }
2622
2904
  /**
2623
- * `run402::<org-slug>/<repo-name>` — the address-form remote builder
2905
+ * `<door>::<org-slug>/<repo-name>` — the address-form remote builder
2624
2906
  * (repo-first-onramp task 4, design D6). Same string shape as
2625
2907
  * {@link gitvaultRemoteUrl} (the wire slot admits both forms undiscriminated
2626
2908
  * — see {@link gitvaultRemoteAddressForm}); kept as its own named function so
2627
2909
  * a call site states which form it means rather than reusing the id-form
2628
- * builder for a semantically different pair of arguments.
2910
+ * builder for a semantically different pair of arguments. Rendered by
2911
+ * {@link gitvaultRemoteScheme} (kygit-handoff design D8) — `run402 repos
2912
+ * create` renders `run402::`, `kygit create` renders `kygit::`.
2629
2913
  */
2630
2914
  export function gitvaultRemoteUrlForRepo(orgSlug, repoName) {
2631
- return `run402::${orgSlug}/${repoName}`;
2915
+ return `${gitvaultRemoteScheme()}::${orgSlug}/${repoName}`;
2632
2916
  }
2633
- /** Parse a `run402::<org>/<project>` remote URL. `null` when it is not one. */
2917
+ /**
2918
+ * Parse a `run402::<org>/<project>` OR `kygit::<org>/<project>` remote URL
2919
+ * (kygit-handoff design D8) into ONE canonical, scheme-less address — the
2920
+ * door never changes resolution, only rendering. `null` when it is neither.
2921
+ */
2634
2922
  export function parseGitvaultRemoteUrl(url) {
2635
- const m = /^run402::([^/]+)\/(.+)$/.exec(url.trim());
2923
+ const m = /^(?:run402|kygit)::([^/]+)\/(.+)$/.exec(url.trim());
2636
2924
  if (!m)
2637
2925
  return null;
2638
2926
  return { org_id: m[1], project_id: m[2] };