run402 4.41.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 (71) hide show
  1. package/README.md +2 -2
  2. package/lib/argparse.mjs +1 -1
  3. package/lib/assets.mjs +10 -14
  4. package/lib/command-manifest.mjs +28 -27
  5. package/lib/deploy-v2.mjs +34 -14
  6. package/lib/doctor.mjs +8 -1
  7. package/lib/gitvault-target.mjs +7 -9
  8. package/lib/gitvault.mjs +7 -8
  9. package/lib/next-actions.mjs +5 -2
  10. package/lib/notifications.mjs +4 -8
  11. package/lib/operator.mjs +1 -1
  12. package/lib/org.mjs +3 -3
  13. package/lib/repos.mjs +536 -164
  14. package/lib/rooms-context.mjs +2 -3
  15. package/lib/rooms.mjs +11 -14
  16. package/lib/stats.mjs +57 -0
  17. package/lib/update-check.mjs +6 -8
  18. package/package.json +1 -1
  19. package/sdk/dist/index.d.ts +10 -1
  20. package/sdk/dist/index.d.ts.map +1 -1
  21. package/sdk/dist/index.js +11 -0
  22. package/sdk/dist/index.js.map +1 -1
  23. package/sdk/dist/kernel.d.ts +31 -0
  24. package/sdk/dist/kernel.d.ts.map +1 -1
  25. package/sdk/dist/kernel.js +116 -3
  26. package/sdk/dist/kernel.js.map +1 -1
  27. package/sdk/dist/namespaces/admin.d.ts +1 -1
  28. package/sdk/dist/namespaces/admin.js +1 -1
  29. package/sdk/dist/namespaces/deploy.types.d.ts +2 -2
  30. package/sdk/dist/namespaces/gitvault.crypto.d.ts +188 -5
  31. package/sdk/dist/namespaces/gitvault.crypto.d.ts.map +1 -1
  32. package/sdk/dist/namespaces/gitvault.crypto.js +277 -8
  33. package/sdk/dist/namespaces/gitvault.crypto.js.map +1 -1
  34. package/sdk/dist/namespaces/gitvault.d.ts +239 -44
  35. package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
  36. package/sdk/dist/namespaces/gitvault.js +327 -49
  37. package/sdk/dist/namespaces/gitvault.js.map +1 -1
  38. package/sdk/dist/namespaces/gitvault.types.d.ts +184 -9
  39. package/sdk/dist/namespaces/gitvault.types.d.ts.map +1 -1
  40. package/sdk/dist/namespaces/gitvault.types.js +2 -1
  41. package/sdk/dist/namespaces/gitvault.types.js.map +1 -1
  42. package/sdk/dist/namespaces/projects.d.ts +1 -1
  43. package/sdk/dist/namespaces/projects.js +1 -1
  44. package/sdk/dist/namespaces/projects.types.d.ts +1 -1
  45. package/sdk/dist/namespaces/projects.types.d.ts.map +1 -1
  46. package/sdk/dist/node/gitvault-apply.d.ts +8 -0
  47. package/sdk/dist/node/gitvault-apply.d.ts.map +1 -1
  48. package/sdk/dist/node/gitvault-apply.js +1 -0
  49. package/sdk/dist/node/gitvault-apply.js.map +1 -1
  50. package/sdk/dist/node/gitvault-deploy.d.ts +13 -0
  51. package/sdk/dist/node/gitvault-deploy.d.ts.map +1 -1
  52. package/sdk/dist/node/gitvault-deploy.js +28 -0
  53. package/sdk/dist/node/gitvault-deploy.js.map +1 -1
  54. package/sdk/dist/node/gitvault-keystore.d.ts +54 -4
  55. package/sdk/dist/node/gitvault-keystore.d.ts.map +1 -1
  56. package/sdk/dist/node/gitvault-keystore.js +20 -0
  57. package/sdk/dist/node/gitvault-keystore.js.map +1 -1
  58. package/sdk/dist/node/gitvault-publication.d.ts +376 -21
  59. package/sdk/dist/node/gitvault-publication.d.ts.map +1 -1
  60. package/sdk/dist/node/gitvault-publication.js +567 -19
  61. package/sdk/dist/node/gitvault-publication.js.map +1 -1
  62. package/sdk/dist/node/gitvault-recover.d.ts +15 -1
  63. package/sdk/dist/node/gitvault-recover.d.ts.map +1 -1
  64. package/sdk/dist/node/gitvault-recover.js +8 -4
  65. package/sdk/dist/node/gitvault-recover.js.map +1 -1
  66. package/sdk/dist/node/gitvault-snapshot.d.ts +40 -7
  67. package/sdk/dist/node/gitvault-snapshot.d.ts.map +1 -1
  68. package/sdk/dist/node/gitvault-snapshot.js +67 -19
  69. package/sdk/dist/node/gitvault-snapshot.js.map +1 -1
  70. package/sdk/dist/scoped.d.ts +2 -2
  71. package/sdk/dist/scoped.js +1 -1
package/lib/repos.mjs CHANGED
@@ -1,27 +1,25 @@
1
1
  /**
2
- * `run402 repos` — the consolidated encrypted-repository family
3
- * (repo-surface-consolidation). One noun, twelve verbs, each one either a
4
- * `gh repo` verb, a `git` verb meaning what it means in git, or a plain-
5
- * English verb for an operation with no analog (design D2). `repo` singular
6
- * resolves identically (`cli.mjs` dispatches both spellings here).
2
+ * `run402 repos` — the consolidated encrypted-repository family. One noun,
3
+ * twelve verbs, each one either a `gh repo` verb, a `git` verb meaning what
4
+ * it means in git, or a plain-English verb for an operation with no analog.
5
+ * `repo` singular resolves identically (`cli.mjs` dispatches both spellings
6
+ * here).
7
7
  *
8
- * ARCHITECTURAL LAW (unchanged from `gitvault.mjs`, which this module
9
- * replaces as the family's CLI home): every piece of protocol behavior —
10
- * crypto core, keystore, creation journal, snapshot + capture, publication
11
- * state machines, ref transactions, verification budget, repair lives
12
- * ONCE in `@run402/sdk` under `r.gitvault` (the SDK KEEPS that name; it is
13
- * infrastructure language see design D1). This module is a THIN ADAPTER:
14
- * argument parsing, TTY output, exit codes, local file I/O. It adds zero
15
- * protocol behavior of its own.
8
+ * ARCHITECTURAL LAW: every piece of protocol behavior — crypto core,
9
+ * keystore, creation journal, snapshot + capture, publication state
10
+ * machines, ref transactions, verification budget, repair lives ONCE in
11
+ * `@run402/sdk` under `r.gitvault` (the SDK keeps that name; it is
12
+ * infrastructure language). This module is a THIN ADAPTER: argument
13
+ * parsing, TTY output, exit codes, local file I/O. It adds zero protocol
14
+ * behavior of its own.
16
15
  *
17
16
  * Pipe contract (docs/style.md): the payload is JSON on stdout; every human
18
17
  * line (progress, the terminal-loss statement, advisories) goes to stderr,
19
18
  * so `run402 repos view | jq` stays clean.
20
19
  *
21
- * The nineteen-command `gitvault` family this replaces (D7): its dispatcher
22
- * has RETIRED see `cli/lib/gitvault.mjs`, now a tombstone that answers
23
- * every old spelling with a typed `COMMAND_MOVED` (naming its `repos`
24
- * successor) or `COMMAND_REMOVED` (for `reconcile`, which has none) error.
20
+ * `cli/lib/gitvault.mjs` is a tombstone that answers every `gitvault <verb>`
21
+ * spelling with a typed `COMMAND_MOVED` (naming its `repos` successor) or
22
+ * `COMMAND_REMOVED` (for `reconcile`, which has none) error.
25
23
  */
