run402 4.42.0 → 4.44.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 +571 -123
  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, isVerbose } 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] [--manifest-out <path>]
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,21 @@ 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.
137
+ Both \`--dry-run\` and a real snapshot print a SUMMARY by default —
138
+ file counts (files_total/files_changed/files_new), total/delta
139
+ bytes, and up to 200 changed/new paths (changed_more names any
140
+ overflow) — never the full captured-file inventory, which can run
141
+ to thousands of entries on a real repo. \`--manifest-out <path>\`
142
+ writes the complete inventory to a file (the result's
143
+ manifest_path names it); \`-v\`/\`--verbose\` inlines the full
144
+ inventory directly in the JSON (in addition to its usual stderr
145
+ stats line, not instead of it).
123
146
  mirror ONE flag-driven verb for the client-side, customer-
124
147
  owned ciphertext mirror — run402 never holds a credential to it.
125
148
  No argument: READ the configured destination + a keyless
@@ -131,12 +154,16 @@ Subcommands:
131
154
  mirror that fell behind). Exactly one of these per call. Mirror
132
155
  state also renders inside \`repos view\`; mirror INTEGRITY inside
133
156
  \`repos fsck --mirror\`.
134
- recover \`r402s-recover\`: rebuild a working git repository straight from
135
- a mirrored prefix, with NO SERVER INVOLVED — the offline disaster
136
- path (normal retrieval is plain \`git clone run402::<org>/<repo>\`,
137
- no \`repos clone\` verb exists). Proves this mirror's validity,
138
- never freshness read both honesty statements before relying on
139
- the result.
157
+ recover \`r402s-recover\`: rebuild a BARE recovery repository (no working
158
+ files) straight from a mirrored prefix, with NO SERVER INVOLVED —
159
+ the offline disaster path (normal retrieval is plain \`git clone
160
+ run402::<org>/<repo>\`, no \`repos clone\` verb exists). The result's
161
+ \`layout\` is \`"bare"\` and its \`next_actions\` print the exact
162
+ \`git clone <out_dir> <out_dir>-worktree\` to run for a working
163
+ tree — recover itself never checks files out. Proves this
164
+ mirror's validity, never freshness — read both honesty statements
165
+ before relying on the result. \`--human\` renders a short summary
166
+ instead of JSON.
140
167
  fsck Walks the head chain AND materializes
141
168
  the ref map, advancing BOTH
142
169
  local trust pins — reported EXPLICITLY as local_state_changed +
@@ -147,6 +174,7 @@ Subcommands:
147
174
  normal writing mode, so a budget-exceeded run resumes). \`--mirror\`
148
175
  additionally runs the keyless mirror integrity probe — it proves
149
176
  the mirror's VALIDITY, never its FRESHNESS, and says so.
177
+ \`--human\` renders a short summary instead of JSON.
150
178
  gc \`git gc\`'s own two halves — checkpoint publication (compact) and
151
179
  prune planning — in one verb, NOT described as "exactly git gc":
152
180
  the deletion ceremony is stricter. Plans and checkpoints by
@@ -163,20 +191,41 @@ Subcommands:
163
191
  pending_removal, from the gateway's desired-recipient-state
164
192
  substrate), and (best-effort, this machine only) each principal's
165
193
  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 report — gitvault
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.
194
+ has NOT yet been rotated away — pending_removal is honest
195
+ bookkeeping, not enforcement, until \`access repair\`/\`revoke-key\`
196
+ actually rotates. history_scope (which epochs each recipient can
197
+ read) is not reported by this read see the \`gap\` field. An
198
+ enrolled teammate's key envelope is wrapped AUTOMATICALLY no
199
+ manual step by the next \`git push\` or \`repos snapshot\` any
200
+ key-holding client runs (best-effort, non-blocking; the retired
201
+ \`gitvault reconcile\` verb did this by hand and is REMOVED).
202
+ \`--human\` renders a compact roster instead of JSON (the read
203
+ form only — repair/revoke-key/declare-exposure stay JSON-only).
173
204
  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.
205
+ Epoch rotation (D193-D203, rev 42) with reason:"elective_rekey"
206
+ re-keys this vault's CURRENT epoch away from every stale_access
207
+ principal at once, and clears a pre-existing vault's one-time
208
+ migration requirement. \`reconcile\`, the workaround this
209
+ replaces, is REMOVED (it never wrapped a key correctly-scoped to
210
+ "from here forward" this does). Needs
211
+ --recipient-state-version and --recipient-revocation-version:
212
+ the gateway exposes no read route for these two counters outside
213
+ \`revoke-key\`'s own response, so this verb needs them supplied
214
+ explicitly today — refuses cleanly, naming exactly this, when
215
+ omitted. Owner + step-up.
216
+ access revoke-key <principal_id>
217
+ The ONE fully self-contained rotation entry point: declares
218
+ reason:"recipient_key_revoked" for one principal and rotates off
219
+ that declaration's OWN returned counters — no flags needed.
220
+ Owner + step-up. The rekey remedy for "this specific principal's
221
+ key should no longer be trusted."
222
+ access declare-exposure
223
+ Declares reason:"epoch_secret_exposed" for THIS vault
224
+ (vault-scoped, not org-wide) — the rekey remedy for a leaked
225
+ K_repo/K_e. The declaration itself lands immediately; the
226
+ follow-up rotation it authorizes is not auto-run (same counter
227
+ gap as \`access repair\`) — this prints exactly what to do next.
228
+ Owner + step-up.
180
229
  policy Set the activation policy — \`required\` (a deploy must present a
181
230
  vaulted capture) or \`grandfathered\` (it need not). Owner +
182
231
  step-up, audited. \`grandfathered\` is the documented way out of a
@@ -195,9 +244,17 @@ Options:
195
244
  --idempotency-key <key>
196
245
  create: re-running with the same key resolves to the
197
246
  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.
247
+ projects only (default: derived from the name).
248
+ access repair/revoke-key: the rotation attempt's OWN
249
+ client_idempotency_key (32-hex) default: a fresh
250
+ CSPRNG value each call, never resumed across processes.
251
+ --recipient-state-version <n>
252
+ --recipient-revocation-version <n>
253
+ access repair: the D194 frozen watermark pair this
254
+ rotation attempt is fenced against. Required — see
255
+ \`run402 repos access repair --help\` for why.
256
+ --human view/list/access/fsck/recover: a short summary on stdout
257
+ instead of the JSON dump. Rejected together with --json.
201
258
  --force delete: proceed even though the repo holds generations
202
259
  that would be permanently and irrecoverably lost. Never
203
260
  overrides the non-repo-infrastructure refusal.
@@ -205,7 +262,19 @@ Options:
205
262
  tree produces (a clean tree pushes HEAD itself, unused)
206
263
  --checkpoint snapshot: force the checkpoint-bearing form regardless of delta size
207
264
  --dry-run snapshot: a REAL preview — runs the actual local pipeline
208
- and reports what would publish. Publishes nothing.
265
+ and reports what would publish. Publishes nothing. A
266
+ dirty tree still refuses SNAPSHOT_DIRTY_TREE here (a
267
+ preview that hid the refusal would lie).
268
+ --allow-dirty snapshot: capture a dirty tree as-is instead of refusing.
269
+ The result discloses exactly what was swept in
270
+ (modified_captured / untracked_captured) — even this
271
+ override never captures silently.
272
+ --manifest-out <path>
273
+ snapshot: write the complete captured-file inventory
274
+ (the full JSON the SDK returned, untouched) to a private
275
+ 0600 file instead of stdout's default summary. The
276
+ printed result's manifest_path names it. Composes with
277
+ --dry-run and with -v/--verbose.
209
278
  --off mirror: remove the configured destination (config only)
210
279
  --backfill mirror: copy every object the configured mirror is missing
211
280
  --profile <name> mirror / recover: the AWS credential profile name for an
@@ -234,6 +303,13 @@ Options:
234
303
  signed completion appears, instead of returning immediately
235
304
  --reason <why> policy: why the policy is changing — recorded in the
236
305
  audit event. REQUIRED for \`grandfathered\`.
306
+ -v, --verbose Print one stderr summary line of this call's request
307
+ stats (round trips, wire time, bytes). Coexists with
308
+ --human. The JSON result always carries a \`stats\` block
309
+ regardless of this flag. On \`snapshot\`/\`snapshot
310
+ --dry-run\`, ALSO inlines the full captured-file
311
+ inventory in stdout's JSON (composes with the stats
312
+ line — both happen, not one or the other).
237
313
  --json No-op: stdout is already JSON.
238
314
 
239
315
  Terminal loss (protocol §0):
@@ -247,15 +323,17 @@ Examples:
247
323
  run402 repos create --project prj_1a2b3c # allocate for an existing project
248
324
  git push -u origin HEAD # the printed next_action, verbatim
249
325
  run402 repos view --human
250
- run402 repos list --org org_1a2b3c
326
+ run402 repos list --org org_1a2b3c --human
251
327
  run402 repos rename my-notes --project prj_1a2b3c
252
328
  run402 repos snapshot --dry-run
329
+ run402 repos snapshot --dry-run --manifest-out /tmp/snapshot-plan.json
330
+ run402 repos snapshot --allow-dirty
253
331
  run402 repos mirror s3://acme-vault-mirror --profile acme
254
332
  run402 repos mirror --backfill
255
- run402 repos fsck --mirror
333
+ run402 repos fsck --mirror --human
256
334
  run402 repos gc
257
- run402 repos access
258
- run402 repos recover s3://acme-vault-mirror --out ./restored
335
+ run402 repos access --human
336
+ run402 repos recover s3://acme-vault-mirror --out ./restored --human
259
337
  run402 repos delete --project prj_xyz --force
260
338
  `;
