run402 4.42.0 → 4.43.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.
Files changed (46) hide show
  1. package/lib/command-manifest.mjs +6 -3
  2. package/lib/deploy-v2.mjs +27 -4
  3. package/lib/next-actions.mjs +5 -2
  4. package/lib/repos.mjs +466 -117
  5. package/lib/stats.mjs +57 -0
  6. package/package.json +1 -1
  7. package/sdk/dist/index.d.ts +10 -1
  8. package/sdk/dist/index.d.ts.map +1 -1
  9. package/sdk/dist/index.js +11 -0
  10. package/sdk/dist/index.js.map +1 -1
  11. package/sdk/dist/kernel.d.ts +31 -0
  12. package/sdk/dist/kernel.d.ts.map +1 -1
  13. package/sdk/dist/kernel.js +116 -3
  14. package/sdk/dist/kernel.js.map +1 -1
  15. package/sdk/dist/namespaces/gitvault.crypto.d.ts +178 -5
  16. package/sdk/dist/namespaces/gitvault.crypto.d.ts.map +1 -1
  17. package/sdk/dist/namespaces/gitvault.crypto.js +266 -7
  18. package/sdk/dist/namespaces/gitvault.crypto.js.map +1 -1
  19. package/sdk/dist/namespaces/gitvault.d.ts +103 -1
  20. package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
  21. package/sdk/dist/namespaces/gitvault.js +175 -7
  22. package/sdk/dist/namespaces/gitvault.js.map +1 -1
  23. package/sdk/dist/namespaces/gitvault.types.d.ts +184 -9
  24. package/sdk/dist/namespaces/gitvault.types.d.ts.map +1 -1
  25. package/sdk/dist/namespaces/gitvault.types.js +2 -1
  26. package/sdk/dist/namespaces/gitvault.types.js.map +1 -1
  27. package/sdk/dist/node/gitvault-apply.d.ts +8 -0
  28. package/sdk/dist/node/gitvault-apply.d.ts.map +1 -1
  29. package/sdk/dist/node/gitvault-apply.js +1 -0
  30. package/sdk/dist/node/gitvault-apply.js.map +1 -1
  31. package/sdk/dist/node/gitvault-deploy.d.ts +13 -0
  32. package/sdk/dist/node/gitvault-deploy.d.ts.map +1 -1
  33. package/sdk/dist/node/gitvault-deploy.js +28 -0
  34. package/sdk/dist/node/gitvault-deploy.js.map +1 -1
  35. package/sdk/dist/node/gitvault-keystore.d.ts +54 -4
  36. package/sdk/dist/node/gitvault-keystore.d.ts.map +1 -1
  37. package/sdk/dist/node/gitvault-keystore.js +20 -0
  38. package/sdk/dist/node/gitvault-keystore.js.map +1 -1
  39. package/sdk/dist/node/gitvault-publication.d.ts +328 -4
  40. package/sdk/dist/node/gitvault-publication.d.ts.map +1 -1
  41. package/sdk/dist/node/gitvault-publication.js +560 -14
  42. package/sdk/dist/node/gitvault-publication.js.map +1 -1
  43. package/sdk/dist/node/gitvault-snapshot.d.ts +40 -7
  44. package/sdk/dist/node/gitvault-snapshot.d.ts.map +1 -1
  45. package/sdk/dist/node/gitvault-snapshot.js +67 -19
  46. package/sdk/dist/node/gitvault-snapshot.js.map +1 -1
package/lib/repos.mjs CHANGED
@@ -33,6 +33,7 @@ import { resolveGitvaultTarget } from "./gitvault-target.mjs";
33
33
  import { nextAction, claimOrgSlugAction, claimRepoNameAction } from "./next-actions.mjs";
34
34
  import { printKeystoreLocation } from "./gitvault.mjs";
35
35
  import { gitvaultRemoteUrlForRepo } from "#sdk";