26
24
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
27
25
  import { basename, join } from "node:path";
@@ -35,6 +33,7 @@ import { resolveGitvaultTarget } from "./gitvault-target.mjs";
35
33
  import { nextAction, claimOrgSlugAction, claimRepoNameAction } from "./next-actions.mjs";
36
34
  import { printKeystoreLocation } from "./gitvault.mjs";
37
35
  import { gitvaultRemoteUrlForRepo } from "#sdk";
36
+ import { sdkStats, printVerboseStats } from "./stats.mjs";
38
37
  import {
39
38
  normalizeArgv,
40
39
  hasHelp,
@@ -57,34 +56,39 @@ Usage:
57
56
  Common:
58
57
  run402 repos create [name] [--org <org_id>] [--dir <path>] [--tier <tier>] [--project <id>]
59
58
  run402 repos view [--project <id>] [--repo <repo_id>] [--human]
60
- run402 repos list [--org <org_id>]
59
+ run402 repos list [--org <org_id>] [--human]
61
60
 
62
61
  Then plain git, forever:
63
62
  git push
64
63
  git clone run402::<org>/<repo>
65
64
 
66
65
  Occasional:
67
- 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]
68
67
  run402 repos mirror [<destination>] [--off] [--backfill] [--profile <name> | --ambient] [--region <r>] [--endpoint <url>] [--project <id>] [--repo <repo_id>]
69
- 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]
70
69
 
71
70
  Lifecycle:
72
71
  run402 repos rename <new_name> [--repo <repo_id> | --project <project_id>]
73
72
  run402 repos delete [--project <id>] [--repo <repo_id>] [--force]
74
73
 
75
74
  Maintenance:
76
- 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]
77
76
  run402 repos gc [--project <id>] [--repo <repo_id>] [--submit --intent-core <path> --verifier-receipt <path> [--wait]]
78
- run402 repos access [--project <id>] [--repo <repo_id>]
79
- 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>]
80
81
  run402 repos policy <required|grandfathered> [--project <id>] [--repo <repo_id>] [--reason <why>]
81
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
+
82
87
  Subcommands:
83
88
  create Provision (or, with --project, ADOPT an existing project), ALLOCATE
84
89
  its vault (mints key material and, on first allocation, a one-shot
85
90
  recovery receipt), and scaffold the git remote — origin when free,
86
- run402 when taken (design D1). Absorbs the old \`gitvault init\`:
87
- \`--project <id>\` allocates for a project that already exists,
91
+ run402 when taken. \`--project <id>\` allocates for a project that already exists,
88
92
  nothing is provisioned. \`[name]\` is inferred from an existing git
89
93
  remote's basename or the directory name when unambiguous — NEVER a
90
94
  prompt; if the directory and an existing remote disagree, or
@@ -96,7 +100,7 @@ Subcommands:
96
100
  believe about the repo — allocation, policy, whether this keystore
97
101
  can sign, the authenticated and materialized pins, the mirror
98
102
  summary (when one is configured), and where the keystore lives.
99
- NEVER materializes refs or advances any local pin (design D3)
103
+ NEVER materializes refs or advances any local pin —
100
104
  that belongs to \`fsck\`, which is why \`refs\` reports
101
105
  {known:false, reason:"not_materialized"} with a next_action
102
106
  pointing there. \`--human\` renders a short summary instead of JSON.
@@ -104,12 +108,13 @@ Subcommands:
104
108
  vaults-by-org read when the gateway has it (one round trip);
105
109
  gracefully falls back to the older per-project walk when it
106
110
  404s. Not every project in the org — ones with no vault are
107
- omitted.
111
+ omitted. \`--human\` renders a compact roster (address,
112
+ generation, bytes, policy) instead of JSON.
108
113
  rename Claim or rename the repo's per-org-unique, address-form name
109
- (the <name> half of run402::<org-slug>/<name>) absorbs the old
110
- \`repos name\`. Address by --repo or --project (not both).
114
+ (the <name> half of run402::<org-slug>/<name>). Address by
115
+ --repo or --project (not both).
111
116
  delete Deletes a REPO-ONLY project — database, functions, subdomains,
112
- mailbox, and secrets must all be absent (design D9). When any of
117
+ mailbox, and secrets must all be absent. When any of
113
118
  them is materialized, this REFUSES with
114
119
  PROJECT_HAS_NON_REPO_RESOURCES, enumerates refused_resources, and
115
120
  points at \`run402 projects delete\` — the verb whose name says
@@ -123,7 +128,13 @@ Subcommands:
123
128
  publishing. Push-to-creates through a slug-form remote
124
129
  (run402::<org-slug>/<name>) the same way \`git push\` does.
125
130
  \`--dry-run\` previews the real local pipeline without publishing.
126
- mirror ONE flag-driven verb (design D4) for the client-side, customer-
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
+ mirror ONE flag-driven verb for the client-side, customer-
127
138
  owned ciphertext mirror — run402 never holds a credential to it.
128
139
  No argument: READ the configured destination + a keyless
129
140
  freshness check against the live vault. \`<destination>\`:
@@ -139,9 +150,9 @@ Subcommands:
139
150
  path (normal retrieval is plain \`git clone run402::<org>/<repo>\`,
140
151
  no \`repos clone\` verb exists). Proves this mirror's validity,
141
152
  never freshness — read both honesty statements before relying on
142
- the result.
143
- fsck Walks the head chain (what \`verify\` used to do) AND materializes
144
- the ref map (what \`status --refs\` used to do), advancing BOTH
153
+ the result. \`--human\` renders a short summary instead of JSON.
154
+ fsck Walks the head chain AND materializes
155
+ the ref map, advancing BOTH
145
156
  local trust pins — reported EXPLICITLY as local_state_changed +
146
157
  pin_before + pin_after, never implied. \`--no-write\` is a genuine
147
158
  audit mode: the same real walk and decrypt, computing the same
@@ -150,6 +161,7 @@ Subcommands:
150
161
  normal writing mode, so a budget-exceeded run resumes). \`--mirror\`
151
162
  additionally runs the keyless mirror integrity probe — it proves
152
163
  the mirror's VALIDITY, never its FRESHNESS, and says so.
164
+ \`--human\` renders a short summary instead of JSON.
153
165
  gc \`git gc\`'s own two halves — checkpoint publication (compact) and
154
166
  prune planning — in one verb, NOT described as "exactly git gc":
155
167
  the deletion ceremony is stricter. Plans and checkpoints by
@@ -162,18 +174,41 @@ Subcommands:
162
174
  safe_to_auto_execute:false as ADDITIVE fields.
163
175
  access READ-ONLY: the org's directory of encryption-key-holding members,
164
176
  which of the vault's current envelope-recipient fingerprints are
165
- covered, and (best-effort, this machine only) each principal's
166
- local TOFU pin. Reports an HONEST gap rather than inventing:
167
- per-recipient envelope_state (converged/pending) and
168
- history_scope are not yet exposed by the gateway — that lands
169
- with gitvault-human-envelopes' epoch-rotation work.
177
+ covered, per-recipient envelope_state (converged/pending/
178
+ pending_removal, from the gateway's desired-recipient-state
179
+ substrate), and (best-effort, this machine only) each principal's
180
+ local TOFU pin. stale_access names removed members whose access
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).
170
187
  access repair
171
- NOT YET AVAILABLE gated on the epoch-rotation mechanism above
172
- landing. \`reconcile\`, the workaround it replaces, is REMOVED
173
- (design D5/D7): it never wrapped a key correctly-scoped to "from
174
- here forward," and a temporary mechanism does not get a
175
- permanent verb. This refuses cleanly and points at \`repos
176
- 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.
177
212
  policy Set the activation policy — \`required\` (a deploy must present a
178
213
  vaulted capture) or \`grandfathered\` (it need not). Owner +
179
214
  step-up, audited. \`grandfathered\` is the documented way out of a
@@ -192,9 +227,17 @@ Options:
192
227
  --idempotency-key <key>
193
228
  create: re-running with the same key resolves to the
194
229
  same project instead of creating a second one — new
195
- projects only (default: derived from the name)
196
- --human view: a short summary on stdout instead of the JSON dump.
197
- 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.
198
241
  --force delete: proceed even though the repo holds generations
199
242
  that would be permanently and irrecoverably lost. Never
200
243
  overrides the non-repo-infrastructure refusal.
@@ -202,7 +245,13 @@ Options:
202
245
  tree produces (a clean tree pushes HEAD itself, unused)
203
246
  --checkpoint snapshot: force the checkpoint-bearing form regardless of delta size
204
247
  --dry-run snapshot: a REAL preview — runs the actual local pipeline
205
- 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.
206
255
  --off mirror: remove the configured destination (config only)
207
256
  --backfill mirror: copy every object the configured mirror is missing
208
257
  --profile <name> mirror / recover: the AWS credential profile name for an
@@ -231,6 +280,10 @@ Options:
231
280
  signed completion appears, instead of returning immediately
232
281
  --reason <why> policy: why the policy is changing — recorded in the
233
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.
234
287
  --json No-op: stdout is already JSON.
235
288
 
236
289
  Terminal loss (protocol §0):
@@ -244,24 +297,24 @@ Examples:
244
297
  run402 repos create --project prj_1a2b3c # allocate for an existing project
245
298
  git push -u origin HEAD # the printed next_action, verbatim
246
299
  run402 repos view --human
247
- run402 repos list --org org_1a2b3c
300
+ run402 repos list --org org_1a2b3c --human
248
301
  run402 repos rename my-notes --project prj_1a2b3c
249
302
  run402 repos snapshot --dry-run
303
+ run402 repos snapshot --allow-dirty
250
304
  run402 repos mirror s3://acme-vault-mirror --profile acme
251
305
  run402 repos mirror --backfill
252
- run402 repos fsck --mirror
306
+ run402 repos fsck --mirror --human
253
307
  run402 repos gc
254
- run402 repos access
255
- 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
256
310
  run402 repos delete --project prj_xyz --force
257
311
  `;