261
339
 
@@ -305,6 +383,19 @@ function printTerminalLoss(status) {
305
383
  console.error("");
306
384
  }
307
385
 
386
+ /**
387
+ * Print a verb's JSON result with the always-on `stats` block (Observability:
388
+ * RUN402_TRACE + always-on stats + -v). `sdk.stats()` reflects only calls
389
+ * made through THIS `sdk` instance — every verb below resolves one `sdk =
390
+ * getSdk()` and reuses it for its own direct calls so the count is accurate
391
+ * for the work this function did; calls a shared cross-cutting helper
392
+ * (org/wallet resolution) makes through its own internal instance are not
393
+ * reflected (see `cli/lib/stats.mjs`'s doc comment).
394
+ */
395
+ function printJson(sdk, payload) {
396
+ console.log(JSON.stringify({ ...payload, stats: sdkStats(sdk) }, null, 2));
397
+ }
398
+
308
399
  const LARGE_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
309
400
 
310
401
  /**
@@ -507,17 +598,17 @@ async function inferRepoName(dir) {
507
598
 
508
599
  const CREATE_VALUE_FLAGS = ["--org", "--dir", "--tier", "--idempotency-key", "--project"];
509
600
 
510
- async function printCreateResult({ projectId, vault, adopted, name }) {
601
+ async function printCreateResult({ sdk, projectId, vault, adopted, name, verboseArgv }) {
511
602
  let address = null;
512
603
  let orgSlug = null;
513
604
  try {
514
605
  const owningOrg = await resolveOwningOrgId(projectId);
515
- const orgRecord = owningOrg ? await getSdk().org(owningOrg).get() : null;
606
+ const orgRecord = owningOrg ? await sdk.org(owningOrg).get() : null;
516
607
  orgSlug = orgRecord?.slug ?? null;
517
608
  if (orgSlug && name) {
518
609
  const candidate = slugifyRepoName(name);
519
610
  if (candidate) {
520
- const named = await getSdk().projects.setRepoName(projectId, candidate);
611
+ const named = await sdk.projects.setRepoName(projectId, candidate);
521
612
  address = gitvaultRemoteUrlForRepo(orgSlug, named.repo_name);
522
613
  }
523
614
  }
@@ -546,13 +637,13 @@ async function printCreateResult({ projectId, vault, adopted, name }) {
546
637
  deployed: false,
547
638
  next_actions: nextActions,
548
639
  };
549
- console.log(JSON.stringify(out, null, 2));
640
+ printJson(sdk, out);
550
641
  console.error(
551
642
  `project ${projectId} ${adopted ? "adopted" : "provisioned"}; repo ${vault.repo_id} ` +
552
643
  (vault.deduplicated ? "already existed — nothing was re-allocated" : `allocated (genesis ${vault.genesis_sha256})`),
553
644
  );
554
645
  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");
646
+ else if (!orgSlug) console.error("no named address yet — claim an org slug (run402 org slug <slug>) to get run402::<slug>/<name> addresses");
556
647
  else console.error(`no address claimed — run 'run402 repos rename <name> --project ${projectId}' to claim one`);
557
648
  if (vault.remote) console.error(`remote '${vault.remote.name}' -> ${vault.remote.url} (${vault.remote.reason})`);
558
649
  if (pushAction) console.error(`next: ${pushAction.command}`);
@@ -561,9 +652,11 @@ async function printCreateResult({ projectId, vault, adopted, name }) {
561
652
  await printKeystoreLocation();
562
653
  console.error("");
563
654
  console.error("nothing was deployed — this is a vault-only repo. Deploy later with `run402 deploy apply`, or never.");
655
+ printVerboseStats(verboseArgv, sdk);
564
656
  }
565
657
 
566
658
  async function createAdopt(projectId, dir, a) {
659
+ const sdk = getSdk();
567
660
  const orgId = flagValue(a, "--org") ?? await resolveOwningOrgId(projectId);
568
661
  if (!orgId) {
569
662
  fail({
@@ -574,14 +667,15 @@ async function createAdopt(projectId, dir, a) {
574
667
  });
575
668
  }
576
669
  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 });
670
+ const vault = await sdk.gitvault.init({ org_id: orgId, project_id: projectId, repo_dir: dir });
671
+ await printCreateResult({ sdk, projectId, vault, adopted: true, name: null, verboseArgv: a });
579
672
  } catch (err) {
580
673
  reportSdkError(err);
581
674
  }
582
675
  }
583
676
 
584
677
  async function createProvision(name, dir, a) {
678
+ const sdk = getSdk();
585
679
  const tier = flagValue(a, "--tier") ?? "prototype";
586
680
  const idempotencyKey = flagValue(a, "--idempotency-key") ?? `repos-create:${name}`;
587
681
  // `optional: true` — a fresh wallet with no org yet is the cold-start path
@@ -593,7 +687,7 @@ async function createProvision(name, dir, a) {
593
687
  let provisioned;
594
688
  try {
595
689
  provisioned = await withAutoApprove(() =>
596
- getSdk().projects.provision({ tier, name, ...(orgId ? { orgId } : {}), idempotencyKey }),
690
+ sdk.projects.provision({ tier, name, ...(orgId ? { orgId } : {}), idempotencyKey }),
597
691
  );
598
692
  } catch (err) {
599
693
  reportSdkError(err);
@@ -612,8 +706,8 @@ async function createProvision(name, dir, a) {
612
706
  }
613
707
 
614
708
  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 });
709
+ const vault = await sdk.gitvault.init({ org_id: effectiveOrgId, project_id: provisioned.project_id, repo_dir: dir });
710
+ await printCreateResult({ sdk, projectId: provisioned.project_id, vault, adopted: false, name, verboseArgv: a });
617
711
  } catch (err) {
618
712
  reportSdkError(err);
619
713
  }
@@ -621,7 +715,7 @@ async function createProvision(name, dir, a) {
621
715
 
622
716
  async function create(args) {
623
717
  const a = normalizeArgv(args);
624
- assertKnownFlags(a, [...CREATE_VALUE_FLAGS, "--help", "-h"], CREATE_VALUE_FLAGS);
718
+ assertKnownFlags(a, [...CREATE_VALUE_FLAGS, "--help", "-h", "-v", "--verbose"], CREATE_VALUE_FLAGS);
625
719
  const positionals = requirePositionalCount(a, CREATE_VALUE_FLAGS, {
626
720
  min: 0, max: 1, command: "run402 repos create [name]", missing: "",
627
721
  });
@@ -655,8 +749,8 @@ async function create(args) {
655
749
  // ─── list ───────────────────────────────────────────────────────────────────
656
750
 
657
751
  /** The FROZEN bulk-read shape (task 2.4) — one round trip. */