36
+ import { sdkStats, printVerboseStats } from "./stats.mjs";
36
37
  import {
37
38
  normalizeArgv,
38
39
  hasHelp,
@@ -55,28 +56,34 @@ Usage:
55
56
  Common:
56
57
  run402 repos create [name] [--org <org_id>] [--dir <path>] [--tier <tier>] [--project <id>]
57
58
  run402 repos view [--project <id>] [--repo <repo_id>] [--human]
58
- run402 repos list [--org <org_id>]
59
+ run402 repos list [--org <org_id>] [--human]
59
60
 
60
61
  Then plain git, forever:
61
62
  git push
62
63
  git clone run402::<org>/<repo>
63
64
 
64
65
  Occasional:
65
- run402 repos snapshot [--project <id>] [--repo <repo_id>] [--message <text>] [--checkpoint] [--dry-run]
66
+ run402 repos snapshot [--project <id>] [--repo <repo_id>] [--message <text>] [--checkpoint] [--dry-run] [--allow-dirty]
66
67
  run402 repos mirror [<destination>] [--off] [--backfill] [--profile <name> | --ambient] [--region <r>] [--endpoint <url>] [--project <id>] [--repo <repo_id>]
67
- run402 repos recover <source> --out <dir> [--repo <repo_id>] [--profile <name> | --ambient] [--region <r>] [--endpoint <url>]
68
+ run402 repos recover <source> --out <dir> [--repo <repo_id>] [--profile <name> | --ambient] [--region <r>] [--endpoint <url>] [--human]
68
69
 
69
70
  Lifecycle:
70
71
  run402 repos rename <new_name> [--repo <repo_id> | --project <project_id>]
71
72
  run402 repos delete [--project <id>] [--repo <repo_id>] [--force]
72
73
 
73
74
  Maintenance:
74
- run402 repos fsck [--project <id>] [--repo <repo_id>] [--mirror] [--budget <n>] [--no-write]
75
+ run402 repos fsck [--project <id>] [--repo <repo_id>] [--mirror] [--budget <n>] [--no-write] [--human]
75
76
  run402 repos gc [--project <id>] [--repo <repo_id>] [--submit --intent-core <path> --verifier-receipt <path> [--wait]]
76
- run402 repos access [--project <id>] [--repo <repo_id>]
77
- run402 repos access repair [--project <id>] [--repo <repo_id>]
77
+ run402 repos access [--project <id>] [--repo <repo_id>] [--human]
78
+ run402 repos access repair [--project <id>] [--repo <repo_id>] --recipient-state-version <n> --recipient-revocation-version <n>
79
+ run402 repos access revoke-key <principal_id> [--project <id>] [--repo <repo_id>]
80
+ run402 repos access declare-exposure [--project <id>] [--repo <repo_id>]
78
81
  run402 repos policy <required|grandfathered> [--project <id>] [--repo <repo_id>] [--reason <why>]
79
82
 
83
+ Every verb above also accepts -v/--verbose (a stderr summary line of request
84
+ stats — round trips, wire time, bytes — coexists with --human) and always
85
+ carries a \`stats\` block in its JSON result.
86
+
80
87
  Subcommands:
81
88
  create Provision (or, with --project, ADOPT an existing project), ALLOCATE
82
89
  its vault (mints key material and, on first allocation, a one-shot
@@ -101,7 +108,8 @@ Subcommands:
101
108
  vaults-by-org read when the gateway has it (one round trip);
102
109
  gracefully falls back to the older per-project walk when it
103
110
  404s. Not every project in the org — ones with no vault are
104
- omitted.
111
+ omitted. \`--human\` renders a compact roster (address,
112
+ generation, bytes, policy) instead of JSON.
105
113
  rename Claim or rename the repo's per-org-unique, address-form name
106
114
  (the <name> half of run402::<org-slug>/<name>). Address by
107
115
  --repo or --project (not both).
@@ -120,6 +128,12 @@ Subcommands:
120
128
  publishing. Push-to-creates through a slug-form remote
121
129
  (run402::<org-slug>/<name>) the same way \`git push\` does.
122
130
  \`--dry-run\` previews the real local pipeline without publishing.
131
+ A DIRTY tree (modified/staged tracked paths, or untracked-not-
132
+ ignored paths) REFUSES by default (SNAPSHOT_DIRTY_TREE, before any
133
+ object is created) — commit and retry, or pass \`--allow-dirty\` to
134
+ capture it as-is; the result then discloses exactly what was
135
+ swept in (modified_captured / untracked_captured), printed to
136
+ stderr too. \`--dry-run\` surfaces the same refusal.
123
137
  mirror ONE flag-driven verb for the client-side, customer-
124
138
  owned ciphertext mirror — run402 never holds a credential to it.
125
139
  No argument: READ the configured destination + a keyless
@@ -136,7 +150,7 @@ Subcommands:
136
150
  path (normal retrieval is plain \`git clone run402::<org>/<repo>\`,
137
151
  no \`repos clone\` verb exists). Proves this mirror's validity,
138
152
  never freshness — read both honesty statements before relying on
139
- the result.
153
+ the result. \`--human\` renders a short summary instead of JSON.
140
154
  fsck Walks the head chain AND materializes
141
155
  the ref map, advancing BOTH
142
156
  local trust pins — reported EXPLICITLY as local_state_changed +
@@ -147,6 +161,7 @@ Subcommands:
147
161
  normal writing mode, so a budget-exceeded run resumes). \`--mirror\`
148
162
  additionally runs the keyless mirror integrity probe — it proves
149
163
  the mirror's VALIDITY, never its FRESHNESS, and says so.
164
+ \`--human\` renders a short summary instead of JSON.
150
165
  gc \`git gc\`'s own two halves — checkpoint publication (compact) and
151
166
  prune planning — in one verb, NOT described as "exactly git gc":
152
167
  the deletion ceremony is stricter. Plans and checkpoints by
@@ -163,20 +178,37 @@ Subcommands:
163
178
  pending_removal, from the gateway's desired-recipient-state
164
179
  substrate), and (best-effort, this machine only) each principal's
165
180
  local TOFU pin. stale_access names removed members whose access
166
- was NOT actually revoked — pending_removal is honest bookkeeping,
167
- not enforcement, until epoch rotation ships. Reports an HONEST
168
- remaining gap rather than inventing: history_scope (which epochs
169
- each recipient can read) has no substrate to reportgitvault
170
- protocol v0 pins a single fixed epoch, so there is no per-epoch
171
- scope yet; that lands with gitvault-human-envelopes' epoch-
172
- rotation work, in fold under adversarial review.
181
+ has NOT yet been rotated away — pending_removal is honest
182
+ bookkeeping, not enforcement, until \`access repair\`/\`revoke-key\`
183
+ actually rotates. history_scope (which epochs each recipient can
184
+ read) is not reported by this read see the \`gap\` field.
185
+ \`--human\` renders a compact roster instead of JSON (the read
186
+ form only repair/revoke-key/declare-exposure stay JSON-only).
173
187
  access repair
174
- NOT YET AVAILABLE gated on the epoch-rotation mechanism above
175
- landing. \`reconcile\`, the workaround it replaces, is REMOVED:
176
- it never wrapped a key correctly-scoped to "from
177
- here forward," and a temporary mechanism does not get a
178
- permanent verb. This refuses cleanly and points at \`repos
179
- access\` for what IS available today.
188
+ Epoch rotation (D193-D203, rev 42) with reason:"elective_rekey"
189
+ re-keys this vault's CURRENT epoch away from every stale_access
190
+ principal at once, and clears a pre-existing vault's one-time
191
+ migration requirement. \`reconcile\`, the workaround this
192
+ replaces, is REMOVED (it never wrapped a key correctly-scoped to
193
+ "from here forward" this does). Needs
194
+ --recipient-state-version and --recipient-revocation-version:
195
+ the gateway exposes no read route for these two counters outside
196
+ \`revoke-key\`'s own response, so this verb needs them supplied
197
+ explicitly today — refuses cleanly, naming exactly this, when
198
+ omitted. Owner + step-up.
199
+ access revoke-key <principal_id>
200
+ The ONE fully self-contained rotation entry point: declares
201
+ reason:"recipient_key_revoked" for one principal and rotates off
202
+ that declaration's OWN returned counters — no flags needed.
203
+ Owner + step-up. The rekey remedy for "this specific principal's
204
+ key should no longer be trusted."
205
+ access declare-exposure
206
+ Declares reason:"epoch_secret_exposed" for THIS vault
207
+ (vault-scoped, not org-wide) — the rekey remedy for a leaked
208
+ K_repo/K_e. The declaration itself lands immediately; the
209
+ follow-up rotation it authorizes is not auto-run (same counter
210
+ gap as \`access repair\`) — this prints exactly what to do next.
211
+ Owner + step-up.
180
212
  policy Set the activation policy — \`required\` (a deploy must present a
181
213
  vaulted capture) or \`grandfathered\` (it need not). Owner +
182
214
  step-up, audited. \`grandfathered\` is the documented way out of a
@@ -195,9 +227,17 @@ Options:
195
227
  --idempotency-key <key>
196
228
  create: re-running with the same key resolves to the
197
229
  same project instead of creating a second one — new
198
- projects only (default: derived from the name)
199
- --human view: a short summary on stdout instead of the JSON dump.
200
- Rejected together with --json.
230
+ projects only (default: derived from the name).
231
+ access repair/revoke-key: the rotation attempt's OWN
232
+ client_idempotency_key (32-hex) default: a fresh
233
+ CSPRNG value each call, never resumed across processes.
234
+ --recipient-state-version <n>
235
+ --recipient-revocation-version <n>
236
+ access repair: the D194 frozen watermark pair this
237
+ rotation attempt is fenced against. Required — see
238
+ \`run402 repos access repair --help\` for why.
239
+ --human view/list/access/fsck/recover: a short summary on stdout
240
+ instead of the JSON dump. Rejected together with --json.
201
241
  --force delete: proceed even though the repo holds generations
202
242
  that would be permanently and irrecoverably lost. Never
203
243
  overrides the non-repo-infrastructure refusal.
@@ -205,7 +245,13 @@ Options:
205
245
  tree produces (a clean tree pushes HEAD itself, unused)
206
246
  --checkpoint snapshot: force the checkpoint-bearing form regardless of delta size
207
247
  --dry-run snapshot: a REAL preview — runs the actual local pipeline
208
- and reports what would publish. Publishes nothing.
248
+ and reports what would publish. Publishes nothing. A
249
+ dirty tree still refuses SNAPSHOT_DIRTY_TREE here (a
250
+ preview that hid the refusal would lie).
251
+ --allow-dirty snapshot: capture a dirty tree as-is instead of refusing.
252
+ The result discloses exactly what was swept in
253
+ (modified_captured / untracked_captured) — even this
254
+ override never captures silently.
209
255
  --off mirror: remove the configured destination (config only)
210
256
  --backfill mirror: copy every object the configured mirror is missing
211
257
  --profile <name> mirror / recover: the AWS credential profile name for an
@@ -234,6 +280,10 @@ Options:
234
280
  signed completion appears, instead of returning immediately
235
281
  --reason <why> policy: why the policy is changing — recorded in the
236
282
  audit event. REQUIRED for \`grandfathered\`.
283
+ -v, --verbose Print one stderr summary line of this call's request
284
+ stats (round trips, wire time, bytes). Coexists with
285
+ --human. The JSON result always carries a \`stats\` block
286
+ regardless of this flag.
237
287
  --json No-op: stdout is already JSON.
238
288
 
239
289
  Terminal loss (protocol §0):
@@ -247,15 +297,16 @@ Examples:
247
297
  run402 repos create --project prj_1a2b3c # allocate for an existing project
248
298
  git push -u origin HEAD # the printed next_action, verbatim
249
299
  run402 repos view --human
250
- run402 repos list --org org_1a2b3c
300
+ run402 repos list --org org_1a2b3c --human
251
301
  run402 repos rename my-notes --project prj_1a2b3c
252
302
  run402 repos snapshot --dry-run
303
+ run402 repos snapshot --allow-dirty
253
304
  run402 repos mirror s3://acme-vault-mirror --profile acme
254
305
  run402 repos mirror --backfill
255
- run402 repos fsck --mirror
306
+ run402 repos fsck --mirror --human
256
307
  run402 repos gc
257
- run402 repos access
258
- run402 repos recover s3://acme-vault-mirror --out ./restored
308
+ run402 repos access --human
309
+ run402 repos recover s3://acme-vault-mirror --out ./restored --human
259
310
  run402 repos delete --project prj_xyz --force
260
311
  `;
261
312
 
@@ -305,6 +356,19 @@ function printTerminalLoss(status) {
305
356
  console.error("");
306
357
  }
307
358
 
359
+ /**
360
+ * Print a verb's JSON result with the always-on `stats` block (Observability:
361
+ * RUN402_TRACE + always-on stats + -v). `sdk.stats()` reflects only calls
362
+ * made through THIS `sdk` instance — every verb below resolves one `sdk =
363
+ * getSdk()` and reuses it for its own direct calls so the count is accurate
364
+ * for the work this function did; calls a shared cross-cutting helper
365
+ * (org/wallet resolution) makes through its own internal instance are not
366
+ * reflected (see `cli/lib/stats.mjs`'s doc comment).
367
+ */
368
+ function printJson(sdk, payload) {
369
+ console.log(JSON.stringify({ ...payload, stats: sdkStats(sdk) }, null, 2));
370
+ }
371
+
308
372
  const LARGE_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
309
373
 
310
374
  /**
@@ -507,17 +571,17 @@ async function inferRepoName(dir) {
507
571
 
508
572
  const CREATE_VALUE_FLAGS = ["--org", "--dir", "--tier", "--idempotency-key", "--project"];
509
573
 
510
- async function printCreateResult({ projectId, vault, adopted, name }) {
574
+ async function printCreateResult({ sdk, projectId, vault, adopted, name, verboseArgv }) {
511
575
  let address = null;
512
576
  let orgSlug = null;
513
577
  try {
514
578
  const owningOrg = await resolveOwningOrgId(projectId);
515
- const orgRecord = owningOrg ? await getSdk().org(owningOrg).get() : null;
579
+ const orgRecord = owningOrg ? await sdk.org(owningOrg).get() : null;
516
580
  orgSlug = orgRecord?.slug ?? null;
517
581
  if (orgSlug && name) {
518
582
  const candidate = slugifyRepoName(name);
519
583
  if (candidate) {
520
- const named = await getSdk().projects.setRepoName(projectId, candidate);
584
+ const named = await sdk.projects.setRepoName(projectId, candidate);
521
585
  address = gitvaultRemoteUrlForRepo(orgSlug, named.repo_name);
522
586
  }
523
587
  }
@@ -546,13 +610,13 @@ async function printCreateResult({ projectId, vault, adopted, name }) {
546
610
  deployed: false,
547
611
  next_actions: nextActions,
548
612
  };
549
- console.log(JSON.stringify(out, null, 2));
613
+ printJson(sdk, out);
550
614
  console.error(
551
615
  `project ${projectId} ${adopted ? "adopted" : "provisioned"}; repo ${vault.repo_id} ` +
552
616
  (vault.deduplicated ? "already existed — nothing was re-allocated" : `allocated (genesis ${vault.genesis_sha256})`),
553
617
  );
554
618
  if (address) console.error(`address: ${address}`);
555
- else if (!orgSlug) console.error("no named address yet — claim an org slug (run402 org slug <slug>, one-time $1) to get run402::<slug>/<name> addresses");
619
+ else if (!orgSlug) console.error("no named address yet — claim an org slug (run402 org slug <slug>) to get run402::<slug>/<name> addresses");
556
620
  else console.error(`no address claimed — run 'run402 repos rename <name> --project ${projectId}' to claim one`);
557
621
  if (vault.remote) console.error(`remote '${vault.remote.name}' -> ${vault.remote.url} (${vault.remote.reason})`);
558
622
  if (pushAction) console.error(`next: ${pushAction.command}`);
@@ -561,9 +625,11 @@ async function printCreateResult({ projectId, vault, adopted, name }) {
561
625
  await printKeystoreLocation();
562
626
  console.error("");
563
627
  console.error("nothing was deployed — this is a vault-only repo. Deploy later with `run402 deploy apply`, or never.");
628
+ printVerboseStats(verboseArgv, sdk);
564
629
  }
565
630
 
566
631
  async function createAdopt(projectId, dir, a) {
632
+ const sdk = getSdk();
567
633
  const orgId = flagValue(a, "--org") ?? await resolveOwningOrgId(projectId);
568
634
  if (!orgId) {
569
635
  fail({
@@ -574,14 +640,15 @@ async function createAdopt(projectId, dir, a) {
574
640
  });
575
641
  }
576
642
  try {
577
- const vault = await getSdk().gitvault.init({ org_id: orgId, project_id: projectId, repo_dir: dir });
578
- await printCreateResult({ projectId, vault, adopted: true, name: null });
643
+ const vault = await sdk.gitvault.init({ org_id: orgId, project_id: projectId, repo_dir: dir });
644
+ await printCreateResult({ sdk, projectId, vault, adopted: true, name: null, verboseArgv: a });
579
645
  } catch (err) {
580
646
  reportSdkError(err);
581
647
  }
582
648
  }
583
649
 
584
650
  async function createProvision(name, dir, a) {
651
+ const sdk = getSdk();
585
652
  const tier = flagValue(a, "--tier") ?? "prototype";
586
653
  const idempotencyKey = flagValue(a, "--idempotency-key") ?? `repos-create:${name}`;
587
654
  // `optional: true` — a fresh wallet with no org yet is the cold-start path
@@ -593,7 +660,7 @@ async function createProvision(name, dir, a) {
593
660
  let provisioned;
594
661
  try {
595
662
  provisioned = await withAutoApprove(() =>
596
- getSdk().projects.provision({ tier, name, ...(orgId ? { orgId } : {}), idempotencyKey }),
663
+ sdk.projects.provision({ tier, name, ...(orgId ? { orgId } : {}), idempotencyKey }),
597
664
  );
598
665
  } catch (err) {
599
666
  reportSdkError(err);
@@ -612,8 +679,8 @@ async function createProvision(name, dir, a) {
612
679
  }
613
680
 
614
681
  try {
615
- const vault = await getSdk().gitvault.init({ org_id: effectiveOrgId, project_id: provisioned.project_id, repo_dir: dir });
616
- await printCreateResult({ projectId: provisioned.project_id, vault, adopted: false, name });
682
+ const vault = await sdk.gitvault.init({ org_id: effectiveOrgId, project_id: provisioned.project_id, repo_dir: dir });
683
+ await printCreateResult({ sdk, projectId: provisioned.project_id, vault, adopted: false, name, verboseArgv: a });
617
684
  } catch (err) {
618
685
  reportSdkError(err);
619
686
  }
@@ -621,7 +688,7 @@ async function createProvision(name, dir, a) {
621
688
 
622
689
  async function create(args) {
623
690
  const a = normalizeArgv(args);
624
- assertKnownFlags(a, [...CREATE_VALUE_FLAGS, "--help", "-h"], CREATE_VALUE_FLAGS);
691
+ assertKnownFlags(a, [...CREATE_VALUE_FLAGS, "--help", "-h", "-v", "--verbose"], CREATE_VALUE_FLAGS);
625
692
  const positionals = requirePositionalCount(a, CREATE_VALUE_FLAGS, {
626
693
  min: 0, max: 1, command: "run402 repos create [name]", missing: "",
627
694
  });
@@ -655,8 +722,8 @@ async function create(args) {
655
722
  // ─── list ───────────────────────────────────────────────────────────────────
656
723
 
657
724
  /** The FROZEN bulk-read shape (task 2.4) — one round trip. */
658
- async function listViaBulkRead(orgId) {
659
- const result = await getSdk().gitvault.listByOrg(orgId);
725
+ async function listViaBulkRead(sdk, orgId) {
726
+ const result = await sdk.gitvault.listByOrg(orgId);
660
727
  return Array.isArray(result.vaults) ? result.vaults : [];
661
728
  }
662
729
 
@@ -667,14 +734,14 @@ async function listViaBulkRead(orgId) {
667
734
  * function once the bulk route has shipped long enough that no gateway
668
735
  * still 404s it.
669
736
  */
670
- async function listViaFallback(orgId) {
671
- const result = await getSdk().projects.list({ org: orgId });
737
+ async function listViaFallback(sdk, orgId) {
738
+ const result = await sdk.projects.list({ org: orgId });
672
739
  const projects = Array.isArray(result.projects) ? result.projects : [];
673
740
  const repos = [];
674
741
  for (const p of projects) {
675
742
  let status;
676
743
  try {
677
- status = await getSdk().gitvault.status({ project_id: p.id });
744
+ status = await sdk.gitvault.status({ project_id: p.id });
678
745
  } catch {
679
746
  continue;
680
747
  }
@@ -695,21 +762,38 @@ async function listViaFallback(orgId) {
695
762
  return repos;
696
763
  }
697
764
 
765
+ /** `repos list --human`: a compact roster — one line per repo (address, generation, bytes, policy). */
766
+ async function formatRepoListHuman(orgSlug, repos) {
767
+ if (repos.length === 0) return "(no vault-bearing repos in this organization)";
768
+ const { generationToBigInt } = await import("#sdk/node");
769
+ const decimal = (g) => (g ? generationToBigInt(g).toString() : "none");
770
+ const lines = repos.map((r) => {
771
+ const address = orgSlug && r.repo_name ? `run402::${orgSlug}/${r.repo_name}` : (r.repo_name ?? r.project_id);
772
+ return `${address} gen=${decimal(r.newest_generation)} ${r.source_bytes} byte(s) policy=${r.gitvault_policy ?? "(none)"} (${r.repo_id})`;
773
+ });
774
+ return lines.join("\n");
775
+ }
776
+
698
777
  async function list(args) {
699
778
  const a = normalizeArgv(args);
700
- assertKnownFlags(a, ["--org", "--help", "-h"], ["--org"]);
779
+ assertKnownFlags(a, ["--org", "--human", "-v", "--verbose", "--help", "-h"], ["--org"]);
701
780
  requirePositionalCount(a, ["--org"], { min: 0, max: 0, command: "run402 repos list", missing: "" });
781
+ const human = a.includes("--human");
782
+ if (human && a.includes("--json")) {
783
+ fail({ code: "BAD_USAGE", message: "--human cannot be combined with --json.", details: { flags: a.filter((arg) => arg === "--human" || arg === "--json") } });
784
+ }
785
+ const sdk = getSdk();
702
786
  const orgId = await resolveOrgId(a, { cmd: "repos" });
703
787
 
704
788
  let repos;
705
789
  let usedFallback = false;
706
790
  try {
707
- repos = await listViaBulkRead(orgId);
791
+ repos = await listViaBulkRead(sdk, orgId);
708
792
  } catch (err) {
709
793
  if (err?.status === 404) {
710
794
  usedFallback = true;
711
795
  try {
712
- repos = await listViaFallback(orgId);
796
+ repos = await listViaFallback(sdk, orgId);
713
797
  } catch (fallbackErr) {
714
798
  reportSdkError(fallbackErr);
715
799
  return;
@@ -723,42 +807,50 @@ async function list(args) {
723
807
  let orgSlug = repos.find((r) => r.org_slug)?.org_slug ?? null;
724
808
  if (orgSlug == null) {
725
809
  try {
726
- orgSlug = (await getSdk().org(orgId).get()).slug;
810
+ orgSlug = (await sdk.org(orgId).get()).slug;
727
811
  } catch {
728
812
  // best-effort — `list` must not fail over an org-slug lookup
729
813
  }
730
814
  }
731
815
 
732
- console.log(JSON.stringify({ org_id: orgId, org_slug: orgSlug, repos }, null, 2));
816
+ if (human) {
817
+ console.log(await formatRepoListHuman(orgSlug, repos));
818
+ printVerboseStats(a, sdk);
819
+ return;
820
+ }
821
+ printJson(sdk, { org_id: orgId, org_slug: orgSlug, repos });
733
822
  console.error(`${repos.length} vault-bearing project(s) in this organization${usedFallback ? " (per-project fallback read — the bulk vaults-by-org route is not live on this gateway yet)" : ""}`);
734
823
  if (orgSlug) console.error(`org slug: ${orgSlug} — a repo with a claimed address-form name is reachable at run402::${orgSlug}/<name>`);
824
+ printVerboseStats(a, sdk);
735
825
  }
736
826
 
737
827
  // ─── view ───────────────────────────────────────────────────────────────────
738
828
 
739
829
  async function view(args) {
740
830
  const a = normalizeArgv(args);
741
- assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--human", "--help", "-h"], COMMON_VALUE_FLAGS);
831
+ assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--human", "-v", "--verbose", "--help", "-h"], COMMON_VALUE_FLAGS);
742
832
  requirePositionalCount(a, COMMON_VALUE_FLAGS, { min: 0, max: 0, command: "run402 repos view", missing: "" });
743
833
  const human = a.includes("--human");
744
834
  if (human && a.includes("--json")) {
745
835
  fail({ code: "BAD_USAGE", message: "--human cannot be combined with --json.", details: { flags: a.filter((arg) => arg === "--human" || arg === "--json") } });
746
836
  }
837
+ const sdk = getSdk();
747
838
  const target = await vaultTarget(a);
748
839
  try {
749
840
  // Design D3: `view` NEVER passes `refs: true` — it is side-effect-free
750
841
  // by construction, not by convention. Materialization belongs to `fsck`.
751
- const s = await getSdk().gitvault.status(target);
842
+ const s = await sdk.gitvault.status(target);
752
843
  let mirror = null;
753
844
  if (s.repo_id) {
754
845
  try {
755
- mirror = await getSdk().gitvault.mirrorStatus({ ...target, repo_id: s.repo_id });
846
+ mirror = await sdk.gitvault.mirrorStatus({ ...target, repo_id: s.repo_id });
756
847
  } catch {
757
848
  // best-effort — a mirror read failure never fails `view`
758
849
  }
759
850
  }
760
851
  if (human) {
761
852
  console.log(await formatRepoHuman(s, mirror));
853
+ printVerboseStats(a, sdk);
762
854
  return;
763
855
  }
764
856
  const verifyRefsAction = nextAction("verify_refs", { command: "run402 repos fsck", why: "Walk the signed chain and materialize verified refs." });
@@ -769,7 +861,7 @@ async function view(args) {
769
861
  mirror,
770
862
  next_actions: combinedNextActions,
771
863
  };
772
- console.log(JSON.stringify(out, null, 2));
864
+ printJson(sdk, out);
773
865
  printTerminalLoss(s);
774
866
  if (s.remote) {
775
867
  const suffix =
@@ -787,6 +879,7 @@ async function view(args) {
787
879
  }
788
880
  for (const w of s.warnings) console.error(`warning (${w.kind}): ${w.message}`);
789
881
  for (const n of combinedNextActions) console.error(`next: ${n.why ?? n.action ?? n.type}${n.command ? ` — ${n.command}` : ""}`);
882
+ printVerboseStats(a, sdk);
790
883
  } catch (err) {
791
884
  reportSdkError(err);
792
885
  }
@@ -796,11 +889,12 @@ async function view(args) {
796
889
 
797
890
  async function rename(args) {
798
891
  const a = normalizeArgv(args);
799
- assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--help", "-h"], COMMON_VALUE_FLAGS);
892
+ assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "-v", "--verbose", "--help", "-h"], COMMON_VALUE_FLAGS);
800
893
  const [repoName] = requirePositionalCount(a, COMMON_VALUE_FLAGS, {
801
894
  min: 1, max: 1, command: "run402 repos rename <new_name> [--repo <repo_id> | --project <project_id>]",
802
895
  missing: "run402 repos rename <new_name>: a new name is required",
803
896
  });
897
+ const sdk = getSdk();
804
898
  const repoFlag = flagValue(a, "--repo");
805
899
  const projectFlag = flagValue(a, "--project");
806
900
  if (repoFlag != null && projectFlag != null) {
@@ -809,7 +903,7 @@ async function rename(args) {
809
903
  let projectId;
810
904
  if (repoFlag != null) {
811
905
  try {
812
- projectId = (await getSdk().gitvault.get(repoFlag)).project_id;
906
+ projectId = (await sdk.gitvault.get(repoFlag)).project_id;
813
907
  } catch (err) {
814
908
  reportSdkError(err);
815
909
  return;
@@ -818,16 +912,16 @@ async function rename(args) {
818
912
  projectId = resolveProjectId(projectFlag);
819
913
  }
820
914
  try {
821
- const result = await getSdk().projects.setRepoName(projectId, repoName);
915
+ const result = await sdk.projects.setRepoName(projectId, repoName);
822
916
  let address = null;
823
917
  try {
824
918
  const owningOrg = await resolveOwningOrgId(projectId);
825
- const orgSlug = owningOrg ? (await getSdk().org(owningOrg).get()).slug : null;
919
+ const orgSlug = owningOrg ? (await sdk.org(owningOrg).get()).slug : null;
826
920
  if (orgSlug) address = gitvaultRemoteUrlForRepo(orgSlug, result.repo_name);
827
921
  } catch {
828
922
  // The claim itself already succeeded — a failed address-preview lookup is never fatal.
829
923
  }
830
- console.log(JSON.stringify({ ...result, address }, null, 2));
924
+ printJson(sdk, { ...result, address });
831
925
  console.error(
832
926
  result.previous_repo_name && result.previous_repo_name !== result.repo_name
833
927
  ? `renamed from "${result.previous_repo_name}" to "${result.repo_name}"`
@@ -835,6 +929,7 @@ async function rename(args) {
835
929
  );
836
930
  if (address) console.error(`address: ${address}`);
837
931
  else console.error("this org has no slug yet — claim one with `run402 org slug <slug>` to get a full run402::<slug>/<name> address");
932
+ printVerboseStats(a, sdk);
838
933
  } catch (err) {
839
934
  reportSdkError(err);
840
935
  }
@@ -863,22 +958,22 @@ async function checkResource(read, resourceName, countOf) {
863
958
  * that fails for a reason OTHER than "genuinely absent" (404) is reported
864
959
  * `unknown` and REFUSES delete too — D9 never guesses its way to yes.
865
960
  */
866
- async function checkNonRepoResources(projectId) {
961
+ async function checkNonRepoResources(sdk, projectId) {
867
962
  const refused = [];
868
963
  try {
869
- const detail = await getSdk().projects.get(projectId);
964
+ const detail = await sdk.projects.get(projectId);
870
965
  if (Array.isArray(detail.mailbox) && detail.mailbox.length > 0) refused.push({ resource: "mailbox", status: "present", count: detail.mailbox.length });
871
966
  if (Array.isArray(detail.custom_domains) && detail.custom_domains.length > 0) refused.push({ resource: "custom_domains", status: "present", count: detail.custom_domains.length });
872
967
  } catch (err) {
873
968
  refused.push({ resource: "project_detail", status: "unknown", reason: err?.message ?? String(err) });
874
969
  }
875
- const schema = await checkResource(() => getSdk().projects.getSchema(projectId), "database_schema", (s) => (Array.isArray(s?.tables) ? s.tables.length : 0));
970
+ const schema = await checkResource(() => sdk.projects.getSchema(projectId), "database_schema", (s) => (Array.isArray(s?.tables) ? s.tables.length : 0));
876
971
  if (schema) refused.push(schema);
877
- const functions = await checkResource(() => getSdk().functions.list(projectId), "functions", (r) => (Array.isArray(r?.functions) ? r.functions.length : 0));
972
+ const functions = await checkResource(() => sdk.functions.list(projectId), "functions", (r) => (Array.isArray(r?.functions) ? r.functions.length : 0));
878
973
  if (functions) refused.push(functions);
879
- const secrets = await checkResource(() => getSdk().secrets.list(projectId), "secrets", (r) => (Array.isArray(r?.secrets) ? r.secrets.length : 0));
974
+ const secrets = await checkResource(() => sdk.secrets.list(projectId), "secrets", (r) => (Array.isArray(r?.secrets) ? r.secrets.length : 0));
880
975
  if (secrets) refused.push(secrets);
881
- const subdomains = await checkResource(() => getSdk().subdomains.list(projectId), "subdomains", (r) => (Array.isArray(r) ? r.length : 0));
976
+ const subdomains = await checkResource(() => sdk.subdomains.list(projectId), "subdomains", (r) => (Array.isArray(r) ? r.length : 0));
882
977
  if (subdomains) refused.push(subdomains);
883
978
  return refused;
884
979
  }
@@ -893,7 +988,8 @@ function stripFlag(args, flag) {
893
988
 
894
989
  async function del(args) {
895
990
  const a = normalizeArgv(args);
896
- assertKnownFlags(a, ["--project", "--repo", "--force", "--help", "-h"], ["--project", "--repo"]);
991
+ assertKnownFlags(a, ["--project", "--repo", "--force", "-v", "--verbose", "--help", "-h"], ["--project", "--repo"]);
992
+ const sdk = getSdk();
897
993
  const repoFlag = flagValue(a, "--repo");
898
994
  let projectId;
899
995
  let rest;
@@ -902,7 +998,7 @@ async function del(args) {
902
998
  fail({ code: "BAD_USAGE", message: "pass --repo or --project, not both." });
903
999
  }
904
1000
  try {
905
- projectId = (await getSdk().gitvault.get(repoFlag)).project_id;
1001
+ projectId = (await sdk.gitvault.get(repoFlag)).project_id;
906
1002
  } catch (err) {
907
1003
  reportSdkError(err);
908
1004
  return;
@@ -916,7 +1012,7 @@ async function del(args) {
916
1012
 
917
1013
  let status;
918
1014
  try {
919
- status = await getSdk().gitvault.status({ project_id: projectId });
1015
+ status = await sdk.gitvault.status({ project_id: projectId });
920
1016
  } catch (err) {
921
1017
  reportSdkError(err);
922
1018
  return;
@@ -927,7 +1023,7 @@ async function del(args) {
927
1023
 
928
1024
  // D9, checked FIRST and unconditionally: --force below overrides only the
929
1025
  // vault-history confirmation, never this refusal.
930
- const refusedResources = await checkNonRepoResources(projectId);
1026
+ const refusedResources = await checkNonRepoResources(sdk, projectId);
931
1027
  if (refusedResources.length > 0) {
932
1028
  fail({
933
1029
  code: "PROJECT_HAS_NON_REPO_RESOURCES",
@@ -957,13 +1053,14 @@ async function del(args) {
957
1053
  }
958
1054
 
959
1055
  try {
960
- await getSdk().projects.delete(projectId);
961
- console.log(JSON.stringify({
1056
+ await sdk.projects.delete(projectId);
1057
+ printJson(sdk, {
962
1058
  project_id: projectId,
963
1059
  deleted: true,
964
1060
  deleted_resources: ["project", ...(vault ? ["vault_history"] : [])],
965
1061
  vault: vault ? { repo_id: status.repo_id, admitted_generations: admittedGenerations, source_bytes: sourceBytes } : null,
966
- }, null, 2));
1062
+ });
1063
+ printVerboseStats(a, sdk);
967
1064
  } catch (err) {
968
1065
  reportSdkError(err);
969
1066
  }
@@ -998,12 +1095,26 @@ async function detectSlugFormRemote(a, repoDir) {
998
1095
  return null;
999
1096
  }
1000
1097
 
1098
+ /**
1099
+ * Dirty-tree disclosure (help people not make mistakes): even an explicit
1100
+ * `--allow-dirty` override never captures silently — every modified/staged
1101
+ * tracked path and every untracked-not-ignored path that got swept into the
1102
+ * capture is named on stderr, one per line.
1103
+ */
1104
+ function printDirtyDisclosure(snapshot) {
1105
+ if (!snapshot) return;
1106
+ for (const p of snapshot.modified_captured ?? []) console.error(`captured (modified): ${p}`);
1107
+ for (const p of snapshot.untracked_captured ?? []) console.error(`captured (untracked): ${p}`);
1108
+ }
1109
+
1001
1110
  async function snapshot(args) {
1002
1111
  const a = normalizeArgv(args);
1003
- assertKnownFlags(a, [...SNAPSHOT_VALUE_FLAGS, "--checkpoint", "--dry-run", "--help", "-h"], SNAPSHOT_VALUE_FLAGS);
1112
+ assertKnownFlags(a, [...SNAPSHOT_VALUE_FLAGS, "--checkpoint", "--dry-run", "--allow-dirty", "-v", "--verbose", "--help", "-h"], SNAPSHOT_VALUE_FLAGS);
1004
1113
  requirePositionalCount(a, SNAPSHOT_VALUE_FLAGS, { min: 0, max: 0, command: "run402 repos snapshot", missing: "" });
1114
+ const sdk = getSdk();
1005
1115
  const dryRun = a.includes("--dry-run");
1006
1116
  const message = flagValue(a, "--message");
1117
+ const allowDirty = a.includes("--allow-dirty");
1007
1118
  const repoDir = process.cwd();
1008
1119
  const address = await detectSlugFormRemote(a, repoDir);
1009
1120
  const target = address ? { repo_dir: repoDir } : await vaultTarget(a);
@@ -1021,12 +1132,15 @@ async function snapshot(args) {
1021
1132
  console.error("");
1022
1133
  },
1023
1134
  };
1024
- if (message != null) opts.snapshot = { message };
1135
+ const snapshotOpts = {};
1136
+ if (message != null) snapshotOpts.message = message;
1137
+ if (allowDirty) snapshotOpts.allowDirty = true;
1138
+ if (Object.keys(snapshotOpts).length > 0) opts.snapshot = snapshotOpts;
1025
1139
  if (a.includes("--checkpoint")) opts.checkpoint = true;
1026
1140
  try {
1027
1141
  if (dryRun) {
1028
- const plan = await getSdk().gitvault.planPush(opts);
1029
- console.log(JSON.stringify(plan, null, 2));
1142
+ const plan = await sdk.gitvault.planPush(opts);
1143
+ printJson(sdk, plan);
1030
1144
  if (plan.allocation_needed) {
1031
1145
  console.error("dry-run: no repo allocated for this project yet — a real snapshot would allocate one first; object/byte sizing is not knowable until then");
1032
1146
  } else {
@@ -1035,16 +1149,20 @@ async function snapshot(args) {
1035
1149
  `${plan.object_count} object(s), ${plan.encrypted_bytes} encrypted byte(s) (${plan.raw_bytes} raw)`,
1036
1150
  );
1037
1151
  }
1152
+ printDirtyDisclosure(plan.snapshot);
1153
+ printVerboseStats(a, sdk);
1038
1154
  return;
1039
1155
  }
1040
- const result = await getSdk().gitvault.push(opts);
1041
- console.log(JSON.stringify(result, null, 2));
1156
+ const result = await sdk.gitvault.push(opts);
1157
+ printJson(sdk, result);
1042
1158
  console.error(`published generation ${result.generation} (${result.form})`);
1043
1159
  if (result.mirror_push?.outcome === "pushed") {
1044
1160
  console.error(`mirror: pushed generation ${result.generation} (${result.mirror_push.summary?.objects_copied ?? 0} object(s) copied)`);
1045
1161
  } else if (result.mirror_push?.outcome === "failed") {
1046
1162
  console.error(`mirror: dual-push FAILED (deploy is unaffected) — ${result.mirror_push.error ?? "see mirror_push.summary.errors"}`);
1047
1163
  }
1164
+ printDirtyDisclosure(result.snapshot);
1165
+ printVerboseStats(a, sdk);
1048
1166
  } catch (err) {
1049
1167
  reportSdkError(err);
1050
1168
  }
@@ -1055,7 +1173,7 @@ async function snapshot(args) {
1055
1173
  async function policy(args) {
1056
1174
  const a = normalizeArgv(args);
1057
1175
  const valueFlags = [...COMMON_VALUE_FLAGS, "--reason"];
1058
- assertKnownFlags(a, [...valueFlags, "--help", "-h"], valueFlags);
1176
+ assertKnownFlags(a, [...valueFlags, "-v", "--verbose", "--help", "-h"], valueFlags);
1059
1177
  const [requested] = requirePositionalCount(a, valueFlags, {
1060
1178
  min: 1, max: 1, command: "run402 repos policy <required|grandfathered>",
1061
1179
  missing: "Missing <policy>. Expected `required` or `grandfathered`.",
@@ -1083,13 +1201,14 @@ async function policy(args) {
1083
1201
  const sdk = getSdk();
1084
1202
  const repoId = target.repo_id ?? (await sdk.gitvault.forProject(target.project_id)).repo_id;
1085
1203
  const result = await sdk.gitvault.setPolicy(repoId, { gitvault_policy: requested, ...(reason != null ? { reason } : {}) });
1086
- console.log(JSON.stringify({ repo_id: repoId, ...result }, null, 2));
1204
+ printJson(sdk, { repo_id: repoId, ...result });
1087
1205
  console.error(
1088
1206
  result.changed
1089
1207
  ? `gitvault_policy is now ${result.gitvault_policy} (version ${result.gitvault_policy_version})`
1090
1208
  : `gitvault_policy was already ${result.gitvault_policy} — nothing changed`,
1091
1209
  );
1092
1210
  for (const w of result.warnings ?? []) console.error(`warning (${w.kind}): ${w.message}`);
1211
+ printVerboseStats(a, sdk);
1093
1212
  } catch (err) {
1094
1213
  reportSdkError(err);
1095
1214
  }
@@ -1099,10 +1218,11 @@ async function policy(args) {
1099
1218
 
1100
1219
  const MIRROR_VALUE_FLAGS = [...COMMON_VALUE_FLAGS, "--profile", "--region", "--endpoint"];
1101
1220
 
1102
- async function mirrorRead(target) {
1221
+ async function mirrorRead(target, a) {
1222
+ const sdk = getSdk();
1103
1223
  try {
1104
- const result = await getSdk().gitvault.mirrorStatus(target);
1105
- console.log(JSON.stringify(result, null, 2));
1224
+ const result = await sdk.gitvault.mirrorStatus(target);
1225
+ printJson(sdk, result);
1106
1226
  if (!result.configured) {
1107
1227
  console.error(`no mirror configured for ${result.repo_id}. Configure one: run402 repos mirror <destination>`);
1108
1228
  } else {
@@ -1110,49 +1230,55 @@ async function mirrorRead(target) {
1110
1230
  console.error(`mirror ${result.destination}: mirrored generation ${result.mirrored_generation ?? "(none)"}, vault newest ${result.newest_generation ?? "(none)"} — ${currency}`);
1111
1231
  }
1112
1232
  printMirrorHonesty(result);
1233
+ printVerboseStats(a, sdk);
1113
1234
  } catch (err) {
1114
1235
  reportSdkError(err);
1115
1236
  }
1116
1237
  }
1117
1238
 
1118
1239
  async function mirrorSet(target, destination, a) {
1240
+ const sdk = getSdk();
1119
1241
  const credential = resolveMirrorCredential(a);
1120
1242
  const region = flagValue(a, "--region");
1121
1243
  const endpoint = flagValue(a, "--endpoint");
1122
1244
  try {
1123
- const result = await getSdk().gitvault.mirrorSet({
1245
+ const result = await sdk.gitvault.mirrorSet({
1124
1246
  ...target,
1125
1247
  destination_url: destination,
1126
1248
  ...(credential ? { credential } : {}),
1127
1249
  ...(region != null ? { region } : {}),
1128
1250
  ...(endpoint != null ? { endpoint } : {}),
1129
1251
  });
1130
- console.log(JSON.stringify(result, null, 2));
1252
+ printJson(sdk, result);
1131
1253
  console.error(`mirror configured for ${result.repo_id} -> ${formatMirrorDestination(result.destination)}`);
1132
1254
  console.error("run `run402 repos mirror --backfill` to catch it up now, then every publish dual-pushes automatically.");
1255
+ printVerboseStats(a, sdk);
1133
1256
  } catch (err) {
1134
1257
  reportSdkError(err);
1135
1258
  }
1136
1259
  }
1137
1260
 
1138
- async function mirrorOff(target) {
1261
+ async function mirrorOff(target, a) {
1262
+ const sdk = getSdk();
1139
1263
  try {
1140
- const result = await getSdk().gitvault.mirrorRemove(target);
1141
- console.log(JSON.stringify(result, null, 2));
1264
+ const result = await sdk.gitvault.mirrorRemove(target);
1265
+ printJson(sdk, result);
1142
1266
  console.error(
1143
1267
  result.removed
1144
1268
  ? `mirror config removed for ${result.repo_id} — the mirror's OWN bytes were not touched`
1145
1269
  : `no mirror was configured for ${result.repo_id} — nothing to remove`,
1146
1270
  );
1271
+ printVerboseStats(a, sdk);
1147
1272
  } catch (err) {
1148
1273
  reportSdkError(err);
1149
1274
  }
1150
1275
  }
1151
1276
 
1152
- async function mirrorBackfill(target) {
1277
+ async function mirrorBackfill(target, a) {
1278
+ const sdk = getSdk();
1153
1279
  try {
1154
- const result = await getSdk().gitvault.mirrorSync(target);
1155
- console.log(JSON.stringify(result, null, 2));
1280
+ const result = await sdk.gitvault.mirrorSync(target);
1281
+ printJson(sdk, result);
1156
1282
  await spillIfLarge(result.repo_id, "mirror-backfill", result);
1157
1283
  console.error(
1158
1284
  `mirror backfill for ${result.repo_id}: ${result.objects_copied} copied, ${result.objects_already_present} already present` +
@@ -1161,6 +1287,7 @@ async function mirrorBackfill(target) {
1161
1287
  );
1162
1288
  for (const e of result.errors) console.error(` failed: ${e.key} — ${e.error}`);
1163
1289
  printMirrorHonesty(result);
1290
+ printVerboseStats(a, sdk);
1164
1291
  } catch (err) {
1165
1292
  reportSdkError(err);
1166
1293
  }
@@ -1168,7 +1295,7 @@ async function mirrorBackfill(target) {
1168
1295
 
1169
1296
  async function mirror(args) {
1170
1297
  const a = normalizeArgv(args);
1171
- assertKnownFlags(a, [...MIRROR_VALUE_FLAGS, "--off", "--backfill", "--ambient", "--help", "-h"], MIRROR_VALUE_FLAGS);
1298
+ assertKnownFlags(a, [...MIRROR_VALUE_FLAGS, "--off", "--backfill", "--ambient", "-v", "--verbose", "--help", "-h"], MIRROR_VALUE_FLAGS);
1172
1299
  const positionals = requirePositionalCount(a, MIRROR_VALUE_FLAGS, {
1173
1300
  min: 0, max: 1, command: "run402 repos mirror [<destination>]", missing: "",
1174
1301
  });
@@ -1185,26 +1312,57 @@ async function mirror(args) {
1185
1312
  }
1186
1313
  const target = await vaultTarget(a);
1187
1314
  if (destination != null) return mirrorSet(target, destination, a);
1188
- if (off) return mirrorOff(target);
1189
- if (backfill) return mirrorBackfill(target);
1190
- return mirrorRead(target);
1315
+ if (off) return mirrorOff(target, a);
1316
+ if (backfill) return mirrorBackfill(target, a);
1317
+ return mirrorRead(target, a);
1191
1318
  }
1192
1319
 
1193
1320
  // ─── fsck (verify the head chain + materialize refs) ──────────────────
1194
1321
 
1322
+ /** `repos fsck --human`: the same verdict the stderr lines already carry, condensed into one block. */
1323
+ function formatFsckHuman(result, mirrorRequested) {
1324
+ const lines = [`Repo: ${result.repo_id}`];
1325
+ lines.push(
1326
+ !result.write
1327
+ ? `Verified through generation ${result.verified_to_generation} — audit mode, nothing local was persisted.`
1328
+ : result.local_state_changed
1329
+ ? `Verified through generation ${result.verified_to_generation} — local pin advanced from ${result.pin_before.highest_authenticated ?? "genesis"} to ${result.pin_after.highest_authenticated}.`
1330
+ : `Verified through generation ${result.verified_to_generation} — already at the newest verified generation.`,
1331
+ );
1332
+ if (mirrorRequested && result.mirror) {
1333
+ lines.push(
1334
+ `Mirror: recoverable generation ${result.mirror.recovered_generation}` +
1335
+ (result.mirror.chain_break ? ` (chain break at ${result.mirror.chain_break.generation}: ${result.mirror.chain_break.reason})` : "") +
1336
+ (result.mirror.data_loss_detected ? ` — DATA LOSS DETECTED (${result.mirror.absences.filter((x) => x.adjudication === "unexplained_absence").length} unexplained absence(s))` : ""),
1337
+ );
1338
+ }
1339
+ return lines.join("\n");
1340
+ }
1341
+
1195
1342
  async function fsck(args) {
1196
1343
  const a = normalizeArgv(args);
1197
1344
  const valueFlags = [...COMMON_VALUE_FLAGS, "--budget"];
1198
- assertKnownFlags(a, [...valueFlags, "--mirror", "--no-write", "--help", "-h"], valueFlags);
1345
+ assertKnownFlags(a, [...valueFlags, "--mirror", "--no-write", "--human", "-v", "--verbose", "--help", "-h"], valueFlags);
1199
1346
  requirePositionalCount(a, valueFlags, { min: 0, max: 0, command: "run402 repos fsck", missing: "" });
1347
+ const human = a.includes("--human");
1348
+ if (human && a.includes("--json")) {
1349
+ fail({ code: "BAD_USAGE", message: "--human cannot be combined with --json.", details: { flags: a.filter((arg) => arg === "--human" || arg === "--json") } });
1350
+ }
1351
+ const sdk = getSdk();
1200
1352
  const target = await vaultTarget(a);
1201
1353
  const budget = flagValue(a, "--budget");
1202
1354
  if (budget != null) target.verification_budget = parseIntegerFlag("--budget", budget, { min: 1 });
1203
1355
  const write = !a.includes("--no-write");
1204
1356
  const mirrorRequested = a.includes("--mirror");
1205
1357
  try {
1206
- const result = await getSdk().gitvault.fsck({ ...target, write, mirror: mirrorRequested });
1207
- console.log(JSON.stringify(result, null, 2));
1358
+ const result = await sdk.gitvault.fsck({ ...target, write, mirror: mirrorRequested });
1359
+ if (human) {
1360
+ console.log(formatFsckHuman(result, mirrorRequested));
1361
+ if (mirrorRequested && result.mirror) printMirrorHonesty(result.mirror);
1362
+ printVerboseStats(a, sdk);
1363
+ return;
1364
+ }
1365
+ printJson(sdk, result);
1208
1366
  await spillIfLarge(result.repo_id, "fsck", result);
1209
1367
  if (!write) {
1210
1368
  console.error(`--no-write: verified through generation ${result.verified_to_generation} — nothing local was persisted (pin_before === pin_after).`);
@@ -1220,6 +1378,7 @@ async function fsck(args) {
1220
1378
  }
1221
1379
  printMirrorHonesty(result.mirror);
1222
1380
  }
1381
+ printVerboseStats(a, sdk);
1223
1382
  } catch (err) {
1224
1383
  reportSdkError(err);
1225
1384
  }
@@ -1231,8 +1390,9 @@ const GC_VALUE_FLAGS = [...COMMON_VALUE_FLAGS, "--intent-core", "--verifier-rece
1231
1390
 
1232
1391
  async function gc(args) {
1233
1392
  const a = normalizeArgv(args);
1234
- assertKnownFlags(a, [...GC_VALUE_FLAGS, "--submit", "--wait", "--help", "-h"], GC_VALUE_FLAGS);
1393
+ assertKnownFlags(a, [...GC_VALUE_FLAGS, "--submit", "--wait", "-v", "--verbose", "--help", "-h"], GC_VALUE_FLAGS);
1235
1394
  requirePositionalCount(a, GC_VALUE_FLAGS, { min: 0, max: 0, command: "run402 repos gc", missing: "" });
1395
+ const sdk = getSdk();
1236
1396
  const submitting = a.includes("--submit");
1237
1397
  const corePath = flagValue(a, "--intent-core");
1238
1398
  const receiptPath = flagValue(a, "--verifier-receipt");
@@ -1252,9 +1412,9 @@ async function gc(args) {
1252
1412
  if (submitting) {
1253
1413
  const opts = { ...target, submit: { core: readJsonFile("--intent-core", corePath), verifier_receipt: readJsonFile("--verifier-receipt", receiptPath) } };
1254
1414
  if (a.includes("--wait")) opts.submit.wait = {};
1255
- const prune = await getSdk().gitvault.prune(opts);
1415
+ const prune = await sdk.gitvault.prune(opts);
1256
1416
  const out = { phase: "submitted", prune };
1257
- console.log(JSON.stringify(out, null, 2));
1417
+ printJson(sdk, out);
1258
1418
  if (prune.confirmation?.outcome) {
1259
1419
  console.error(
1260
1420
  `submitted — the signed completion reports ${prune.confirmation.deleted.length} deleted, ` +
@@ -1265,11 +1425,12 @@ async function gc(args) {
1265
1425
  console.error("submitted — no completion yet. Nothing is deleted until the control-plane-signed completion says so; re-run with --wait or poll the intent.");
1266
1426
  }
1267
1427
  console.error(prune.note);
1428
+ printVerboseStats(a, sdk);
1268
1429
  return;
1269
1430
  }
1270
1431
 
1271
- const checkpoint = await getSdk().gitvault.compact(target);
1272
- const prune = await getSdk().gitvault.prune(target);
1432
+ const checkpoint = await sdk.gitvault.compact(target);
1433
+ const prune = await sdk.gitvault.prune(target);
1273
1434
  const nextActions = [];
1274
1435
  if (!prune.blocked_reason && prune.object_candidates.length > 0) {
1275
1436
  // Additive fields beyond the CLI's usual {type, command, why}: the
@@ -1285,7 +1446,7 @@ async function gc(args) {
1285
1446
  });
1286
1447
  }
1287
1448
  const out = { phase: "planned", checkpoint, prune, next_actions: nextActions };
1288
- console.log(JSON.stringify(out, null, 2));
1449
+ printJson(sdk, out);
1289
1450
  console.error(`checkpoint published at generation ${checkpoint.generation}: ${checkpoint.covered_refs} ref(s), ${checkpoint.covered_roots} retention root(s).`);
1290
1451
  if (!checkpoint.cutoff_bound) {
1291
1452
  console.error("no retention-cutoff ticket was obtained, so roots were RETAINED — expiry is permissive. The checkpoint published, but no expired root left the map.");
@@ -1303,6 +1464,7 @@ async function gc(args) {
1303
1464
  }
1304
1465
  }
1305
1466
  console.error("`gc` is NOT \"exactly git gc\" — the deletion ceremony is stricter: nothing is removed until a control-plane-signed completion confirms it.");
1467
+ printVerboseStats(a, sdk);
1306
1468
  } catch (err) {
1307
1469
  reportSdkError(err);
1308
1470
  }
@@ -1310,14 +1472,40 @@ async function gc(args) {
1310
1472
 
1311
1473
  // ─── access (read-only; repair gated) ──────────────────────
1312
1474
 
1475
+ /** `repos access --human`: a compact roster of directory recipients and their coverage. */
1476
+ function formatAccessHuman(result) {
1477
+ const lines = [`Repo: ${result.repo_id}`];
1478
+ lines.push(`Recipients: ${result.recipients.length} directory, ${result.recipients.filter((r) => r.covered).length} covered`);
1479
+ for (const r of result.recipients) {
1480
+ lines.push(` ${r.covered ? "covered" : "NOT covered"} ${r.display_name ?? r.principal_id}${r.envelope_state ? ` (${r.envelope_state})` : ""}`);
1481
+ }
1482
+ if (result.this_keystore) lines.push(`This machine's own keystore also covers (writing principal, not in org directory): ${result.this_keystore.fingerprint}`);
1483
+ if (result.unmatched_covered_fingerprints.length > 0) lines.push(`Orphaned/external coverage: ${result.unmatched_covered_fingerprints.join(", ")}`);
1484
+ if (Array.isArray(result.stale_access) && result.stale_access.length > 0) {
1485
+ lines.push(`Stale access (removed members that still decrypt): ${result.stale_access.map((s) => s.display_name ?? s.principal_id).join(", ")}`);
1486
+ }
1487
+ lines.push(result.gap);
1488
+ return lines.join("\n");
1489
+ }
1490
+
1313
1491
  async function accessRead(args) {
1314
1492
  const a = normalizeArgv(args);
1315
- assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--help", "-h"], COMMON_VALUE_FLAGS);
1493
+ assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--human", "-v", "--verbose", "--help", "-h"], COMMON_VALUE_FLAGS);
1316
1494
  requirePositionalCount(a, COMMON_VALUE_FLAGS, { min: 0, max: 0, command: "run402 repos access", missing: "" });
1495
+ const human = a.includes("--human");
1496
+ if (human && a.includes("--json")) {
1497
+ fail({ code: "BAD_USAGE", message: "--human cannot be combined with --json.", details: { flags: a.filter((arg) => arg === "--human" || arg === "--json") } });
1498
+ }
1499
+ const sdk = getSdk();
1317
1500
  const target = await vaultTarget(a);
1318
1501
  try {
1319
- const result = await getSdk().gitvault.access(target);
1320
- console.log(JSON.stringify(result, null, 2));
1502
+ const result = await sdk.gitvault.access(target);
1503
+ if (human) {
1504
+ console.log(formatAccessHuman(result));
1505
+ printVerboseStats(a, sdk);
1506
+ return;
1507
+ }
1508
+ printJson(sdk, result);
1321
1509
  await spillIfLarge(result.repo_id, "access", result);
1322
1510
  console.error(`${result.recipients.length} directory recipient(s), ${result.recipients.filter((r) => r.covered).length} covered on this repo.`);
1323
1511
  if (result.this_keystore) {
@@ -1331,35 +1519,183 @@ async function accessRead(args) {
1331
1519
  console.error(`${result.stale_access.length} removed member(s) STILL decrypt this vault (not yet revocable — no epoch rotation in v0): ${names}`);
1332
1520
  }
1333
1521
  console.error(result.gap);
1522
+ printVerboseStats(a, sdk);
1334
1523
  } catch (err) {
1335
1524
  reportSdkError(err);
1336
1525
  }
1337
1526
  }
1338
1527
 
1528
+ const ROTATION_VALUE_FLAGS = [...COMMON_VALUE_FLAGS, "--recipient-state-version", "--recipient-revocation-version", "--idempotency-key"];
1529
+
1530
+ /**
1531
+ * `run402 repos access repair` (D193-D203, rev 42) — a general re-key of
1532
+ * this vault's CURRENT epoch, dropping every principal in `stale_access`
1533
+ * (`pending_removal`, still covered) and clearing a pre-existing vault's
1534
+ * one-time migration requirement. Drives `rotateEpoch({reason:"elective_rekey"})`.
1535
+ *
1536
+ * `--recipient-state-version`/`--recipient-revocation-version` are the D194
1537
+ * frozen watermarks this attempt must be fenced against. They are NOT
1538
+ * discovered automatically here: the live gateway exposes NO general read
1539
+ * route for `internal.gitvault_recipient_state_counters` outside the
1540
+ * `key-revocation` declare route's own response (see
1541
+ * `GitvaultVault.rotateEpoch`'s doc comment, `sdk/src/node/gitvault-
1542
+ * publication.ts`, for the confirmed source-level finding). Until that
1543
+ * route ships, this verb needs the pair supplied explicitly — refusing
1544
+ * cleanly and naming exactly this when they are omitted, rather than
1545
+ * guessing and either failing opaquely or (worse) never converging.
1546
+ *
1547
+ * **`elective_rekey` refuses ANY exclusion** (`EPOCH_ROTATION_INCOMPLETE_ENROLLMENT`
1548
+ * on even one keyless/unconfirmed desired principal) — so a pending
1549
+ * `/confirm`/`/repin` receipt does NOT help here: folding it into THIS
1550
+ * rotation's `pending_confirmations` still leaves that principal
1551
+ * `excluded_unconfirmed` for THIS rotation (D196 — same-head manifest
1552
+ * updates never self-authorize), which `elective_rekey`'s own
1553
+ * completeness check then refuses on. If a directory principal is
1554
+ * unconfirmed when this vault needs to clear its migration requirement,
1555
+ * use `run402 repos access revoke-key`/`declare-exposure` instead (an
1556
+ * urgent reason, which admits with a nonempty partial target set) and
1557
+ * fold the pending receipt into THAT rotation.
1558
+ */
1339
1559
  async function accessRepair(args) {
1340
1560
  const a = normalizeArgv(args);
1341
- assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--help", "-h"], COMMON_VALUE_FLAGS);
1342
- requirePositionalCount(a, COMMON_VALUE_FLAGS, { min: 0, max: 0, command: "run402 repos access repair", missing: "" });
1343
- fail({
1344
- code: "ACCESS_REPAIR_NOT_AVAILABLE",
1345
- message: "`run402 repos access repair` is not available yet — it is gated on gitvault-human-envelopes' real epoch-rotation work landing.",
1346
- hint: "Use `run402 repos access` to see what the read surface reports today. Repair is a NAMED, deliberate action for genuine drift once the mechanism ships — never a routine workaround (the `reconcile` verb it replaces was removed for exactly that reason).",
1347
- next_actions: [nextAction("access_repair_pending", { command: "run402 repos access", why: "See recipients, coverage, and this machine's own TOFU pins today; repair lands once epoch rotation ships." })],
1561
+ assertKnownFlags(a, [...ROTATION_VALUE_FLAGS, "-v", "--verbose", "--help", "-h"], ROTATION_VALUE_FLAGS);
1562
+ requirePositionalCount(a, ROTATION_VALUE_FLAGS, { min: 0, max: 0, command: "run402 repos access repair", missing: "" });
1563
+ const recipientStateVersion = flagValue(a, "--recipient-state-version");
1564
+ const recipientRevocationVersion = flagValue(a, "--recipient-revocation-version");
1565
+ if (recipientStateVersion == null || recipientRevocationVersion == null) {
1566
+ fail({
1567
+ code: "ROTATION_COUNTERS_REQUIRED",
1568
+ message: "`run402 repos access repair` needs --recipient-state-version and --recipient-revocation-version — the gateway does not yet expose a read route for these two counters outside the key-revocation declare route.",
1569
+ hint: "If you know a specific principal whose key should be revoked, use `run402 repos access revoke-key <principal_id>` instead — it is fully self-contained (no flags needed). `access repair` is the general re-key for clearing stale_access / a first-ever migration and needs these two values from platform staff or direct DB access until a gateway read route ships.",
1570
+ next_actions: [nextAction("edit_request", { command: "run402 repos access revoke-key <principal_id>", why: "the ONE fully self-contained rotation entry point today — no counters needed" })],
1571
+ });
1572
+ }
1573
+ const sdk = getSdk();
1574
+ const target = await vaultTarget(a);
1575
+ try {
1576
+ const result = await sdk.gitvault.rotateEpoch({
1577
+ ...target,
1578
+ reason: "elective_rekey",
1579
+ recipient_state_version: recipientStateVersion,
1580
+ recipient_revocation_version: recipientRevocationVersion,
1581
+ ...(flagValue(a, "--idempotency-key") != null ? { client_idempotency_key: flagValue(a, "--idempotency-key") } : {}),
1582
+ });
1583
+ printJson(sdk, result);
1584
+ await spillIfLarge(result.rotation_id, "access-repair", result);
1585
+ console.error(`rotated to epoch ${result.new_epoch} at generation ${result.generation}: ${result.included.length} recipient(s) included, ${result.excluded_keyless_principal_ids.length} keyless, ${result.excluded_unconfirmed_principal_ids.length} unconfirmed.`);
1586
+ console.error(`self_check: ${result.self_check}${result.self_check === "not_a_recipient" ? " (this machine's own principal is not itself a vault recipient — nothing to self-verify)" : " (this machine's own opened envelope reproduced the committed epoch key)"}.`);
1587
+ printVerboseStats(a, sdk);
1588
+ } catch (err) {
1589
+ reportSdkError(err);
1590
+ }
1591
+ }
1592
+
1593
+ /**
1594
+ * `run402 repos access revoke-key <principal_id>` (D199) — the ONE fully
1595
+ * self-contained rotation entry point: declares
1596
+ * `reason:"recipient_key_revoked"` for `principal_id` (owner + step-up)
1597
+ * and drives the rotation off that declaration's OWN returned counters.
1598
+ * No flags needed — this is the reason value with a real, working
1599
+ * gateway-side counter read.
1600
+ */
1601
+ async function accessRevokeKey(args) {
1602
+ const a = normalizeArgv(args);
1603
+ assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--idempotency-key", "-v", "--verbose", "--help", "-h"], [...COMMON_VALUE_FLAGS, "--idempotency-key"]);
1604
+ const [principalId] = requirePositionalCount(a, [...COMMON_VALUE_FLAGS, "--idempotency-key"], {
1605
+ min: 1, max: 1, command: "run402 repos access revoke-key <principal_id>",
1606
+ missing: "Missing <principal_id>. This is the principal whose current key should no longer be trusted — the next rotation excludes them from the new epoch.",
1348
1607
  });
1608
+ const sdk = getSdk();
1609
+ const target = await vaultTarget(a);
1610
+ try {
1611
+ const result = await sdk.gitvault.rotateEpochForKeyRevocation(principalId, {
1612
+ ...target,
1613
+ ...(flagValue(a, "--idempotency-key") != null ? { client_idempotency_key: flagValue(a, "--idempotency-key") } : {}),
1614
+ });
1615
+ printJson(sdk, result);
1616
+ await spillIfLarge(result.rotation_id, "access-revoke-key", result);
1617
+ console.error(`declared ${principalId}'s key revoked and rotated to epoch ${result.new_epoch} at generation ${result.generation}: ${result.included.length} recipient(s) included going forward.`);
1618
+ console.error(`self_check: ${result.self_check}.`);
1619
+ printVerboseStats(a, sdk);
1620
+ } catch (err) {
1621
+ reportSdkError(err);
1622
+ }
1623
+ }
1624
+
1625
+ /**
1626
+ * `run402 repos access declare-exposure` (D199) — declares
1627
+ * `reason:"epoch_secret_exposed"` admissible for THIS vault (owner +
1628
+ * step-up), vault-scoped (one vault's leaked key is not evidence any
1629
+ * sibling vault is compromised). The DECLARATION itself is real and
1630
+ * self-contained; the FOLLOW-UP rotation it authorizes is NOT auto-run
1631
+ * here, because — same confirmed gap as `access repair` — the D194
1632
+ * counters it must be fenced against have no client-visible read for this
1633
+ * reason value either. This is the rekey remedy the exposed-key incident
1634
+ * needs: declare here, then rotate (via `--recipient-state-version`/
1635
+ * `--recipient-revocation-version` once known, e.g. from platform staff).
1636
+ *
1637
+ * **If a `/confirm`/`/repin` receipt is already pending** (a directory
1638
+ * principal was confirmed BEFORE this declaration, or gets confirmed while
1639
+ * the rotation is outstanding), do NOT call `publishPinManifestUpdate`
1640
+ * separately — that call is itself an ORDINARY admission and is itself
1641
+ * refused `EPOCH_ROTATION_REQUIRED` for as long as this declaration stays
1642
+ * outstanding (reproduced live in production 2026-08-27). Pass the receipt
1643
+ * to `r.gitvault.rotateEpoch({..., pending_confirmations: [{principal_id,
1644
+ * ek_fingerprint, receipt}]})` instead — it rides the SAME head as the
1645
+ * rotation this declaration requires, publishing durably without needing a
1646
+ * second, separately-gated admission. See `GitvaultVault.rotateEpoch`'s
1647
+ * doc comment for what this does NOT do: the folded principal is still
1648
+ * excluded from THIS rotation's own envelope set (D196) and becomes
1649
+ * eligible starting at the NEXT rotation.
1650
+ */
1651
+ async function accessDeclareExposure(args) {
1652
+ const a = normalizeArgv(args);
1653
+ assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "-v", "--verbose", "--help", "-h"], COMMON_VALUE_FLAGS);
1654
+ requirePositionalCount(a, COMMON_VALUE_FLAGS, { min: 0, max: 0, command: "run402 repos access declare-exposure", missing: "" });
1655
+ const target = await vaultTarget(a);
1656
+ try {
1657
+ const sdk = getSdk();
1658
+ const repoId = target.repo_id ?? (await sdk.gitvault.forProject(target.project_id)).repo_id;
1659
+ const result = await sdk.gitvault.declareEpochSecretExposed(repoId);
1660
+ printJson(sdk, result);
1661
+ console.error(`declared epoch_secret_exposed for ${repoId} (epoch_secret_exposure_version now ${result.epoch_secret_exposure_version}).`);
1662
+ console.error("THIS DECLARATION DOES NOT ROTATE THE VAULT BY ITSELF — the next ordinary push now refuses EPOCH_ROTATION_REQUIRED until a rotate_epoch with reason:\"epoch_secret_exposed\" commits.");
1663
+ console.error("submit that rotation via r.gitvault.rotateEpoch({repo_id, reason: \"epoch_secret_exposed\", recipient_state_version, recipient_revocation_version}) once you have the two counter values (no CLI shortcut exists for this reason yet — see `run402 repos access repair --help`).");
1664
+ console.error("if a /confirm or /repin receipt is already pending for a directory principal, do NOT publish it separately (publishPinManifestUpdate is itself gated the same way) — pass it as rotateEpoch's pending_confirmations instead so it rides the SAME head as this rotation.");
1665
+ printVerboseStats(a, sdk);
1666
+ } catch (err) {
1667
+ reportSdkError(err);
1668
+ }
1349
1669
  }
1350
1670
 
1351
1671
  async function access(args) {
1352
1672
  const a = normalizeArgv(args);
1353
1673
  if (a[0] === "repair") return accessRepair(a.slice(1));
1674
+ if (a[0] === "revoke-key") return accessRevokeKey(a.slice(1));
1675
+ if (a[0] === "declare-exposure") return accessDeclareExposure(a.slice(1));
1354
1676
  return accessRead(a);
1355
1677
  }
1356
1678
 
1357
1679
  // ─── recover ─────────────────────
1358
1680
 
1681
+ /** `repos recover --human`: the same verdict the stderr lines already carry, condensed into one block. */
1682
+ function formatRecoverHuman(result, outDir) {
1683
+ const lines = [
1684
+ `Repo: ${result.repo_id}`,
1685
+ `Recovered generation ${result.recovered_generation} into ${outDir}` +
1686
+ (result.chain_break ? ` (chain break at ${result.chain_break.generation} — fell back to the newest fully-verified generation)` : "") + ".",
1687
+ ];
1688
+ if (result.data_loss_detected) {
1689
+ lines.push(`DATA LOSS DETECTED: ${result.absences.filter((x) => x.adjudication === "unexplained_absence").length} object(s) are unexplained absences.`);
1690
+ }
1691
+ lines.push(`Layout: ${result.layout}` + (result.layout === "bare" ? " (no working files — not a failed recovery)" : ""));
1692
+ return lines.join("\n");
1693
+ }
1694
+
1359
1695
  async function recover(args) {
1360
1696
  const a = normalizeArgv(args);
1361
1697
  const valueFlags = ["--out", "--repo", "--profile", "--region", "--endpoint"];
1362
- assertKnownFlags(a, [...valueFlags, "--ambient", "--help", "-h"], valueFlags);
1698
+ assertKnownFlags(a, [...valueFlags, "--ambient", "--human", "-v", "--verbose", "--help", "-h"], valueFlags);
1363
1699
  const [source] = requirePositionalCount(a, valueFlags, {
1364
1700
  min: 1, max: 1, command: "run402 repos recover <source> --out <dir>",
1365
1701
  missing: "Missing <source>. Expected s3://<bucket>[/<prefix>] or a directory path.",
@@ -1368,19 +1704,31 @@ async function recover(args) {
1368
1704
  if (outDir == null) {
1369
1705
  fail({ code: "BAD_USAGE", message: "run402 repos recover needs --out <dir>.", hint: "Where to materialize the recovered repository, e.g. --out ./restored" });
1370
1706
  }
1707
+ const human = a.includes("--human");
1708
+ if (human && a.includes("--json")) {
1709
+ fail({ code: "BAD_USAGE", message: "--human cannot be combined with --json.", details: { flags: a.filter((arg) => arg === "--human" || arg === "--json") } });
1710
+ }
1711
+ const sdk = getSdk();
1371
1712
  const credential = resolveMirrorCredential(a);
1372
1713
  const repoId = flagValue(a, "--repo");
1373
1714
  const region = flagValue(a, "--region");
1374
1715
  const endpoint = flagValue(a, "--endpoint");
1375
1716
  try {
1376
- const result = await getSdk().gitvault.recover({
1717
+ const result = await sdk.gitvault.recover({
1377
1718
  source, out_dir: outDir,
1378
1719
  ...(repoId != null ? { repo_id: repoId } : {}),
1379
1720
  ...(credential ? { credential } : {}),
1380
1721
  ...(region != null ? { region } : {}),
1381
1722
  ...(endpoint != null ? { endpoint } : {}),
1382
1723
  });
1383
- console.log(JSON.stringify(result, null, 2));
1724
+ if (human) {
1725
+ console.log(formatRecoverHuman(result, outDir));
1726
+ if (result.layout === "bare") for (const n of result.next_actions ?? []) console.error(`next: ${n.action} — ${n.command}`);
1727
+ printMirrorHonesty(result);
1728
+ printVerboseStats(a, sdk);
1729
+ return;
1730
+ }
1731
+ printJson(sdk, result);
1384
1732
  await spillIfLarge(result.repo_id, "recover", result);
1385
1733
  console.error(`recovered generation ${result.recovered_generation} for ${result.repo_id} into ${outDir}` + (result.chain_break ? ` (chain break at ${result.chain_break.generation} — fell back to the newest fully-verified generation)` : "") + ".");
1386
1734
  if (result.data_loss_detected) {
@@ -1391,6 +1739,7 @@ async function recover(args) {
1391
1739
  for (const n of result.next_actions ?? []) console.error(`next: ${n.action} — ${n.command}`);
1392
1740
  }
1393
1741
  printMirrorHonesty(result);
1742
+ printVerboseStats(a, sdk);
1394
1743
  } catch (err) {
1395
1744
  reportSdkError(err);
1396
1745
  }