258
312
 
259
- // ─── shared targeting + printing (ported from the retired gitvault.mjs) ────
313
+ // ─── shared targeting + printing ────
260
314
 
261
315
  /**
262
- * Resolve which repo to act on, plus the local git tree. Identical
263
- * resolution order the old `gitvault.mjs` used (design change: none
264
- * naming only): explicit `--repo`/`--project` > the repo's own pin/remote
316
+ * Resolve which repo to act on, plus the local git tree. Resolution order:
317
+ * explicit `--repo`/`--project` > the repo's own pin/remote
265
318
  * > RUN402_PROJECT_ID > the active project.
266
319
  */
267
320
  async function vaultTarget(a) {
@@ -282,15 +335,40 @@ async function vaultTarget(a) {
282
335
  return target;
283
336
  }
284
337
 
285
- /** Print the protocol §0 terminal-loss statement, verbatim, from the SDK's own constants — never paraphrased here. */
338
+ /**
339
+ * Print the protocol §0 terminal-loss statement, verbatim, from the SDK's own
340
+ * constants — never paraphrased here. The SDK's `status()` already downgrades
341
+ * this to the durability sentence when it has locally proven the vault has
342
+ * >= 2 covering recipients (dogfood item 2: the single-principal terminal-loss
343
+ * claim is false for that vault) — this function only renders whichever
344
+ * statement the SDK selected, it never chooses between them itself.
345
+ */
286
346
  function printTerminalLoss(status) {
287
347
  console.error("");
288
- console.error(status.terminal_loss_statement);
289
- console.error(status.terminal_loss_detail);
348
+ if (status.terminal_loss_statement) {
349
+ console.error(status.terminal_loss_statement);
350
+ console.error(status.terminal_loss_detail);
351
+ } else if (status.durability_statement) {
352
+ console.error(status.durability_statement);
353
+ if (status.covering_recipients != null) console.error(`covering_recipients: ${status.covering_recipients}`);
354
+ }
290
355
  console.error(`Back up this directory: ${status.keystore.root}`);
291
356
  console.error("");
292
357
  }
293
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
+
294
372
  const LARGE_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
295
373
 
296
374
  /**
@@ -299,8 +377,7 @@ const LARGE_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
299
377
  * to a private 0600 file with a one-line stderr breadcrumb naming the path.
300
378
  * Best-effort. Deliberately NEVER called on `create`'s result — that JSON
301
379
  * carries the one-shot recovery receipt, and a secret-bearing response is
302
- * never spilled into any cache path (agent-response-design's secrets rule,
303
- * design D10).
380
+ * never spilled into any cache path (agent-response-design's secrets rule).
304
381
  */
305
382
  async function spillIfLarge(repoId, verb, payload) {
306
383
  const json = JSON.stringify(payload, null, 2);
@@ -400,7 +477,7 @@ async function formatRepoHuman(s, mirror) {
400
477
  return lines.join("\n");
401
478
  }
402
479
 
403
- // ─── name inference (design D10 — create's delight pass) ──────────────────
480
+ // ─── name inference ──────────────────
404
481
 
405
482
  function validateProjectName(name) {
406
483
  if (name === "") {
@@ -416,8 +493,8 @@ function validateProjectName(name) {
416
493
  }
417
494
 
418
495
  /**
419
- * Best-effort slugify for the address-form repo name (design D6's grammar:
420
- * lowercase [a-z0-9-], no leading/trailing/double hyphen, <=63 chars).
496
+ * Best-effort slugify for the address-form repo name (the address-form
497
+ * grammar: lowercase [a-z0-9-], no leading/trailing/double hyphen, <=63 chars).
421
498
  */
422
499
  function slugifyRepoName(name) {
423
500
  return name
@@ -458,7 +535,7 @@ function dirBasenameCandidate(dir) {
458
535
  }
459
536
 
460
537
  /**
461
- * `repos create [name]`'s inference (design D10): the directory or an
538
+ * `repos create [name]`'s inference: the directory or an
462
539
  * existing git remote's basename, when unambiguous. NEVER a prompt —
463
540
  * ambiguity (the two candidates disagree) or a dead end (neither yields a
464
541
  * usable slug) is a structured error naming exactly one next_action.
@@ -494,17 +571,17 @@ async function inferRepoName(dir) {
494
571
 
495
572
  const CREATE_VALUE_FLAGS = ["--org", "--dir", "--tier", "--idempotency-key", "--project"];
496
573
 
497
- async function printCreateResult({ projectId, vault, adopted, name }) {
574
+ async function printCreateResult({ sdk, projectId, vault, adopted, name, verboseArgv }) {
498
575
  let address = null;
499
576
  let orgSlug = null;
500
577
  try {
501
578
  const owningOrg = await resolveOwningOrgId(projectId);
502
- const orgRecord = owningOrg ? await getSdk().org(owningOrg).get() : null;
579
+ const orgRecord = owningOrg ? await sdk.org(owningOrg).get() : null;
503
580
  orgSlug = orgRecord?.slug ?? null;
504
581
  if (orgSlug && name) {
505
582
  const candidate = slugifyRepoName(name);
506
583
  if (candidate) {
507
- const named = await getSdk().projects.setRepoName(projectId, candidate);
584
+ const named = await sdk.projects.setRepoName(projectId, candidate);
508
585
  address = gitvaultRemoteUrlForRepo(orgSlug, named.repo_name);
509
586
  }
510
587
  }
@@ -533,13 +610,13 @@ async function printCreateResult({ projectId, vault, adopted, name }) {
533
610
  deployed: false,
534
611
  next_actions: nextActions,
535
612
  };
536
- console.log(JSON.stringify(out, null, 2));
613
+ printJson(sdk, out);
537
614
  console.error(
538
615
  `project ${projectId} ${adopted ? "adopted" : "provisioned"}; repo ${vault.repo_id} ` +
539
616
  (vault.deduplicated ? "already existed — nothing was re-allocated" : `allocated (genesis ${vault.genesis_sha256})`),
540
617
  );
541
618
  if (address) console.error(`address: ${address}`);
542
- 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");
543
620
  else console.error(`no address claimed — run 'run402 repos rename <name> --project ${projectId}' to claim one`);
544
621
  if (vault.remote) console.error(`remote '${vault.remote.name}' -> ${vault.remote.url} (${vault.remote.reason})`);
545
622
  if (pushAction) console.error(`next: ${pushAction.command}`);
@@ -548,9 +625,11 @@ async function printCreateResult({ projectId, vault, adopted, name }) {
548
625
  await printKeystoreLocation();
549
626
  console.error("");
550
627
  console.error("nothing was deployed — this is a vault-only repo. Deploy later with `run402 deploy apply`, or never.");
628
+ printVerboseStats(verboseArgv, sdk);
551
629
  }
552
630
 
553
631
  async function createAdopt(projectId, dir, a) {
632
+ const sdk = getSdk();
554
633
  const orgId = flagValue(a, "--org") ?? await resolveOwningOrgId(projectId);
555
634
  if (!orgId) {
556
635
  fail({
@@ -561,14 +640,15 @@ async function createAdopt(projectId, dir, a) {
561
640
  });
562
641
  }
563
642
  try {
564
- const vault = await getSdk().gitvault.init({ org_id: orgId, project_id: projectId, repo_dir: dir });
565
- 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 });
566
645
  } catch (err) {
567
646
  reportSdkError(err);
568
647
  }
569
648
  }
570
649
 
571
650
  async function createProvision(name, dir, a) {
651
+ const sdk = getSdk();
572
652
  const tier = flagValue(a, "--tier") ?? "prototype";
573
653
  const idempotencyKey = flagValue(a, "--idempotency-key") ?? `repos-create:${name}`;
574
654
  // `optional: true` — a fresh wallet with no org yet is the cold-start path
@@ -580,7 +660,7 @@ async function createProvision(name, dir, a) {
580
660
  let provisioned;
581
661
  try {
582
662
  provisioned = await withAutoApprove(() =>
583
- getSdk().projects.provision({ tier, name, ...(orgId ? { orgId } : {}), idempotencyKey }),
663
+ sdk.projects.provision({ tier, name, ...(orgId ? { orgId } : {}), idempotencyKey }),
584
664
  );
585
665
  } catch (err) {
586
666
  reportSdkError(err);
@@ -599,8 +679,8 @@ async function createProvision(name, dir, a) {
599
679
  }
600
680
 
601
681
  try {
602
- const vault = await getSdk().gitvault.init({ org_id: effectiveOrgId, project_id: provisioned.project_id, repo_dir: dir });
603
- 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 });
604
684
  } catch (err) {
605
685
  reportSdkError(err);
606
686
  }
@@ -608,7 +688,7 @@ async function createProvision(name, dir, a) {
608
688
 
609
689
  async function create(args) {
610
690
  const a = normalizeArgv(args);
611
- assertKnownFlags(a, [...CREATE_VALUE_FLAGS, "--help", "-h"], CREATE_VALUE_FLAGS);
691
+ assertKnownFlags(a, [...CREATE_VALUE_FLAGS, "--help", "-h", "-v", "--verbose"], CREATE_VALUE_FLAGS);
612
692
  const positionals = requirePositionalCount(a, CREATE_VALUE_FLAGS, {
613
693
  min: 0, max: 1, command: "run402 repos create [name]", missing: "",
614
694
  });
@@ -642,8 +722,8 @@ async function create(args) {
642
722
  // ─── list ───────────────────────────────────────────────────────────────────
643
723
 
644
724
  /** The FROZEN bulk-read shape (task 2.4) — one round trip. */
645
- async function listViaBulkRead(orgId) {
646
- const result = await getSdk().gitvault.listByOrg(orgId);
725
+ async function listViaBulkRead(sdk, orgId) {
726
+ const result = await sdk.gitvault.listByOrg(orgId);
647
727
  return Array.isArray(result.vaults) ? result.vaults : [];
648
728
  }
649
729
 
@@ -654,14 +734,14 @@ async function listViaBulkRead(orgId) {
654
734
  * function once the bulk route has shipped long enough that no gateway
655
735
  * still 404s it.
656
736
  */
657
- async function listViaFallback(orgId) {
658
- const result = await getSdk().projects.list({ org: orgId });
737
+ async function listViaFallback(sdk, orgId) {
738
+ const result = await sdk.projects.list({ org: orgId });
659
739
  const projects = Array.isArray(result.projects) ? result.projects : [];
660
740
  const repos = [];
661
741
  for (const p of projects) {
662
742
  let status;
663
743
  try {
664
- status = await getSdk().gitvault.status({ project_id: p.id });
744
+ status = await sdk.gitvault.status({ project_id: p.id });
665
745
  } catch {
666
746
  continue;
667
747
  }
@@ -682,21 +762,38 @@ async function listViaFallback(orgId) {
682
762
  return repos;
683
763
  }
684
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
+
685
777
  async function list(args) {
686
778
  const a = normalizeArgv(args);
687
- assertKnownFlags(a, ["--org", "--help", "-h"], ["--org"]);
779
+ assertKnownFlags(a, ["--org", "--human", "-v", "--verbose", "--help", "-h"], ["--org"]);
688
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();
689
786
  const orgId = await resolveOrgId(a, { cmd: "repos" });
690
787
 
691
788
  let repos;
692
789
  let usedFallback = false;
693
790
  try {
694
- repos = await listViaBulkRead(orgId);
791
+ repos = await listViaBulkRead(sdk, orgId);
695
792
  } catch (err) {
696
793
  if (err?.status === 404) {
697
794
  usedFallback = true;
698
795
  try {
699
- repos = await listViaFallback(orgId);
796
+ repos = await listViaFallback(sdk, orgId);
700
797
  } catch (fallbackErr) {
701
798
  reportSdkError(fallbackErr);
702
799
  return;
@@ -710,42 +807,50 @@ async function list(args) {
710
807
  let orgSlug = repos.find((r) => r.org_slug)?.org_slug ?? null;
711
808
  if (orgSlug == null) {
712
809
  try {
713
- orgSlug = (await getSdk().org(orgId).get()).slug;
810
+ orgSlug = (await sdk.org(orgId).get()).slug;
714
811
  } catch {
715
812
  // best-effort — `list` must not fail over an org-slug lookup
716
813
  }
717
814
  }
718
815
 
719
- 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 });
720
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)" : ""}`);
721
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);
722
825
  }
723
826
 
724
827
  // ─── view ───────────────────────────────────────────────────────────────────
725
828
 
726
829
  async function view(args) {
727
830
  const a = normalizeArgv(args);
728
- 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);
729
832
  requirePositionalCount(a, COMMON_VALUE_FLAGS, { min: 0, max: 0, command: "run402 repos view", missing: "" });
730
833
  const human = a.includes("--human");
731
834
  if (human && a.includes("--json")) {
732
835
  fail({ code: "BAD_USAGE", message: "--human cannot be combined with --json.", details: { flags: a.filter((arg) => arg === "--human" || arg === "--json") } });
733
836
  }
837
+ const sdk = getSdk();
734
838
  const target = await vaultTarget(a);
735
839
  try {
736
840
  // Design D3: `view` NEVER passes `refs: true` — it is side-effect-free
737
841
  // by construction, not by convention. Materialization belongs to `fsck`.
738
- const s = await getSdk().gitvault.status(target);
842
+ const s = await sdk.gitvault.status(target);
739
843
  let mirror = null;
740
844
  if (s.repo_id) {
741
845
  try {
742
- mirror = await getSdk().gitvault.mirrorStatus({ ...target, repo_id: s.repo_id });
846
+ mirror = await sdk.gitvault.mirrorStatus({ ...target, repo_id: s.repo_id });
743
847
  } catch {
744
848
  // best-effort — a mirror read failure never fails `view`
745
849
  }
746
850
  }
747
851
  if (human) {
748
852
  console.log(await formatRepoHuman(s, mirror));
853
+ printVerboseStats(a, sdk);
749
854
  return;
750
855
  }
751
856
  const verifyRefsAction = nextAction("verify_refs", { command: "run402 repos fsck", why: "Walk the signed chain and materialize verified refs." });
@@ -756,7 +861,7 @@ async function view(args) {
756
861
  mirror,
757
862
  next_actions: combinedNextActions,
758
863
  };
759
- console.log(JSON.stringify(out, null, 2));
864
+ printJson(sdk, out);
760
865
  printTerminalLoss(s);
761
866
  if (s.remote) {
762
867
  const suffix =
@@ -774,6 +879,7 @@ async function view(args) {
774
879
  }
775
880
  for (const w of s.warnings) console.error(`warning (${w.kind}): ${w.message}`);
776
881
  for (const n of combinedNextActions) console.error(`next: ${n.why ?? n.action ?? n.type}${n.command ? ` — ${n.command}` : ""}`);
882
+ printVerboseStats(a, sdk);
777
883
  } catch (err) {
778
884
  reportSdkError(err);
779
885
  }
@@ -783,11 +889,12 @@ async function view(args) {
783
889
 
784
890
  async function rename(args) {
785
891
  const a = normalizeArgv(args);
786
- assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--help", "-h"], COMMON_VALUE_FLAGS);
892
+ assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "-v", "--verbose", "--help", "-h"], COMMON_VALUE_FLAGS);
787
893
  const [repoName] = requirePositionalCount(a, COMMON_VALUE_FLAGS, {
788
894
  min: 1, max: 1, command: "run402 repos rename <new_name> [--repo <repo_id> | --project <project_id>]",
789
895
  missing: "run402 repos rename <new_name>: a new name is required",
790
896
  });
897
+ const sdk = getSdk();
791
898
  const repoFlag = flagValue(a, "--repo");
792
899
  const projectFlag = flagValue(a, "--project");
793
900
  if (repoFlag != null && projectFlag != null) {
@@ -796,7 +903,7 @@ async function rename(args) {
796
903
  let projectId;
797
904
  if (repoFlag != null) {
798
905
  try {
799
- projectId = (await getSdk().gitvault.get(repoFlag)).project_id;
906
+ projectId = (await sdk.gitvault.get(repoFlag)).project_id;
800
907
  } catch (err) {
801
908
  reportSdkError(err);
802
909
  return;
@@ -805,16 +912,16 @@ async function rename(args) {
805
912
  projectId = resolveProjectId(projectFlag);
806
913
  }
807
914
  try {
808
- const result = await getSdk().projects.setRepoName(projectId, repoName);
915
+ const result = await sdk.projects.setRepoName(projectId, repoName);
809
916
  let address = null;
810
917
  try {
811
918
  const owningOrg = await resolveOwningOrgId(projectId);
812
- const orgSlug = owningOrg ? (await getSdk().org(owningOrg).get()).slug : null;
919
+ const orgSlug = owningOrg ? (await sdk.org(owningOrg).get()).slug : null;
813
920
  if (orgSlug) address = gitvaultRemoteUrlForRepo(orgSlug, result.repo_name);
814
921
  } catch {
815
922
  // The claim itself already succeeded — a failed address-preview lookup is never fatal.
816
923
  }
817
- console.log(JSON.stringify({ ...result, address }, null, 2));
924
+ printJson(sdk, { ...result, address });
818
925
  console.error(
819
926
  result.previous_repo_name && result.previous_repo_name !== result.repo_name
820
927
  ? `renamed from "${result.previous_repo_name}" to "${result.repo_name}"`
@@ -822,12 +929,13 @@ async function rename(args) {
822
929
  );
823
930
  if (address) console.error(`address: ${address}`);
824
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);
825
933
  } catch (err) {
826
934
  reportSdkError(err);
827
935
  }
828
936
  }
829
937
 
830
- // ─── delete (design D9) ─────────────────────────────────────────────────────
938
+ // ─── delete ─────────────────────────────────────────────────────
831
939
 
832
940
  /** One non-repo-resource read, `null` when absent (including a clean 404), an entry when present or genuinely unverifiable. */
833
941
  async function checkResource(read, resourceName, countOf) {
@@ -850,22 +958,22 @@ async function checkResource(read, resourceName, countOf) {
850
958
  * that fails for a reason OTHER than "genuinely absent" (404) is reported
851
959
  * `unknown` and REFUSES delete too — D9 never guesses its way to yes.
852
960
  */
853
- async function checkNonRepoResources(projectId) {
961
+ async function checkNonRepoResources(sdk, projectId) {
854
962
  const refused = [];
855
963
  try {
856
- const detail = await getSdk().projects.get(projectId);
964
+ const detail = await sdk.projects.get(projectId);
857
965
  if (Array.isArray(detail.mailbox) && detail.mailbox.length > 0) refused.push({ resource: "mailbox", status: "present", count: detail.mailbox.length });
858
966
  if (Array.isArray(detail.custom_domains) && detail.custom_domains.length > 0) refused.push({ resource: "custom_domains", status: "present", count: detail.custom_domains.length });
859
967
  } catch (err) {
860
968
  refused.push({ resource: "project_detail", status: "unknown", reason: err?.message ?? String(err) });
861
969
  }
862
- 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));
863
971
  if (schema) refused.push(schema);
864
- 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));
865
973
  if (functions) refused.push(functions);
866
- 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));
867
975
  if (secrets) refused.push(secrets);
868
- 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));
869
977
  if (subdomains) refused.push(subdomains);
870
978
  return refused;
871
979
  }
@@ -880,7 +988,8 @@ function stripFlag(args, flag) {
880
988
 
881
989
  async function del(args) {
882
990
  const a = normalizeArgv(args);
883
- 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();
884
993
  const repoFlag = flagValue(a, "--repo");
885
994
  let projectId;
886
995
  let rest;
@@ -889,7 +998,7 @@ async function del(args) {
889
998
  fail({ code: "BAD_USAGE", message: "pass --repo or --project, not both." });
890
999
  }
891
1000
  try {
892
- projectId = (await getSdk().gitvault.get(repoFlag)).project_id;
1001
+ projectId = (await sdk.gitvault.get(repoFlag)).project_id;
893
1002
  } catch (err) {
894
1003
  reportSdkError(err);
895
1004
  return;
@@ -903,7 +1012,7 @@ async function del(args) {
903
1012
 
904
1013
  let status;
905
1014
  try {
906
- status = await getSdk().gitvault.status({ project_id: projectId });
1015
+ status = await sdk.gitvault.status({ project_id: projectId });
907
1016
  } catch (err) {
908
1017
  reportSdkError(err);
909
1018
  return;
@@ -914,7 +1023,7 @@ async function del(args) {
914
1023
 
915
1024
  // D9, checked FIRST and unconditionally: --force below overrides only the
916
1025
  // vault-history confirmation, never this refusal.
917
- const refusedResources = await checkNonRepoResources(projectId);
1026
+ const refusedResources = await checkNonRepoResources(sdk, projectId);
918
1027
  if (refusedResources.length > 0) {
919
1028
  fail({
920
1029
  code: "PROJECT_HAS_NON_REPO_RESOURCES",
@@ -944,13 +1053,14 @@ async function del(args) {
944
1053
  }
945
1054
 
946
1055
  try {
947
- await getSdk().projects.delete(projectId);
948
- console.log(JSON.stringify({
1056
+ await sdk.projects.delete(projectId);
1057
+ printJson(sdk, {
949
1058
  project_id: projectId,
950
1059
  deleted: true,
951
1060
  deleted_resources: ["project", ...(vault ? ["vault_history"] : [])],
952
1061
  vault: vault ? { repo_id: status.repo_id, admitted_generations: admittedGenerations, source_bytes: sourceBytes } : null,
953
- }, null, 2));
1062
+ });
1063
+ printVerboseStats(a, sdk);
954
1064
  } catch (err) {
955
1065
  reportSdkError(err);
956
1066
  }
@@ -985,12 +1095,26 @@ async function detectSlugFormRemote(a, repoDir) {
985
1095
  return null;
986
1096
  }
987
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
+
988
1110
  async function snapshot(args) {
989
1111
  const a = normalizeArgv(args);
990
- 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);
991
1113
  requirePositionalCount(a, SNAPSHOT_VALUE_FLAGS, { min: 0, max: 0, command: "run402 repos snapshot", missing: "" });
1114
+ const sdk = getSdk();
992
1115
  const dryRun = a.includes("--dry-run");
993
1116
  const message = flagValue(a, "--message");
1117
+ const allowDirty = a.includes("--allow-dirty");
994
1118
  const repoDir = process.cwd();
995
1119
  const address = await detectSlugFormRemote(a, repoDir);
996
1120
  const target = address ? { repo_dir: repoDir } : await vaultTarget(a);
@@ -1008,12 +1132,15 @@ async function snapshot(args) {
1008
1132
  console.error("");
1009
1133
  },
1010
1134
  };
1011
- 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;
1012
1139
  if (a.includes("--checkpoint")) opts.checkpoint = true;
1013
1140
  try {
1014
1141
  if (dryRun) {
1015
- const plan = await getSdk().gitvault.planPush(opts);
1016
- console.log(JSON.stringify(plan, null, 2));
1142
+ const plan = await sdk.gitvault.planPush(opts);
1143
+ printJson(sdk, plan);
1017
1144
  if (plan.allocation_needed) {
1018
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");
1019
1146
  } else {
@@ -1022,16 +1149,20 @@ async function snapshot(args) {
1022
1149
  `${plan.object_count} object(s), ${plan.encrypted_bytes} encrypted byte(s) (${plan.raw_bytes} raw)`,
1023
1150
  );
1024
1151
  }
1152
+ printDirtyDisclosure(plan.snapshot);
1153
+ printVerboseStats(a, sdk);
1025
1154
  return;
1026
1155
  }
1027
- const result = await getSdk().gitvault.push(opts);
1028
- console.log(JSON.stringify(result, null, 2));
1156
+ const result = await sdk.gitvault.push(opts);
1157
+ printJson(sdk, result);
1029
1158
  console.error(`published generation ${result.generation} (${result.form})`);
1030
1159
  if (result.mirror_push?.outcome === "pushed") {
1031
1160
  console.error(`mirror: pushed generation ${result.generation} (${result.mirror_push.summary?.objects_copied ?? 0} object(s) copied)`);
1032
1161
  } else if (result.mirror_push?.outcome === "failed") {
1033
1162
  console.error(`mirror: dual-push FAILED (deploy is unaffected) — ${result.mirror_push.error ?? "see mirror_push.summary.errors"}`);
1034
1163
  }
1164
+ printDirtyDisclosure(result.snapshot);
1165
+ printVerboseStats(a, sdk);
1035
1166
  } catch (err) {
1036
1167
  reportSdkError(err);
1037
1168
  }
@@ -1042,7 +1173,7 @@ async function snapshot(args) {
1042
1173
  async function policy(args) {
1043
1174
  const a = normalizeArgv(args);
1044
1175
  const valueFlags = [...COMMON_VALUE_FLAGS, "--reason"];
1045
- assertKnownFlags(a, [...valueFlags, "--help", "-h"], valueFlags);
1176
+ assertKnownFlags(a, [...valueFlags, "-v", "--verbose", "--help", "-h"], valueFlags);
1046
1177
  const [requested] = requirePositionalCount(a, valueFlags, {
1047
1178
  min: 1, max: 1, command: "run402 repos policy <required|grandfathered>",
1048
1179
  missing: "Missing <policy>. Expected `required` or `grandfathered`.",
@@ -1070,26 +1201,28 @@ async function policy(args) {
1070
1201
  const sdk = getSdk();
1071
1202
  const repoId = target.repo_id ?? (await sdk.gitvault.forProject(target.project_id)).repo_id;
1072
1203
  const result = await sdk.gitvault.setPolicy(repoId, { gitvault_policy: requested, ...(reason != null ? { reason } : {}) });
1073
- console.log(JSON.stringify({ repo_id: repoId, ...result }, null, 2));
1204
+ printJson(sdk, { repo_id: repoId, ...result });
1074
1205
  console.error(
1075
1206
  result.changed
1076
1207
  ? `gitvault_policy is now ${result.gitvault_policy} (version ${result.gitvault_policy_version})`
1077
1208
  : `gitvault_policy was already ${result.gitvault_policy} — nothing changed`,
1078
1209
  );
1079
1210
  for (const w of result.warnings ?? []) console.error(`warning (${w.kind}): ${w.message}`);
1211
+ printVerboseStats(a, sdk);
1080
1212
  } catch (err) {
1081
1213
  reportSdkError(err);
1082
1214
  }
1083
1215
  }
1084
1216
 
1085
- // ─── mirror (design D4 — ONE flag-driven verb) ─────────────────────────────
1217
+ // ─── mirror (ONE flag-driven verb) ─────────────────────────────
1086
1218
 
1087
1219
  const MIRROR_VALUE_FLAGS = [...COMMON_VALUE_FLAGS, "--profile", "--region", "--endpoint"];
1088
1220
 
1089
- async function mirrorRead(target) {
1221
+ async function mirrorRead(target, a) {
1222
+ const sdk = getSdk();
1090
1223
  try {
1091
- const result = await getSdk().gitvault.mirrorStatus(target);
1092
- console.log(JSON.stringify(result, null, 2));
1224
+ const result = await sdk.gitvault.mirrorStatus(target);
1225
+ printJson(sdk, result);
1093
1226
  if (!result.configured) {
1094
1227
  console.error(`no mirror configured for ${result.repo_id}. Configure one: run402 repos mirror <destination>`);
1095
1228
  } else {
@@ -1097,49 +1230,55 @@ async function mirrorRead(target) {
1097
1230
  console.error(`mirror ${result.destination}: mirrored generation ${result.mirrored_generation ?? "(none)"}, vault newest ${result.newest_generation ?? "(none)"} — ${currency}`);
1098
1231
  }
1099
1232
  printMirrorHonesty(result);
1233
+ printVerboseStats(a, sdk);
1100
1234
  } catch (err) {
1101
1235
  reportSdkError(err);
1102
1236
  }
1103
1237
  }
1104
1238
 
1105
1239
  async function mirrorSet(target, destination, a) {
1240
+ const sdk = getSdk();
1106
1241
  const credential = resolveMirrorCredential(a);
1107
1242
  const region = flagValue(a, "--region");
1108
1243
  const endpoint = flagValue(a, "--endpoint");
1109
1244
  try {
1110
- const result = await getSdk().gitvault.mirrorSet({
1245
+ const result = await sdk.gitvault.mirrorSet({
1111
1246
  ...target,
1112
1247
  destination_url: destination,
1113
1248
  ...(credential ? { credential } : {}),
1114
1249
  ...(region != null ? { region } : {}),
1115
1250
  ...(endpoint != null ? { endpoint } : {}),
1116
1251
  });
1117
- console.log(JSON.stringify(result, null, 2));
1252
+ printJson(sdk, result);
1118
1253
  console.error(`mirror configured for ${result.repo_id} -> ${formatMirrorDestination(result.destination)}`);
1119
1254
  console.error("run `run402 repos mirror --backfill` to catch it up now, then every publish dual-pushes automatically.");
1255
+ printVerboseStats(a, sdk);
1120
1256
  } catch (err) {
1121
1257
  reportSdkError(err);
1122
1258
  }
1123
1259
  }
1124
1260
 
1125
- async function mirrorOff(target) {
1261
+ async function mirrorOff(target, a) {
1262
+ const sdk = getSdk();
1126
1263
  try {
1127
- const result = await getSdk().gitvault.mirrorRemove(target);
1128
- console.log(JSON.stringify(result, null, 2));
1264
+ const result = await sdk.gitvault.mirrorRemove(target);
1265
+ printJson(sdk, result);
1129
1266
  console.error(
1130
1267
  result.removed
1131
1268
  ? `mirror config removed for ${result.repo_id} — the mirror's OWN bytes were not touched`
1132
1269
  : `no mirror was configured for ${result.repo_id} — nothing to remove`,
1133
1270
  );
1271
+ printVerboseStats(a, sdk);
1134
1272
  } catch (err) {
1135
1273
  reportSdkError(err);
1136
1274
  }
1137
1275
  }
1138
1276
 
1139
- async function mirrorBackfill(target) {
1277
+ async function mirrorBackfill(target, a) {
1278
+ const sdk = getSdk();
1140
1279
  try {
1141
- const result = await getSdk().gitvault.mirrorSync(target);
1142
- console.log(JSON.stringify(result, null, 2));
1280
+ const result = await sdk.gitvault.mirrorSync(target);
1281
+ printJson(sdk, result);
1143
1282
  await spillIfLarge(result.repo_id, "mirror-backfill", result);
1144
1283
  console.error(
1145
1284
  `mirror backfill for ${result.repo_id}: ${result.objects_copied} copied, ${result.objects_already_present} already present` +
@@ -1148,6 +1287,7 @@ async function mirrorBackfill(target) {
1148
1287
  );
1149
1288
  for (const e of result.errors) console.error(` failed: ${e.key} — ${e.error}`);
1150
1289
  printMirrorHonesty(result);
1290
+ printVerboseStats(a, sdk);
1151
1291
  } catch (err) {
1152
1292
  reportSdkError(err);
1153
1293
  }
@@ -1155,7 +1295,7 @@ async function mirrorBackfill(target) {
1155
1295
 
1156
1296
  async function mirror(args) {
1157
1297
  const a = normalizeArgv(args);
1158
- 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);
1159
1299
  const positionals = requirePositionalCount(a, MIRROR_VALUE_FLAGS, {
1160
1300
  min: 0, max: 1, command: "run402 repos mirror [<destination>]", missing: "",
1161
1301
  });
@@ -1172,26 +1312,57 @@ async function mirror(args) {
1172
1312
  }
1173
1313
  const target = await vaultTarget(a);
1174
1314
  if (destination != null) return mirrorSet(target, destination, a);
1175
- if (off) return mirrorOff(target);
1176
- if (backfill) return mirrorBackfill(target);
1177
- return mirrorRead(target);
1315
+ if (off) return mirrorOff(target, a);
1316
+ if (backfill) return mirrorBackfill(target, a);
1317
+ return mirrorRead(target, a);
1178
1318
  }
1179
1319
 
1180
- // ─── fsck (design D2/D3 absorbs verify + mirror verify) ──────────────────
1320
+ // ─── fsck (verify the head chain + materialize refs) ──────────────────
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
+ }
1181
1341
 
1182
1342
  async function fsck(args) {
1183
1343
  const a = normalizeArgv(args);
1184
1344
  const valueFlags = [...COMMON_VALUE_FLAGS, "--budget"];
1185
- assertKnownFlags(a, [...valueFlags, "--mirror", "--no-write", "--help", "-h"], valueFlags);
1345
+ assertKnownFlags(a, [...valueFlags, "--mirror", "--no-write", "--human", "-v", "--verbose", "--help", "-h"], valueFlags);
1186
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();
1187
1352
  const target = await vaultTarget(a);
1188
1353
  const budget = flagValue(a, "--budget");
1189
1354
  if (budget != null) target.verification_budget = parseIntegerFlag("--budget", budget, { min: 1 });
1190
1355
  const write = !a.includes("--no-write");
1191
1356
  const mirrorRequested = a.includes("--mirror");
1192
1357
  try {
1193
- const result = await getSdk().gitvault.fsck({ ...target, write, mirror: mirrorRequested });
1194
- 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);
1195
1366
  await spillIfLarge(result.repo_id, "fsck", result);
1196
1367
  if (!write) {
1197
1368
  console.error(`--no-write: verified through generation ${result.verified_to_generation} — nothing local was persisted (pin_before === pin_after).`);
@@ -1207,19 +1378,21 @@ async function fsck(args) {
1207
1378
  }
1208
1379
  printMirrorHonesty(result.mirror);
1209
1380
  }
1381
+ printVerboseStats(a, sdk);
1210
1382
  } catch (err) {
1211
1383
  reportSdkError(err);
1212
1384
  }
1213
1385
  }
1214
1386
 
1215
- // ─── gc (design D2 — absorbs compact + prune) ──────────────────────────────
1387
+ // ─── gc (checkpoint + prune) ──────────────────────────────
1216
1388
 
1217
1389
  const GC_VALUE_FLAGS = [...COMMON_VALUE_FLAGS, "--intent-core", "--verifier-receipt"];
1218
1390
 
1219
1391
  async function gc(args) {
1220
1392
  const a = normalizeArgv(args);
1221
- 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);
1222
1394
  requirePositionalCount(a, GC_VALUE_FLAGS, { min: 0, max: 0, command: "run402 repos gc", missing: "" });
1395
+ const sdk = getSdk();
1223
1396
  const submitting = a.includes("--submit");
1224
1397
  const corePath = flagValue(a, "--intent-core");
1225
1398
  const receiptPath = flagValue(a, "--verifier-receipt");
@@ -1239,9 +1412,9 @@ async function gc(args) {
1239
1412
  if (submitting) {
1240
1413
  const opts = { ...target, submit: { core: readJsonFile("--intent-core", corePath), verifier_receipt: readJsonFile("--verifier-receipt", receiptPath) } };
1241
1414
  if (a.includes("--wait")) opts.submit.wait = {};
1242
- const prune = await getSdk().gitvault.prune(opts);
1415
+ const prune = await sdk.gitvault.prune(opts);
1243
1416
  const out = { phase: "submitted", prune };
1244
- console.log(JSON.stringify(out, null, 2));
1417
+ printJson(sdk, out);
1245
1418
  if (prune.confirmation?.outcome) {
1246
1419
  console.error(
1247
1420
  `submitted — the signed completion reports ${prune.confirmation.deleted.length} deleted, ` +
@@ -1252,17 +1425,17 @@ async function gc(args) {
1252
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.");
1253
1426
  }
1254
1427
  console.error(prune.note);
1428
+ printVerboseStats(a, sdk);
1255
1429
  return;
1256
1430
  }
1257
1431
 
1258
- const checkpoint = await getSdk().gitvault.compact(target);
1259
- const prune = await getSdk().gitvault.prune(target);
1432
+ const checkpoint = await sdk.gitvault.compact(target);
1433
+ const prune = await sdk.gitvault.prune(target);
1260
1434
  const nextActions = [];
1261
1435
  if (!prune.blocked_reason && prune.object_candidates.length > 0) {
1262
1436
  // Additive fields beyond the CLI's usual {type, command, why}: the
1263
- // external review's explicit ask (design D2 clause 5) `gc` is never
1264
- // described as "exactly git gc," and its submit next_action must say
1265
- // so structurally, not just in prose.
1437
+ // `gc` is never described as "exactly git gc," and its submit
1438
+ // next_action must say so structurally, not just in prose.
1266
1439
  nextActions.push({
1267
1440
  type: "submit_gc",
1268
1441
  command: "run402 repos gc --submit --intent-core <core.json> --verifier-receipt <receipt.json>",
@@ -1273,7 +1446,7 @@ async function gc(args) {
1273
1446
  });
1274
1447
  }
1275
1448
  const out = { phase: "planned", checkpoint, prune, next_actions: nextActions };
1276
- console.log(JSON.stringify(out, null, 2));
1449
+ printJson(sdk, out);
1277
1450
  console.error(`checkpoint published at generation ${checkpoint.generation}: ${checkpoint.covered_refs} ref(s), ${checkpoint.covered_roots} retention root(s).`);
1278
1451
  if (!checkpoint.cutoff_bound) {
1279
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.");
@@ -1291,56 +1464,238 @@ async function gc(args) {
1291
1464
  }
1292
1465
  }
1293
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);
1294
1468
  } catch (err) {
1295
1469
  reportSdkError(err);
1296
1470
  }
1297
1471
  }
1298
1472
 
1299
- // ─── access (design D5/D10 — read-only; repair gated) ──────────────────────
1473
+ // ─── access (read-only; repair gated) ──────────────────────
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
+ }
1300
1490
 
1301
1491
  async function accessRead(args) {
1302
1492
  const a = normalizeArgv(args);
1303
- assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--help", "-h"], COMMON_VALUE_FLAGS);
1493
+ assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--human", "-v", "--verbose", "--help", "-h"], COMMON_VALUE_FLAGS);
1304
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();
1305
1500
  const target = await vaultTarget(a);
1306
1501
  try {
1307
- const result = await getSdk().gitvault.access(target);
1308
- 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);
1309
1509
  await spillIfLarge(result.repo_id, "access", result);
1310
1510
  console.error(`${result.recipients.length} directory recipient(s), ${result.recipients.filter((r) => r.covered).length} covered on this repo.`);
1511
+ if (result.this_keystore) {
1512
+ console.error(`1 covering fingerprint is this machine's own keystore (the vault's writing principal, not in the org directory): ${result.this_keystore.fingerprint}`);
1513
+ }
1311
1514
  if (result.unmatched_covered_fingerprints.length > 0) {
1312
- console.error(`${result.unmatched_covered_fingerprints.length} covering fingerprint(s) match no directory entry (orphaned/external): ${result.unmatched_covered_fingerprints.join(", ")}`);
1515
+ console.error(`${result.unmatched_covered_fingerprints.length} covering fingerprint(s) match no directory entry or desired-state row (orphaned/external): ${result.unmatched_covered_fingerprints.join(", ")}`);
1516
+ }
1517
+ if (Array.isArray(result.stale_access) && result.stale_access.length > 0) {
1518
+ const names = result.stale_access.map((s) => s.display_name ?? s.principal_id).join(", ");
1519
+ console.error(`${result.stale_access.length} removed member(s) STILL decrypt this vault (not yet revocable — no epoch rotation in v0): ${names}`);
1313
1520
  }
1314
1521
  console.error(result.gap);
1522
+ printVerboseStats(a, sdk);
1315
1523
  } catch (err) {
1316
1524
  reportSdkError(err);
1317
1525
  }
1318
1526
  }
1319
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
+ */
1320
1559
  async function accessRepair(args) {
1321
1560
  const a = normalizeArgv(args);
1322
- assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--help", "-h"], COMMON_VALUE_FLAGS);
1323
- requirePositionalCount(a, COMMON_VALUE_FLAGS, { min: 0, max: 0, command: "run402 repos access repair", missing: "" });
1324
- fail({
1325
- code: "ACCESS_REPAIR_NOT_AVAILABLE",
1326
- message: "`run402 repos access repair` is not available yet — it is gated on gitvault-human-envelopes' real epoch-rotation work landing.",
1327
- 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).",
1328
- 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.",
1329
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
+ }
1330
1669
  }
1331
1670
 
1332
1671
  async function access(args) {
1333
1672
  const a = normalizeArgv(args);
1334
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));
1335
1676
  return accessRead(a);
1336
1677
  }
1337
1678
 
1338
- // ─── recover (design D4 — kept, D10 confirms the name) ─────────────────────
1679
+ // ─── recover ─────────────────────
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
+ }
1339
1694
 
1340
1695
  async function recover(args) {
1341
1696
  const a = normalizeArgv(args);
1342
1697
  const valueFlags = ["--out", "--repo", "--profile", "--region", "--endpoint"];
1343
- assertKnownFlags(a, [...valueFlags, "--ambient", "--help", "-h"], valueFlags);
1698
+ assertKnownFlags(a, [...valueFlags, "--ambient", "--human", "-v", "--verbose", "--help", "-h"], valueFlags);
1344
1699
  const [source] = requirePositionalCount(a, valueFlags, {
1345
1700
  min: 1, max: 1, command: "run402 repos recover <source> --out <dir>",
1346
1701
  missing: "Missing <source>. Expected s3://<bucket>[/<prefix>] or a directory path.",
@@ -1349,25 +1704,42 @@ async function recover(args) {
1349
1704
  if (outDir == null) {
1350
1705
  fail({ code: "BAD_USAGE", message: "run402 repos recover needs --out <dir>.", hint: "Where to materialize the recovered repository, e.g. --out ./restored" });
1351
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();
1352
1712
  const credential = resolveMirrorCredential(a);
1353
1713
  const repoId = flagValue(a, "--repo");
1354
1714
  const region = flagValue(a, "--region");
1355
1715
  const endpoint = flagValue(a, "--endpoint");
1356
1716
  try {
1357
- const result = await getSdk().gitvault.recover({
1717
+ const result = await sdk.gitvault.recover({
1358
1718
  source, out_dir: outDir,
1359
1719
  ...(repoId != null ? { repo_id: repoId } : {}),
1360
1720
  ...(credential ? { credential } : {}),
1361
1721
  ...(region != null ? { region } : {}),
1362
1722
  ...(endpoint != null ? { endpoint } : {}),
1363
1723
  });
1364
- 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);
1365
1732
  await spillIfLarge(result.repo_id, "recover", result);
1366
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)` : "") + ".");
1367
1734
  if (result.data_loss_detected) {
1368
1735
  console.error(`DATA LOSS DETECTED: ${result.absences.filter((x) => x.adjudication === "unexplained_absence").length} object(s) are unexplained absences — see "absences" in the result above.`);
1369
1736
  }
1737
+ if (result.layout === "bare") {
1738
+ console.error(`layout: bare (no working files in ${outDir} — this is not a failed recovery)`);
1739
+ for (const n of result.next_actions ?? []) console.error(`next: ${n.action} — ${n.command}`);
1740
+ }
1370
1741
  printMirrorHonesty(result);
1742
+ printVerboseStats(a, sdk);
1371
1743
  } catch (err) {
1372
1744
  reportSdkError(err);
1373
1745
  }