658
- async function listViaBulkRead(orgId) {
659
- const result = await getSdk().gitvault.listByOrg(orgId);
752
+ async function listViaBulkRead(sdk, orgId) {
753
+ const result = await sdk.gitvault.listByOrg(orgId);
660
754
  return Array.isArray(result.vaults) ? result.vaults : [];
661
755
  }
662
756
 
@@ -667,14 +761,14 @@ async function listViaBulkRead(orgId) {
667
761
  * function once the bulk route has shipped long enough that no gateway
668
762
  * still 404s it.
669
763
  */
670
- async function listViaFallback(orgId) {
671
- const result = await getSdk().projects.list({ org: orgId });
764
+ async function listViaFallback(sdk, orgId) {
765
+ const result = await sdk.projects.list({ org: orgId });
672
766
  const projects = Array.isArray(result.projects) ? result.projects : [];
673
767
  const repos = [];
674
768
  for (const p of projects) {
675
769
  let status;
676
770
  try {
677
- status = await getSdk().gitvault.status({ project_id: p.id });
771
+ status = await sdk.gitvault.status({ project_id: p.id });
678
772
  } catch {
679
773
  continue;
680
774
  }
@@ -695,21 +789,38 @@ async function listViaFallback(orgId) {
695
789
  return repos;
696
790
  }
697
791
 
792
+ /** `repos list --human`: a compact roster — one line per repo (address, generation, bytes, policy). */
793
+ async function formatRepoListHuman(orgSlug, repos) {
794
+ if (repos.length === 0) return "(no vault-bearing repos in this organization)";
795
+ const { generationToBigInt } = await import("#sdk/node");
796
+ const decimal = (g) => (g ? generationToBigInt(g).toString() : "none");
797
+ const lines = repos.map((r) => {
798
+ const address = orgSlug && r.repo_name ? `run402::${orgSlug}/${r.repo_name}` : (r.repo_name ?? r.project_id);
799
+ return `${address} gen=${decimal(r.newest_generation)} ${r.source_bytes} byte(s) policy=${r.gitvault_policy ?? "(none)"} (${r.repo_id})`;
800
+ });
801
+ return lines.join("\n");
802
+ }
803
+
698
804
  async function list(args) {
699
805
  const a = normalizeArgv(args);
700
- assertKnownFlags(a, ["--org", "--help", "-h"], ["--org"]);
806
+ assertKnownFlags(a, ["--org", "--human", "-v", "--verbose", "--help", "-h"], ["--org"]);
701
807
  requirePositionalCount(a, ["--org"], { min: 0, max: 0, command: "run402 repos list", missing: "" });
808
+ const human = a.includes("--human");
809
+ if (human && a.includes("--json")) {
810
+ fail({ code: "BAD_USAGE", message: "--human cannot be combined with --json.", details: { flags: a.filter((arg) => arg === "--human" || arg === "--json") } });
811
+ }
812
+ const sdk = getSdk();
702
813
  const orgId = await resolveOrgId(a, { cmd: "repos" });
703
814
 
704
815
  let repos;
705
816
  let usedFallback = false;
706
817
  try {
707
- repos = await listViaBulkRead(orgId);
818
+ repos = await listViaBulkRead(sdk, orgId);
708
819
  } catch (err) {
709
820
  if (err?.status === 404) {
710
821
  usedFallback = true;
711
822
  try {
712
- repos = await listViaFallback(orgId);
823
+ repos = await listViaFallback(sdk, orgId);
713
824
  } catch (fallbackErr) {
714
825
  reportSdkError(fallbackErr);
715
826
  return;
@@ -723,42 +834,50 @@ async function list(args) {
723
834
  let orgSlug = repos.find((r) => r.org_slug)?.org_slug ?? null;
724
835
  if (orgSlug == null) {
725
836
  try {
726
- orgSlug = (await getSdk().org(orgId).get()).slug;
837
+ orgSlug = (await sdk.org(orgId).get()).slug;
727
838
  } catch {
728
839
  // best-effort — `list` must not fail over an org-slug lookup
729
840
  }
730
841
  }
731
842
 
732
- console.log(JSON.stringify({ org_id: orgId, org_slug: orgSlug, repos }, null, 2));
843
+ if (human) {
844
+ console.log(await formatRepoListHuman(orgSlug, repos));
845
+ printVerboseStats(a, sdk);
846
+ return;
847
+ }
848
+ printJson(sdk, { org_id: orgId, org_slug: orgSlug, repos });
733
849
  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
850
  if (orgSlug) console.error(`org slug: ${orgSlug} — a repo with a claimed address-form name is reachable at run402::${orgSlug}/<name>`);
851
+ printVerboseStats(a, sdk);
735
852
  }
736
853
 
737
854
  // ─── view ───────────────────────────────────────────────────────────────────
738
855
 
739
856
  async function view(args) {
740
857
  const a = normalizeArgv(args);
741
- assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--human", "--help", "-h"], COMMON_VALUE_FLAGS);
858
+ assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--human", "-v", "--verbose", "--help", "-h"], COMMON_VALUE_FLAGS);
742
859
  requirePositionalCount(a, COMMON_VALUE_FLAGS, { min: 0, max: 0, command: "run402 repos view", missing: "" });
743
860
  const human = a.includes("--human");
744
861
  if (human && a.includes("--json")) {
745
862
  fail({ code: "BAD_USAGE", message: "--human cannot be combined with --json.", details: { flags: a.filter((arg) => arg === "--human" || arg === "--json") } });
746
863
  }
864
+ const sdk = getSdk();
747
865
  const target = await vaultTarget(a);
748
866
  try {
749
867
  // Design D3: `view` NEVER passes `refs: true` — it is side-effect-free
750
868
  // by construction, not by convention. Materialization belongs to `fsck`.
751
- const s = await getSdk().gitvault.status(target);
869
+ const s = await sdk.gitvault.status(target);
752
870
  let mirror = null;
753
871
  if (s.repo_id) {
754
872
  try {
755
- mirror = await getSdk().gitvault.mirrorStatus({ ...target, repo_id: s.repo_id });
873
+ mirror = await sdk.gitvault.mirrorStatus({ ...target, repo_id: s.repo_id });
756
874
  } catch {
757
875
  // best-effort — a mirror read failure never fails `view`
758
876
  }
759
877
  }
760
878
  if (human) {
761
879
  console.log(await formatRepoHuman(s, mirror));
880
+ printVerboseStats(a, sdk);
762
881
  return;
763
882
  }
764
883
  const verifyRefsAction = nextAction("verify_refs", { command: "run402 repos fsck", why: "Walk the signed chain and materialize verified refs." });
@@ -769,7 +888,7 @@ async function view(args) {
769
888
  mirror,
770
889
  next_actions: combinedNextActions,
771
890
  };
772
- console.log(JSON.stringify(out, null, 2));
891
+ printJson(sdk, out);
773
892
  printTerminalLoss(s);
774
893
  if (s.remote) {
775
894
  const suffix =
@@ -787,6 +906,7 @@ async function view(args) {
787
906
  }
788
907
  for (const w of s.warnings) console.error(`warning (${w.kind}): ${w.message}`);
789
908
  for (const n of combinedNextActions) console.error(`next: ${n.why ?? n.action ?? n.type}${n.command ? ` — ${n.command}` : ""}`);
909
+ printVerboseStats(a, sdk);
790
910
  } catch (err) {
791
911
  reportSdkError(err);
792
912
  }
@@ -796,11 +916,12 @@ async function view(args) {
796
916
 
797
917
  async function rename(args) {
798
918
  const a = normalizeArgv(args);
799
- assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--help", "-h"], COMMON_VALUE_FLAGS);
919
+ assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "-v", "--verbose", "--help", "-h"], COMMON_VALUE_FLAGS);
800
920
  const [repoName] = requirePositionalCount(a, COMMON_VALUE_FLAGS, {
801
921
  min: 1, max: 1, command: "run402 repos rename <new_name> [--repo <repo_id> | --project <project_id>]",
802
922
  missing: "run402 repos rename <new_name>: a new name is required",
803
923
  });
924
+ const sdk = getSdk();
804
925
  const repoFlag = flagValue(a, "--repo");
805
926
  const projectFlag = flagValue(a, "--project");
806
927
  if (repoFlag != null && projectFlag != null) {
@@ -809,7 +930,7 @@ async function rename(args) {
809
930
  let projectId;
810
931
  if (repoFlag != null) {
811
932
  try {
812
- projectId = (await getSdk().gitvault.get(repoFlag)).project_id;
933
+ projectId = (await sdk.gitvault.get(repoFlag)).project_id;
813
934
  } catch (err) {
814
935
  reportSdkError(err);
815
936
  return;
@@ -818,16 +939,16 @@ async function rename(args) {
818
939
  projectId = resolveProjectId(projectFlag);
819
940
  }
820
941
  try {
821
- const result = await getSdk().projects.setRepoName(projectId, repoName);
942
+ const result = await sdk.projects.setRepoName(projectId, repoName);
822
943
  let address = null;
823
944
  try {
824
945
  const owningOrg = await resolveOwningOrgId(projectId);
825
- const orgSlug = owningOrg ? (await getSdk().org(owningOrg).get()).slug : null;
946
+ const orgSlug = owningOrg ? (await sdk.org(owningOrg).get()).slug : null;
826
947
  if (orgSlug) address = gitvaultRemoteUrlForRepo(orgSlug, result.repo_name);
827
948
  } catch {
828
949
  // The claim itself already succeeded — a failed address-preview lookup is never fatal.
829
950
  }
830
- console.log(JSON.stringify({ ...result, address }, null, 2));
951
+ printJson(sdk, { ...result, address });
831
952
  console.error(
832
953
  result.previous_repo_name && result.previous_repo_name !== result.repo_name
833
954
  ? `renamed from "${result.previous_repo_name}" to "${result.repo_name}"`
@@ -835,6 +956,7 @@ async function rename(args) {
835
956
  );
836
957
  if (address) console.error(`address: ${address}`);
837
958
  else console.error("this org has no slug yet — claim one with `run402 org slug <slug>` to get a full run402::<slug>/<name> address");
959
+ printVerboseStats(a, sdk);
838
960
  } catch (err) {
839
961
  reportSdkError(err);
840
962
  }
@@ -863,22 +985,22 @@ async function checkResource(read, resourceName, countOf) {
863
985
  * that fails for a reason OTHER than "genuinely absent" (404) is reported
864
986
  * `unknown` and REFUSES delete too — D9 never guesses its way to yes.
865
987
  */
866
- async function checkNonRepoResources(projectId) {
988
+ async function checkNonRepoResources(sdk, projectId) {
867
989
  const refused = [];
868
990
  try {
869
- const detail = await getSdk().projects.get(projectId);
991
+ const detail = await sdk.projects.get(projectId);
870
992
  if (Array.isArray(detail.mailbox) && detail.mailbox.length > 0) refused.push({ resource: "mailbox", status: "present", count: detail.mailbox.length });
871
993
  if (Array.isArray(detail.custom_domains) && detail.custom_domains.length > 0) refused.push({ resource: "custom_domains", status: "present", count: detail.custom_domains.length });
872
994
  } catch (err) {
873
995
  refused.push({ resource: "project_detail", status: "unknown", reason: err?.message ?? String(err) });
874
996
  }
875
- const schema = await checkResource(() => getSdk().projects.getSchema(projectId), "database_schema", (s) => (Array.isArray(s?.tables) ? s.tables.length : 0));
997
+ const schema = await checkResource(() => sdk.projects.getSchema(projectId), "database_schema", (s) => (Array.isArray(s?.tables) ? s.tables.length : 0));
876
998
  if (schema) refused.push(schema);
877
- const functions = await checkResource(() => getSdk().functions.list(projectId), "functions", (r) => (Array.isArray(r?.functions) ? r.functions.length : 0));
999
+ const functions = await checkResource(() => sdk.functions.list(projectId), "functions", (r) => (Array.isArray(r?.functions) ? r.functions.length : 0));
878
1000
  if (functions) refused.push(functions);
879
- const secrets = await checkResource(() => getSdk().secrets.list(projectId), "secrets", (r) => (Array.isArray(r?.secrets) ? r.secrets.length : 0));
1001
+ const secrets = await checkResource(() => sdk.secrets.list(projectId), "secrets", (r) => (Array.isArray(r?.secrets) ? r.secrets.length : 0));
880
1002
  if (secrets) refused.push(secrets);
881
- const subdomains = await checkResource(() => getSdk().subdomains.list(projectId), "subdomains", (r) => (Array.isArray(r) ? r.length : 0));
1003
+ const subdomains = await checkResource(() => sdk.subdomains.list(projectId), "subdomains", (r) => (Array.isArray(r) ? r.length : 0));
882
1004
  if (subdomains) refused.push(subdomains);
883
1005
  return refused;
884
1006
  }
@@ -893,7 +1015,8 @@ function stripFlag(args, flag) {
893
1015
 
894
1016
  async function del(args) {
895
1017
  const a = normalizeArgv(args);
896
- assertKnownFlags(a, ["--project", "--repo", "--force", "--help", "-h"], ["--project", "--repo"]);
1018
+ assertKnownFlags(a, ["--project", "--repo", "--force", "-v", "--verbose", "--help", "-h"], ["--project", "--repo"]);
1019
+ const sdk = getSdk();
897
1020
  const repoFlag = flagValue(a, "--repo");
898
1021
  let projectId;
899
1022
  let rest;
@@ -902,7 +1025,7 @@ async function del(args) {
902
1025
  fail({ code: "BAD_USAGE", message: "pass --repo or --project, not both." });
903
1026
  }
904
1027
  try {
905
- projectId = (await getSdk().gitvault.get(repoFlag)).project_id;
1028
+ projectId = (await sdk.gitvault.get(repoFlag)).project_id;
906
1029
  } catch (err) {
907
1030
  reportSdkError(err);
908
1031
  return;
@@ -916,7 +1039,7 @@ async function del(args) {
916
1039
 
917
1040
  let status;
918
1041
  try {
919
- status = await getSdk().gitvault.status({ project_id: projectId });
1042
+ status = await sdk.gitvault.status({ project_id: projectId });
920
1043
  } catch (err) {
921
1044
  reportSdkError(err);
922
1045
  return;
@@ -927,7 +1050,7 @@ async function del(args) {
927
1050
 
928
1051
  // D9, checked FIRST and unconditionally: --force below overrides only the
929
1052
  // vault-history confirmation, never this refusal.
930
- const refusedResources = await checkNonRepoResources(projectId);
1053
+ const refusedResources = await checkNonRepoResources(sdk, projectId);
931
1054
  if (refusedResources.length > 0) {
932
1055
  fail({
933
1056
  code: "PROJECT_HAS_NON_REPO_RESOURCES",
@@ -957,13 +1080,14 @@ async function del(args) {
957
1080
  }
958
1081
 
959
1082
  try {
960
- await getSdk().projects.delete(projectId);
961
- console.log(JSON.stringify({
1083
+ await sdk.projects.delete(projectId);
1084
+ printJson(sdk, {
962
1085
  project_id: projectId,
963
1086
  deleted: true,
964
1087
  deleted_resources: ["project", ...(vault ? ["vault_history"] : [])],
965
1088
  vault: vault ? { repo_id: status.repo_id, admitted_generations: admittedGenerations, source_bytes: sourceBytes } : null,
966
- }, null, 2));
1089
+ });
1090
+ printVerboseStats(a, sdk);
967
1091
  } catch (err) {
968
1092
  reportSdkError(err);
969
1093
  }
@@ -971,7 +1095,7 @@ async function del(args) {
971
1095
 
972
1096
  // ─── snapshot ───────────────────────────────────────────────────────────────
973
1097
 
974
- const SNAPSHOT_VALUE_FLAGS = [...COMMON_VALUE_FLAGS, "--message"];
1098
+ const SNAPSHOT_VALUE_FLAGS = [...COMMON_VALUE_FLAGS, "--message", "--manifest-out"];
975
1099
 
976
1100
  /**
977
1101
  * When neither `--repo` nor `--project` was given explicitly, look at the
@@ -998,12 +1122,94 @@ async function detectSlugFormRemote(a, repoDir) {
998
1122
  return null;
999
1123
  }
1000
1124
 
1125
+ /**
1126
+ * Dirty-tree disclosure (help people not make mistakes): even an explicit
1127
+ * `--allow-dirty` override never captures silently — every modified/staged
1128
+ * tracked path and every untracked-not-ignored path that got swept into the
1129
+ * capture is named on stderr, one per line.
1130
+ */
1131
+ function printDirtyDisclosure(snapshot) {
1132
+ if (!snapshot) return;
1133
+ for (const p of snapshot.modified_captured ?? []) console.error(`captured (modified): ${p}`);
1134
+ for (const p of snapshot.untracked_captured ?? []) console.error(`captured (untracked): ${p}`);
1135
+ }
1136
+
1137
+ /** How many `changed_paths` entries {@link summarizeSnapshotPayload} inlines before capping. */
1138
+ const SNAPSHOT_CHANGED_PATHS_CAP = 200;
1139
+
1140
+ /**
1141
+ * item 1 (dogfood): `snapshot.captured` — the SDK's full captured-file
1142
+ * inventory (every tracked + untracked-not-ignored path in the repo, always
1143
+ * populated regardless of how small the actual push delta is) — is a
1144
+ * multi-thousand-line flood on a real repo, even when what actually
1145
+ * publishes is a handful of kilobytes. The SDK keeps returning it in full
1146
+ * (thin-shim law: other SDK consumers may want it) — this reshapes ONLY the
1147
+ * CLI's own stdout, by default:
1148
+ *
1149
+ * - `files_total` / `files_changed` / `files_new` — counts. `files_total`
1150
+ * is `captured.length`; `files_changed`/`files_new` are
1151
+ * `modified_captured`/`untracked_captured` — the ONLY per-path drift
1152
+ * this data distinguishes (the `--allow-dirty` sweep-in disclosure).
1153
+ * On the common clean-tree path both are empty, so `changed_paths` is
1154
+ * too — there is no `files_deleted` here, because `captured` only
1155
+ * lists paths PRESENT on disk today; nothing in this data names which
1156
+ * paths a plain clean push's new commits touched.
1157
+ * - `changed_paths` — `modified_captured` ∪ `untracked_captured`,
1158
+ * sorted, capped at `SNAPSHOT_CHANGED_PATHS_CAP`; `changed_more` names
1159
+ * the overflow explicitly rather than truncating silently.
1160
+ * - `snapshot.captured` / `.paths` / `.modified_captured` /
1161
+ * `.untracked_captured` are dropped from the default `snapshot` object
1162
+ * (its other scalar fields — kind, oid, tree_oid, head, head_oid,
1163
+ * captured_digest, top_level, global_excludes_path — stay). `verbose`
1164
+ * restores them (composes with the summary fields, does not replace
1165
+ * them) — the `-v`/`--verbose` flag already means "print a stats
1166
+ * line"; on `snapshot --dry-run`/`snapshot` it ALSO inlines the full
1167
+ * inventory.
1168
+ * - `manifest_path` is `null` unless `--manifest-out <path>` wrote the
1169
+ * COMPLETE, untouched payload to that file — see `writeManifestOut`.
1170
+ */
1171
+ function summarizeSnapshotPayload(payload, { verbose = false, manifestPath = null } = {}) {
1172
+ const out = { ...payload, manifest_path: manifestPath };
1173
+ const snapshot = payload.snapshot;
1174
+ if (!snapshot) return out;
1175
+ const modified = snapshot.modified_captured ?? [];
1176
+ const untracked = snapshot.untracked_captured ?? [];
1177
+ const changedAll = [...modified, ...untracked].sort();
1178
+ const changedPaths = changedAll.slice(0, SNAPSHOT_CHANGED_PATHS_CAP);
1179
+ out.files_total = Array.isArray(snapshot.captured) ? snapshot.captured.length : 0;
1180
+ out.files_changed = modified.length;
1181
+ out.files_new = untracked.length;
1182
+ out.changed_paths = changedPaths;
1183
+ out.changed_more = changedAll.length - changedPaths.length;
1184
+ if (!verbose) {
1185
+ const { captured, paths, modified_captured, untracked_captured, ...trimmedSnapshot } = snapshot;
1186
+ out.snapshot = trimmedSnapshot;
1187
+ }
1188
+ return out;
1189
+ }
1190
+
1191
+ /** `--manifest-out <path>`: write the COMPLETE, untouched plan/push payload — the full captured-file inventory included — to a private 0600 file. */
1192
+ function writeManifestOut(path, payload) {
1193
+ try {
1194
+ writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
1195
+ } catch (e) {
1196
+ fail({
1197
+ code: "MANIFEST_OUT_WRITE_FAILED",
1198
+ message: `could not write the full snapshot inventory to ${path}: ${e instanceof Error ? e.message : String(e)}`,
1199
+ hint: "Check that the path is writable and its parent directory exists.",
1200
+ details: { path },
1201
+ });
1202
+ }
1203
+ }
1204
+
1001
1205
  async function snapshot(args) {
1002
1206
  const a = normalizeArgv(args);
1003
- assertKnownFlags(a, [...SNAPSHOT_VALUE_FLAGS, "--checkpoint", "--dry-run", "--help", "-h"], SNAPSHOT_VALUE_FLAGS);
1207
+ assertKnownFlags(a, [...SNAPSHOT_VALUE_FLAGS, "--checkpoint", "--dry-run", "--allow-dirty", "-v", "--verbose", "--help", "-h"], SNAPSHOT_VALUE_FLAGS);
1004
1208
  requirePositionalCount(a, SNAPSHOT_VALUE_FLAGS, { min: 0, max: 0, command: "run402 repos snapshot", missing: "" });
1209
+ const sdk = getSdk();
1005
1210
  const dryRun = a.includes("--dry-run");
1006
1211
  const message = flagValue(a, "--message");
1212
+ const allowDirty = a.includes("--allow-dirty");
1007
1213
  const repoDir = process.cwd();
1008
1214
  const address = await detectSlugFormRemote(a, repoDir);
1009
1215
  const target = address ? { repo_dir: repoDir } : await vaultTarget(a);
@@ -1021,12 +1227,18 @@ async function snapshot(args) {
1021
1227
  console.error("");
1022
1228
  },
1023
1229
  };
1024
- if (message != null) opts.snapshot = { message };
1230
+ const snapshotOpts = {};
1231
+ if (message != null) snapshotOpts.message = message;
1232
+ if (allowDirty) snapshotOpts.allowDirty = true;
1233
+ if (Object.keys(snapshotOpts).length > 0) opts.snapshot = snapshotOpts;
1025
1234
  if (a.includes("--checkpoint")) opts.checkpoint = true;
1235
+ const manifestOutPath = flagValue(a, "--manifest-out");
1236
+ const verbose = isVerbose(a);
1026
1237
  try {
1027
1238
  if (dryRun) {
1028
- const plan = await getSdk().gitvault.planPush(opts);
1029
- console.log(JSON.stringify(plan, null, 2));
1239
+ const plan = await sdk.gitvault.planPush(opts);
1240
+ if (manifestOutPath != null) writeManifestOut(manifestOutPath, plan);
1241
+ printJson(sdk, summarizeSnapshotPayload(plan, { verbose, manifestPath: manifestOutPath }));
1030
1242
  if (plan.allocation_needed) {
1031
1243
  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
1244
  } else {
@@ -1035,16 +1247,21 @@ async function snapshot(args) {
1035
1247
  `${plan.object_count} object(s), ${plan.encrypted_bytes} encrypted byte(s) (${plan.raw_bytes} raw)`,
1036
1248
  );
1037
1249
  }
1250
+ printDirtyDisclosure(plan.snapshot);
1251
+ printVerboseStats(a, sdk);
1038
1252
  return;
1039
1253
  }
1040
- const result = await getSdk().gitvault.push(opts);
1041
- console.log(JSON.stringify(result, null, 2));
1254
+ const result = await sdk.gitvault.push(opts);
1255
+ if (manifestOutPath != null) writeManifestOut(manifestOutPath, result);
1256
+ printJson(sdk, summarizeSnapshotPayload(result, { verbose, manifestPath: manifestOutPath }));
1042
1257
  console.error(`published generation ${result.generation} (${result.form})`);
1043
1258
  if (result.mirror_push?.outcome === "pushed") {
1044
1259
  console.error(`mirror: pushed generation ${result.generation} (${result.mirror_push.summary?.objects_copied ?? 0} object(s) copied)`);
1045
1260
  } else if (result.mirror_push?.outcome === "failed") {
1046
1261
  console.error(`mirror: dual-push FAILED (deploy is unaffected) — ${result.mirror_push.error ?? "see mirror_push.summary.errors"}`);
1047
1262
  }
1263
+ printDirtyDisclosure(result.snapshot);
1264
+ printVerboseStats(a, sdk);
1048
1265
  } catch (err) {
1049
1266
  reportSdkError(err);
1050
1267
  }
@@ -1055,7 +1272,7 @@ async function snapshot(args) {
1055
1272
  async function policy(args) {
1056
1273
  const a = normalizeArgv(args);
1057
1274
  const valueFlags = [...COMMON_VALUE_FLAGS, "--reason"];
1058
- assertKnownFlags(a, [...valueFlags, "--help", "-h"], valueFlags);
1275
+ assertKnownFlags(a, [...valueFlags, "-v", "--verbose", "--help", "-h"], valueFlags);
1059
1276
  const [requested] = requirePositionalCount(a, valueFlags, {
1060
1277
  min: 1, max: 1, command: "run402 repos policy <required|grandfathered>",
1061
1278
  missing: "Missing <policy>. Expected `required` or `grandfathered`.",
@@ -1083,13 +1300,14 @@ async function policy(args) {
1083
1300
  const sdk = getSdk();
1084
1301
  const repoId = target.repo_id ?? (await sdk.gitvault.forProject(target.project_id)).repo_id;
1085
1302
  const result = await sdk.gitvault.setPolicy(repoId, { gitvault_policy: requested, ...(reason != null ? { reason } : {}) });
1086
- console.log(JSON.stringify({ repo_id: repoId, ...result }, null, 2));
1303
+ printJson(sdk, { repo_id: repoId, ...result });
1087
1304
  console.error(
1088
1305
  result.changed
1089
1306
  ? `gitvault_policy is now ${result.gitvault_policy} (version ${result.gitvault_policy_version})`
1090
1307
  : `gitvault_policy was already ${result.gitvault_policy} — nothing changed`,
1091
1308
  );
1092
1309
  for (const w of result.warnings ?? []) console.error(`warning (${w.kind}): ${w.message}`);
1310
+ printVerboseStats(a, sdk);
1093
1311
  } catch (err) {
1094
1312
  reportSdkError(err);
1095
1313
  }
@@ -1099,10 +1317,11 @@ async function policy(args) {
1099
1317
 
1100
1318
  const MIRROR_VALUE_FLAGS = [...COMMON_VALUE_FLAGS, "--profile", "--region", "--endpoint"];
1101
1319
 
1102
- async function mirrorRead(target) {
1320
+ async function mirrorRead(target, a) {
1321
+ const sdk = getSdk();
1103
1322
  try {
1104
- const result = await getSdk().gitvault.mirrorStatus(target);
1105
- console.log(JSON.stringify(result, null, 2));
1323
+ const result = await sdk.gitvault.mirrorStatus(target);
1324
+ printJson(sdk, result);
1106
1325
  if (!result.configured) {
1107
1326
  console.error(`no mirror configured for ${result.repo_id}. Configure one: run402 repos mirror <destination>`);
1108
1327
  } else {
@@ -1110,49 +1329,55 @@ async function mirrorRead(target) {
1110
1329
  console.error(`mirror ${result.destination}: mirrored generation ${result.mirrored_generation ?? "(none)"}, vault newest ${result.newest_generation ?? "(none)"} — ${currency}`);
1111
1330
  }
1112
1331
  printMirrorHonesty(result);
1332
+ printVerboseStats(a, sdk);
1113
1333
  } catch (err) {
1114
1334
  reportSdkError(err);
1115
1335
  }
1116
1336
  }
1117
1337
 
1118
1338
  async function mirrorSet(target, destination, a) {
1339
+ const sdk = getSdk();
1119
1340
  const credential = resolveMirrorCredential(a);
1120
1341
  const region = flagValue(a, "--region");
1121
1342
  const endpoint = flagValue(a, "--endpoint");
1122
1343
  try {
1123
- const result = await getSdk().gitvault.mirrorSet({
1344
+ const result = await sdk.gitvault.mirrorSet({
1124
1345
  ...target,
1125
1346
  destination_url: destination,
1126
1347
  ...(credential ? { credential } : {}),
1127
1348
  ...(region != null ? { region } : {}),
1128
1349
  ...(endpoint != null ? { endpoint } : {}),
1129
1350
  });
1130
- console.log(JSON.stringify(result, null, 2));
1351
+ printJson(sdk, result);
1131
1352
  console.error(`mirror configured for ${result.repo_id} -> ${formatMirrorDestination(result.destination)}`);
1132
1353
  console.error("run `run402 repos mirror --backfill` to catch it up now, then every publish dual-pushes automatically.");
1354
+ printVerboseStats(a, sdk);
1133
1355
  } catch (err) {
1134
1356
  reportSdkError(err);
1135
1357
  }
1136
1358
  }
1137
1359
 
1138
- async function mirrorOff(target) {
1360
+ async function mirrorOff(target, a) {
1361
+ const sdk = getSdk();
1139
1362
  try {
1140
- const result = await getSdk().gitvault.mirrorRemove(target);
1141
- console.log(JSON.stringify(result, null, 2));
1363
+ const result = await sdk.gitvault.mirrorRemove(target);
1364
+ printJson(sdk, result);
1142
1365
  console.error(
1143
1366
  result.removed
1144
1367
  ? `mirror config removed for ${result.repo_id} — the mirror's OWN bytes were not touched`
1145
1368
  : `no mirror was configured for ${result.repo_id} — nothing to remove`,
1146
1369
  );
1370
+ printVerboseStats(a, sdk);
1147
1371
  } catch (err) {
1148
1372
  reportSdkError(err);
1149
1373
  }
1150
1374
  }
1151
1375
 
1152
- async function mirrorBackfill(target) {
1376
+ async function mirrorBackfill(target, a) {
1377
+ const sdk = getSdk();
1153
1378
  try {
1154
- const result = await getSdk().gitvault.mirrorSync(target);
1155
- console.log(JSON.stringify(result, null, 2));
1379
+ const result = await sdk.gitvault.mirrorSync(target);
1380
+ printJson(sdk, result);
1156
1381
  await spillIfLarge(result.repo_id, "mirror-backfill", result);
1157
1382
  console.error(
1158
1383
  `mirror backfill for ${result.repo_id}: ${result.objects_copied} copied, ${result.objects_already_present} already present` +
@@ -1161,6 +1386,7 @@ async function mirrorBackfill(target) {
1161
1386
  );
1162
1387
  for (const e of result.errors) console.error(` failed: ${e.key} — ${e.error}`);
1163
1388
  printMirrorHonesty(result);
1389
+ printVerboseStats(a, sdk);
1164
1390
  } catch (err) {
1165
1391
  reportSdkError(err);
1166
1392
  }
@@ -1168,7 +1394,7 @@ async function mirrorBackfill(target) {
1168
1394
 
1169
1395
  async function mirror(args) {
1170
1396
  const a = normalizeArgv(args);
1171
- assertKnownFlags(a, [...MIRROR_VALUE_FLAGS, "--off", "--backfill", "--ambient", "--help", "-h"], MIRROR_VALUE_FLAGS);
1397
+ assertKnownFlags(a, [...MIRROR_VALUE_FLAGS, "--off", "--backfill", "--ambient", "-v", "--verbose", "--help", "-h"], MIRROR_VALUE_FLAGS);
1172
1398
  const positionals = requirePositionalCount(a, MIRROR_VALUE_FLAGS, {
1173
1399
  min: 0, max: 1, command: "run402 repos mirror [<destination>]", missing: "",
1174
1400
  });
@@ -1185,26 +1411,57 @@ async function mirror(args) {
1185
1411
  }
1186
1412
  const target = await vaultTarget(a);
1187
1413
  if (destination != null) return mirrorSet(target, destination, a);
1188
- if (off) return mirrorOff(target);
1189
- if (backfill) return mirrorBackfill(target);
1190
- return mirrorRead(target);
1414
+ if (off) return mirrorOff(target, a);
1415
+ if (backfill) return mirrorBackfill(target, a);
1416
+ return mirrorRead(target, a);
1191
1417
  }
1192
1418
 
1193
1419
  // ─── fsck (verify the head chain + materialize refs) ──────────────────
1194
1420
 
1421
+ /** `repos fsck --human`: the same verdict the stderr lines already carry, condensed into one block. */
1422
+ function formatFsckHuman(result, mirrorRequested) {
1423
+ const lines = [`Repo: ${result.repo_id}`];
1424
+ lines.push(
1425
+ !result.write
1426
+ ? `Verified through generation ${result.verified_to_generation} — audit mode, nothing local was persisted.`
1427
+ : result.local_state_changed
1428
+ ? `Verified through generation ${result.verified_to_generation} — local pin advanced from ${result.pin_before.highest_authenticated ?? "genesis"} to ${result.pin_after.highest_authenticated}.`
1429
+ : `Verified through generation ${result.verified_to_generation} — already at the newest verified generation.`,
1430
+ );
1431
+ if (mirrorRequested && result.mirror) {
1432
+ lines.push(
1433
+ `Mirror: recoverable generation ${result.mirror.recovered_generation}` +
1434
+ (result.mirror.chain_break ? ` (chain break at ${result.mirror.chain_break.generation}: ${result.mirror.chain_break.reason})` : "") +
1435
+ (result.mirror.data_loss_detected ? ` — DATA LOSS DETECTED (${result.mirror.absences.filter((x) => x.adjudication === "unexplained_absence").length} unexplained absence(s))` : ""),
1436
+ );
1437
+ }
1438
+ return lines.join("\n");
1439
+ }
1440
+
1195
1441
  async function fsck(args) {
1196
1442
  const a = normalizeArgv(args);
1197
1443
  const valueFlags = [...COMMON_VALUE_FLAGS, "--budget"];
1198
- assertKnownFlags(a, [...valueFlags, "--mirror", "--no-write", "--help", "-h"], valueFlags);
1444
+ assertKnownFlags(a, [...valueFlags, "--mirror", "--no-write", "--human", "-v", "--verbose", "--help", "-h"], valueFlags);
1199
1445
  requirePositionalCount(a, valueFlags, { min: 0, max: 0, command: "run402 repos fsck", missing: "" });
1446
+ const human = a.includes("--human");
1447
+ if (human && a.includes("--json")) {
1448
+ fail({ code: "BAD_USAGE", message: "--human cannot be combined with --json.", details: { flags: a.filter((arg) => arg === "--human" || arg === "--json") } });
1449
+ }
1450
+ const sdk = getSdk();
1200
1451
  const target = await vaultTarget(a);
1201
1452
  const budget = flagValue(a, "--budget");
1202
1453
  if (budget != null) target.verification_budget = parseIntegerFlag("--budget", budget, { min: 1 });
1203
1454
  const write = !a.includes("--no-write");
1204
1455
  const mirrorRequested = a.includes("--mirror");
1205
1456
  try {
1206
- const result = await getSdk().gitvault.fsck({ ...target, write, mirror: mirrorRequested });
1207
- console.log(JSON.stringify(result, null, 2));
1457
+ const result = await sdk.gitvault.fsck({ ...target, write, mirror: mirrorRequested });
1458
+ if (human) {
1459
+ console.log(formatFsckHuman(result, mirrorRequested));
1460
+ if (mirrorRequested && result.mirror) printMirrorHonesty(result.mirror);
1461
+ printVerboseStats(a, sdk);
1462
+ return;
1463
+ }
1464
+ printJson(sdk, result);
1208
1465
  await spillIfLarge(result.repo_id, "fsck", result);
1209
1466
  if (!write) {
1210
1467
  console.error(`--no-write: verified through generation ${result.verified_to_generation} — nothing local was persisted (pin_before === pin_after).`);
@@ -1220,6 +1477,7 @@ async function fsck(args) {
1220
1477
  }
1221
1478
  printMirrorHonesty(result.mirror);
1222
1479
  }
1480
+ printVerboseStats(a, sdk);
1223
1481
  } catch (err) {
1224
1482
  reportSdkError(err);
1225
1483
  }
@@ -1231,8 +1489,9 @@ const GC_VALUE_FLAGS = [...COMMON_VALUE_FLAGS, "--intent-core", "--verifier-rece
1231
1489
 
1232
1490
  async function gc(args) {
1233
1491
  const a = normalizeArgv(args);
1234
- assertKnownFlags(a, [...GC_VALUE_FLAGS, "--submit", "--wait", "--help", "-h"], GC_VALUE_FLAGS);
1492
+ assertKnownFlags(a, [...GC_VALUE_FLAGS, "--submit", "--wait", "-v", "--verbose", "--help", "-h"], GC_VALUE_FLAGS);
1235
1493
  requirePositionalCount(a, GC_VALUE_FLAGS, { min: 0, max: 0, command: "run402 repos gc", missing: "" });
1494
+ const sdk = getSdk();
1236
1495
  const submitting = a.includes("--submit");
1237
1496
  const corePath = flagValue(a, "--intent-core");
1238
1497
  const receiptPath = flagValue(a, "--verifier-receipt");
@@ -1252,9 +1511,9 @@ async function gc(args) {
1252
1511
  if (submitting) {
1253
1512
  const opts = { ...target, submit: { core: readJsonFile("--intent-core", corePath), verifier_receipt: readJsonFile("--verifier-receipt", receiptPath) } };
1254
1513
  if (a.includes("--wait")) opts.submit.wait = {};
1255
- const prune = await getSdk().gitvault.prune(opts);
1514
+ const prune = await sdk.gitvault.prune(opts);
1256
1515
  const out = { phase: "submitted", prune };
1257
- console.log(JSON.stringify(out, null, 2));
1516
+ printJson(sdk, out);
1258
1517
  if (prune.confirmation?.outcome) {
1259
1518
  console.error(
1260
1519
  `submitted — the signed completion reports ${prune.confirmation.deleted.length} deleted, ` +
@@ -1265,11 +1524,12 @@ async function gc(args) {
1265
1524
  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
1525
  }
1267
1526
  console.error(prune.note);
1527
+ printVerboseStats(a, sdk);
1268
1528
  return;
1269
1529
  }
1270
1530
 
1271
- const checkpoint = await getSdk().gitvault.compact(target);
1272
- const prune = await getSdk().gitvault.prune(target);
1531
+ const checkpoint = await sdk.gitvault.compact(target);
1532
+ const prune = await sdk.gitvault.prune(target);
1273
1533
  const nextActions = [];
1274
1534
  if (!prune.blocked_reason && prune.object_candidates.length > 0) {
1275
1535
  // Additive fields beyond the CLI's usual {type, command, why}: the
@@ -1285,7 +1545,7 @@ async function gc(args) {
1285
1545
  });
1286
1546
  }
1287
1547
  const out = { phase: "planned", checkpoint, prune, next_actions: nextActions };
1288
- console.log(JSON.stringify(out, null, 2));
1548
+ printJson(sdk, out);
1289
1549
  console.error(`checkpoint published at generation ${checkpoint.generation}: ${checkpoint.covered_refs} ref(s), ${checkpoint.covered_roots} retention root(s).`);
1290
1550
  if (!checkpoint.cutoff_bound) {
1291
1551
  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 +1563,7 @@ async function gc(args) {
1303
1563
  }
1304
1564
  }
1305
1565
  console.error("`gc` is NOT \"exactly git gc\" — the deletion ceremony is stricter: nothing is removed until a control-plane-signed completion confirms it.");
1566
+ printVerboseStats(a, sdk);
1306
1567
  } catch (err) {
1307
1568
  reportSdkError(err);
1308
1569
  }
@@ -1310,14 +1571,40 @@ async function gc(args) {
1310
1571
 
1311
1572
  // ─── access (read-only; repair gated) ──────────────────────
1312
1573
 
1574
+ /** `repos access --human`: a compact roster of directory recipients and their coverage. */
1575
+ function formatAccessHuman(result) {
1576
+ const lines = [`Repo: ${result.repo_id}`];
1577
+ lines.push(`Recipients: ${result.recipients.length} directory, ${result.recipients.filter((r) => r.covered).length} covered`);
1578
+ for (const r of result.recipients) {
1579
+ lines.push(` ${r.covered ? "covered" : "NOT covered"} ${r.display_name ?? r.principal_id}${r.envelope_state ? ` (${r.envelope_state})` : ""}`);
1580
+ }
1581
+ if (result.this_keystore) lines.push(`This machine's own keystore also covers (writing principal, not in org directory): ${result.this_keystore.fingerprint}`);
1582
+ if (result.unmatched_covered_fingerprints.length > 0) lines.push(`Orphaned/external coverage: ${result.unmatched_covered_fingerprints.join(", ")}`);
1583
+ if (Array.isArray(result.stale_access) && result.stale_access.length > 0) {
1584
+ lines.push(`Stale access (removed members that still decrypt): ${result.stale_access.map((s) => s.display_name ?? s.principal_id).join(", ")}`);
1585
+ }
1586
+ lines.push(result.gap);
1587
+ return lines.join("\n");
1588
+ }
1589
+
1313
1590
  async function accessRead(args) {
1314
1591
  const a = normalizeArgv(args);
1315
- assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--help", "-h"], COMMON_VALUE_FLAGS);
1592
+ assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--human", "-v", "--verbose", "--help", "-h"], COMMON_VALUE_FLAGS);
1316
1593
  requirePositionalCount(a, COMMON_VALUE_FLAGS, { min: 0, max: 0, command: "run402 repos access", missing: "" });
1594
+ const human = a.includes("--human");
1595
+ if (human && a.includes("--json")) {
1596
+ fail({ code: "BAD_USAGE", message: "--human cannot be combined with --json.", details: { flags: a.filter((arg) => arg === "--human" || arg === "--json") } });
1597
+ }
1598
+ const sdk = getSdk();
1317
1599
  const target = await vaultTarget(a);
1318
1600
  try {
1319
- const result = await getSdk().gitvault.access(target);
1320
- console.log(JSON.stringify(result, null, 2));
1601
+ const result = await sdk.gitvault.access(target);
1602
+ if (human) {
1603
+ console.log(formatAccessHuman(result));
1604
+ printVerboseStats(a, sdk);
1605
+ return;
1606
+ }
1607
+ printJson(sdk, result);
1321
1608
  await spillIfLarge(result.repo_id, "access", result);
1322
1609
  console.error(`${result.recipients.length} directory recipient(s), ${result.recipients.filter((r) => r.covered).length} covered on this repo.`);
1323
1610
  if (result.this_keystore) {
@@ -1331,35 +1618,183 @@ async function accessRead(args) {
1331
1618
  console.error(`${result.stale_access.length} removed member(s) STILL decrypt this vault (not yet revocable — no epoch rotation in v0): ${names}`);
1332
1619
  }
1333
1620
  console.error(result.gap);
1621
+ printVerboseStats(a, sdk);
1334
1622
  } catch (err) {
1335
1623
  reportSdkError(err);
1336
1624
  }
1337
1625
  }
1338
1626
 
1627
+ const ROTATION_VALUE_FLAGS = [...COMMON_VALUE_FLAGS, "--recipient-state-version", "--recipient-revocation-version", "--idempotency-key"];
1628
+
1629
+ /**
1630
+ * `run402 repos access repair` (D193-D203, rev 42) — a general re-key of
1631
+ * this vault's CURRENT epoch, dropping every principal in `stale_access`
1632
+ * (`pending_removal`, still covered) and clearing a pre-existing vault's
1633
+ * one-time migration requirement. Drives `rotateEpoch({reason:"elective_rekey"})`.
1634
+ *
1635
+ * `--recipient-state-version`/`--recipient-revocation-version` are the D194
1636
+ * frozen watermarks this attempt must be fenced against. They are NOT
1637
+ * discovered automatically here: the live gateway exposes NO general read
1638
+ * route for `internal.gitvault_recipient_state_counters` outside the
1639
+ * `key-revocation` declare route's own response (see
1640
+ * `GitvaultVault.rotateEpoch`'s doc comment, `sdk/src/node/gitvault-
1641
+ * publication.ts`, for the confirmed source-level finding). Until that
1642
+ * route ships, this verb needs the pair supplied explicitly — refusing
1643
+ * cleanly and naming exactly this when they are omitted, rather than
1644
+ * guessing and either failing opaquely or (worse) never converging.
1645
+ *
1646
+ * **`elective_rekey` refuses ANY exclusion** (`EPOCH_ROTATION_INCOMPLETE_ENROLLMENT`
1647
+ * on even one keyless/unconfirmed desired principal) — so a pending
1648
+ * `/confirm`/`/repin` receipt does NOT help here: folding it into THIS
1649
+ * rotation's `pending_confirmations` still leaves that principal
1650
+ * `excluded_unconfirmed` for THIS rotation (D196 — same-head manifest
1651
+ * updates never self-authorize), which `elective_rekey`'s own
1652
+ * completeness check then refuses on. If a directory principal is
1653
+ * unconfirmed when this vault needs to clear its migration requirement,
1654
+ * use `run402 repos access revoke-key`/`declare-exposure` instead (an
1655
+ * urgent reason, which admits with a nonempty partial target set) and
1656
+ * fold the pending receipt into THAT rotation.
1657
+ */
1339
1658
  async function accessRepair(args) {
1340
1659
  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." })],
1660
+ assertKnownFlags(a, [...ROTATION_VALUE_FLAGS, "-v", "--verbose", "--help", "-h"], ROTATION_VALUE_FLAGS);
1661
+ requirePositionalCount(a, ROTATION_VALUE_FLAGS, { min: 0, max: 0, command: "run402 repos access repair", missing: "" });
1662
+ const recipientStateVersion = flagValue(a, "--recipient-state-version");
1663
+ const recipientRevocationVersion = flagValue(a, "--recipient-revocation-version");
1664
+ if (recipientStateVersion == null || recipientRevocationVersion == null) {
1665
+ fail({
1666
+ code: "ROTATION_COUNTERS_REQUIRED",
1667
+ 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.",
1668
+ 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.",
1669
+ 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" })],
1670
+ });
1671
+ }
1672
+ const sdk = getSdk();
1673
+ const target = await vaultTarget(a);
1674
+ try {
1675
+ const result = await sdk.gitvault.rotateEpoch({
1676
+ ...target,
1677
+ reason: "elective_rekey",
1678
+ recipient_state_version: recipientStateVersion,
1679
+ recipient_revocation_version: recipientRevocationVersion,
1680
+ ...(flagValue(a, "--idempotency-key") != null ? { client_idempotency_key: flagValue(a, "--idempotency-key") } : {}),
1681
+ });
1682
+ printJson(sdk, result);
1683
+ await spillIfLarge(result.rotation_id, "access-repair", result);
1684
+ 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.`);
1685
+ 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)"}.`);
1686
+ printVerboseStats(a, sdk);
1687
+ } catch (err) {
1688
+ reportSdkError(err);
1689
+ }
1690
+ }
1691
+
1692
+ /**
1693
+ * `run402 repos access revoke-key <principal_id>` (D199) — the ONE fully
1694
+ * self-contained rotation entry point: declares
1695
+ * `reason:"recipient_key_revoked"` for `principal_id` (owner + step-up)
1696
+ * and drives the rotation off that declaration's OWN returned counters.
1697
+ * No flags needed — this is the reason value with a real, working
1698
+ * gateway-side counter read.
1699
+ */
1700
+ async function accessRevokeKey(args) {
1701
+ const a = normalizeArgv(args);
1702
+ assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--idempotency-key", "-v", "--verbose", "--help", "-h"], [...COMMON_VALUE_FLAGS, "--idempotency-key"]);
1703
+ const [principalId] = requirePositionalCount(a, [...COMMON_VALUE_FLAGS, "--idempotency-key"], {
1704
+ min: 1, max: 1, command: "run402 repos access revoke-key <principal_id>",
1705
+ 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
1706
  });
1707
+ const sdk = getSdk();
1708
+ const target = await vaultTarget(a);
1709
+ try {
1710
+ const result = await sdk.gitvault.rotateEpochForKeyRevocation(principalId, {
1711
+ ...target,
1712
+ ...(flagValue(a, "--idempotency-key") != null ? { client_idempotency_key: flagValue(a, "--idempotency-key") } : {}),
1713
+ });
1714
+ printJson(sdk, result);
1715
+ await spillIfLarge(result.rotation_id, "access-revoke-key", result);
1716
+ 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.`);
1717
+ console.error(`self_check: ${result.self_check}.`);
1718
+ printVerboseStats(a, sdk);
1719
+ } catch (err) {
1720
+ reportSdkError(err);
1721
+ }
1722
+ }
1723
+
1724
+ /**
1725
+ * `run402 repos access declare-exposure` (D199) — declares
1726
+ * `reason:"epoch_secret_exposed"` admissible for THIS vault (owner +
1727
+ * step-up), vault-scoped (one vault's leaked key is not evidence any
1728
+ * sibling vault is compromised). The DECLARATION itself is real and
1729
+ * self-contained; the FOLLOW-UP rotation it authorizes is NOT auto-run
1730
+ * here, because — same confirmed gap as `access repair` — the D194
1731
+ * counters it must be fenced against have no client-visible read for this
1732
+ * reason value either. This is the rekey remedy the exposed-key incident
1733
+ * needs: declare here, then rotate (via `--recipient-state-version`/
1734
+ * `--recipient-revocation-version` once known, e.g. from platform staff).
1735
+ *
1736
+ * **If a `/confirm`/`/repin` receipt is already pending** (a directory
1737
+ * principal was confirmed BEFORE this declaration, or gets confirmed while
1738
+ * the rotation is outstanding), do NOT call `publishPinManifestUpdate`
1739
+ * separately — that call is itself an ORDINARY admission and is itself
1740
+ * refused `EPOCH_ROTATION_REQUIRED` for as long as this declaration stays
1741
+ * outstanding (reproduced live in production 2026-08-27). Pass the receipt
1742
+ * to `r.gitvault.rotateEpoch({..., pending_confirmations: [{principal_id,
1743
+ * ek_fingerprint, receipt}]})` instead — it rides the SAME head as the
1744
+ * rotation this declaration requires, publishing durably without needing a
1745
+ * second, separately-gated admission. See `GitvaultVault.rotateEpoch`'s
1746
+ * doc comment for what this does NOT do: the folded principal is still
1747
+ * excluded from THIS rotation's own envelope set (D196) and becomes
1748
+ * eligible starting at the NEXT rotation.
1749
+ */
1750
+ async function accessDeclareExposure(args) {
1751
+ const a = normalizeArgv(args);
1752
+ assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "-v", "--verbose", "--help", "-h"], COMMON_VALUE_FLAGS);
1753
+ requirePositionalCount(a, COMMON_VALUE_FLAGS, { min: 0, max: 0, command: "run402 repos access declare-exposure", missing: "" });
1754
+ const target = await vaultTarget(a);
1755
+ try {
1756
+ const sdk = getSdk();
1757
+ const repoId = target.repo_id ?? (await sdk.gitvault.forProject(target.project_id)).repo_id;
1758
+ const result = await sdk.gitvault.declareEpochSecretExposed(repoId);
1759
+ printJson(sdk, result);
1760
+ console.error(`declared epoch_secret_exposed for ${repoId} (epoch_secret_exposure_version now ${result.epoch_secret_exposure_version}).`);
1761
+ 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.");
1762
+ 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`).");
1763
+ 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.");
1764
+ printVerboseStats(a, sdk);
1765
+ } catch (err) {
1766
+ reportSdkError(err);
1767
+ }
1349
1768
  }
1350
1769
 
1351
1770
  async function access(args) {
1352
1771
  const a = normalizeArgv(args);
1353
1772
  if (a[0] === "repair") return accessRepair(a.slice(1));
1773
+ if (a[0] === "revoke-key") return accessRevokeKey(a.slice(1));
1774
+ if (a[0] === "declare-exposure") return accessDeclareExposure(a.slice(1));
1354
1775
  return accessRead(a);
1355
1776
  }
1356
1777
 
1357
1778
  // ─── recover ─────────────────────
1358
1779
 
1780
+ /** `repos recover --human`: the same verdict the stderr lines already carry, condensed into one block. */
1781
+ function formatRecoverHuman(result, outDir) {
1782
+ const lines = [
1783
+ `Repo: ${result.repo_id}`,
1784
+ `Recovered generation ${result.recovered_generation} into ${outDir}` +
1785
+ (result.chain_break ? ` (chain break at ${result.chain_break.generation} — fell back to the newest fully-verified generation)` : "") + ".",
1786
+ ];
1787
+ if (result.data_loss_detected) {
1788
+ lines.push(`DATA LOSS DETECTED: ${result.absences.filter((x) => x.adjudication === "unexplained_absence").length} object(s) are unexplained absences.`);
1789
+ }
1790
+ lines.push(`Layout: ${result.layout}` + (result.layout === "bare" ? " (no working files — not a failed recovery)" : ""));
1791
+ return lines.join("\n");
1792
+ }
1793
+
1359
1794
  async function recover(args) {
1360
1795
  const a = normalizeArgv(args);
1361
1796
  const valueFlags = ["--out", "--repo", "--profile", "--region", "--endpoint"];
1362
- assertKnownFlags(a, [...valueFlags, "--ambient", "--help", "-h"], valueFlags);
1797
+ assertKnownFlags(a, [...valueFlags, "--ambient", "--human", "-v", "--verbose", "--help", "-h"], valueFlags);
1363
1798
  const [source] = requirePositionalCount(a, valueFlags, {
1364
1799
  min: 1, max: 1, command: "run402 repos recover <source> --out <dir>",
1365
1800
  missing: "Missing <source>. Expected s3://<bucket>[/<prefix>] or a directory path.",
@@ -1368,19 +1803,31 @@ async function recover(args) {
1368
1803
  if (outDir == null) {
1369
1804
  fail({ code: "BAD_USAGE", message: "run402 repos recover needs --out <dir>.", hint: "Where to materialize the recovered repository, e.g. --out ./restored" });
1370
1805
  }
1806
+ const human = a.includes("--human");
1807
+ if (human && a.includes("--json")) {
1808
+ fail({ code: "BAD_USAGE", message: "--human cannot be combined with --json.", details: { flags: a.filter((arg) => arg === "--human" || arg === "--json") } });
1809
+ }
1810
+ const sdk = getSdk();
1371
1811
  const credential = resolveMirrorCredential(a);
1372
1812
  const repoId = flagValue(a, "--repo");
1373
1813
  const region = flagValue(a, "--region");
1374
1814
  const endpoint = flagValue(a, "--endpoint");
1375
1815
  try {
1376
- const result = await getSdk().gitvault.recover({
1816
+ const result = await sdk.gitvault.recover({
1377
1817
  source, out_dir: outDir,
1378
1818
  ...(repoId != null ? { repo_id: repoId } : {}),
1379
1819
  ...(credential ? { credential } : {}),
1380
1820
  ...(region != null ? { region } : {}),
1381
1821
  ...(endpoint != null ? { endpoint } : {}),
1382
1822
  });
1383
- console.log(JSON.stringify(result, null, 2));
1823
+ if (human) {
1824
+ console.log(formatRecoverHuman(result, outDir));
1825
+ if (result.layout === "bare") for (const n of result.next_actions ?? []) console.error(`next: ${n.action} — ${n.command}`);
1826
+ printMirrorHonesty(result);
1827
+ printVerboseStats(a, sdk);
1828
+ return;
1829
+ }
1830
+ printJson(sdk, result);
1384
1831
  await spillIfLarge(result.repo_id, "recover", result);
1385
1832
  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
1833
  if (result.data_loss_detected) {
@@ -1391,6 +1838,7 @@ async function recover(args) {
1391
1838
  for (const n of result.next_actions ?? []) console.error(`next: ${n.action} — ${n.command}`);
1392
1839
  }
1393
1840
  printMirrorHonesty(result);
1841
+ printVerboseStats(a, sdk);
1394
1842
  } catch (err) {
1395
1843
  reportSdkError(err);
1396
1844
  }