strictcli 0.35.4__tar.gz → 0.36.0__tar.gz

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 (99) hide show
  1. {strictcli-0.35.4 → strictcli-0.36.0}/PKG-INFO +1 -1
  2. {strictcli-0.35.4 → strictcli-0.36.0}/pyproject.toml +1 -1
  3. {strictcli-0.35.4 → strictcli-0.36.0}/strictcli/__init__.py +181 -31
  4. strictcli-0.36.0/tests/test_confirm.py +349 -0
  5. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_effects.py +1 -1
  6. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_effects_bypass_check.py +132 -0
  7. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_reserved_quartet.py +57 -13
  8. {strictcli-0.35.4 → strictcli-0.36.0}/uv.lock +1 -1
  9. strictcli-0.35.4/tests/test_confirm.py +0 -191
  10. {strictcli-0.35.4 → strictcli-0.36.0}/.claude/settings.json +0 -0
  11. {strictcli-0.35.4 → strictcli-0.36.0}/.github/workflows/ci.yml +0 -0
  12. {strictcli-0.35.4 → strictcli-0.36.0}/.github/workflows/publish.yml +0 -0
  13. {strictcli-0.35.4 → strictcli-0.36.0}/.gitignore +0 -0
  14. {strictcli-0.35.4 → strictcli-0.36.0}/.rlsbl/bases/.github/workflows/ci.yml +0 -0
  15. {strictcli-0.35.4 → strictcli-0.36.0}/.rlsbl/bases/.github/workflows/publish.yml +0 -0
  16. {strictcli-0.35.4 → strictcli-0.36.0}/.rlsbl/bases/.gitignore +0 -0
  17. {strictcli-0.35.4 → strictcli-0.36.0}/.rlsbl/bases/.rlsbl/lint/python.toml +0 -0
  18. {strictcli-0.35.4 → strictcli-0.36.0}/.rlsbl/config.json +0 -0
  19. {strictcli-0.35.4 → strictcli-0.36.0}/.rlsbl/lint/python.toml +0 -0
  20. {strictcli-0.35.4 → strictcli-0.36.0}/.rlsbl/managed-files.json +0 -0
  21. {strictcli-0.35.4 → strictcli-0.36.0}/.rlsbl/version +0 -0
  22. {strictcli-0.35.4 → strictcli-0.36.0}/.strictcli/schema.json +0 -0
  23. {strictcli-0.35.4 → strictcli-0.36.0}/CLAUDE.md +0 -0
  24. {strictcli-0.35.4 → strictcli-0.36.0}/LICENSE +0 -0
  25. {strictcli-0.35.4 → strictcli-0.36.0}/README.md +0 -0
  26. {strictcli-0.35.4 → strictcli-0.36.0}/scripts/add_effect_classification.py +0 -0
  27. {strictcli-0.35.4 → strictcli-0.36.0}/scripts/add_forwarding_declaration.py +0 -0
  28. {strictcli-0.35.4 → strictcli-0.36.0}/strictcli/py.typed +0 -0
  29. {strictcli-0.35.4 → strictcli-0.36.0}/tests/conftest.py +0 -0
  30. {strictcli-0.35.4 → strictcli-0.36.0}/tests/flagship_app.py +0 -0
  31. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_arg_default.py +0 -0
  32. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_arg_default_validation.py +0 -0
  33. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_at_prefix.py +0 -0
  34. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_auto_version.py +0 -0
  35. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_call.py +0 -0
  36. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_check_command.py +0 -0
  37. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_check_discovery.py +0 -0
  38. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_check_provider.py +0 -0
  39. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_check_public_api.py +0 -0
  40. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_check_runner.py +0 -0
  41. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_check_schema.py +0 -0
  42. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_check_types.py +0 -0
  43. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_choices.py +0 -0
  44. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_choices_none.py +0 -0
  45. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_classification.py +0 -0
  46. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_command_help_suggestion.py +0 -0
  47. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_command_tags.py +0 -0
  48. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_compound_types.py +0 -0
  49. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_config.py +0 -0
  50. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_config_fields.py +0 -0
  51. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_config_file_path.py +0 -0
  52. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_config_set_bugs.py +0 -0
  53. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_connection_env.py +0 -0
  54. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_context.py +0 -0
  55. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_coverage.py +0 -0
  56. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_deep_nesting.py +0 -0
  57. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_dependencies.py +0 -0
  58. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_deprecated.py +0 -0
  59. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_dump_schema.py +0 -0
  60. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_e2e.py +0 -0
  61. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_env.py +0 -0
  62. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_exit_codes.py +0 -0
  63. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_flag_sets.py +0 -0
  64. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_flagship_preview.py +0 -0
  65. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_float_format.py +0 -0
  66. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_float_type.py +0 -0
  67. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_float_vectors.py +0 -0
  68. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_global_flag_conflict_position.py +0 -0
  69. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_global_flags.py +0 -0
  70. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_guard_v2.py +0 -0
  71. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_help.py +0 -0
  72. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_hermetic.py +0 -0
  73. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_infra_env.py +0 -0
  74. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_int_type.py +0 -0
  75. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_invoke.py +0 -0
  76. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_keyword_flags.py +0 -0
  77. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_mcp.py +0 -0
  78. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_mutex.py +0 -0
  79. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_nesting.py +0 -0
  80. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_parser.py +0 -0
  81. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_passthrough.py +0 -0
  82. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_provenance.py +0 -0
  83. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_provenance_phase2.py +0 -0
  84. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_registration.py +0 -0
  85. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_repeatable.py +0 -0
  86. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_reserved_global_flags.py +0 -0
  87. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_tagdsl.py +0 -0
  88. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_toml_loading.py +0 -0
  89. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_tool_export.py +0 -0
  90. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_typed_args.py +0 -0
  91. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_unique.py +0 -0
  92. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_utilities.py +0 -0
  93. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_validate.py +0 -0
  94. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_variadic.py +0 -0
  95. {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_visibility.py +0 -0
  96. {strictcli-0.35.4 → strictcli-0.36.0}/todo/.defer/deferred.md +0 -0
  97. {strictcli-0.35.4 → strictcli-0.36.0}/todo/.done/keyword-collision-in-flag-param-name.md +0 -0
  98. {strictcli-0.35.4 → strictcli-0.36.0}/todo/.done/original-idea.md +0 -0
  99. {strictcli-0.35.4 → strictcli-0.36.0}/todo/.done/public-check-runner-api.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: strictcli
3
- Version: 0.35.4
3
+ Version: 0.36.0
4
4
  Summary: A strict CLI framework for Python
5
5
  Project-URL: Homepage, https://github.com/smm-h/strictcli
6
6
  Project-URL: Repository, https://github.com/smm-h/strictcli
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "strictcli"
7
- version = "0.35.4"
7
+ version = "0.36.0"
8
8
  description = "A strict CLI framework for Python"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.35.4"
5
+ __version__ = "0.36.0"
6
6
 
7
7
  __all__ = [
8
8
  "App", "Flag", "Arg", "FlagSet", "MutexGroup", "CoRequired", "Requires",
@@ -248,7 +248,8 @@ class Context:
248
248
  """
249
249
 
250
250
  def __init__(self, stdout=None, stderr=None, sources=None, infra=None,
251
- *, dry_run: bool = False, yes: bool = False,
251
+ *, dry_run: bool = False,
252
+ approve_consequential: bool = False,
252
253
  quiet: bool = False, verbose: bool = False,
253
254
  effects: "_Effects | None" = None):
254
255
  self._stdout = stdout or sys.stdout
@@ -256,7 +257,7 @@ class Context:
256
257
  self._sources = sources or {} # flag-name -> source label (cli/env/config/default/implied/infra)
257
258
  self._infra = infra # _InfraAccess | None
258
259
  self._dry_run = dry_run
259
- self._yes = yes
260
+ self._approve_consequential = approve_consequential
260
261
  self._quiet = quiet
261
262
  self._verbose = verbose
262
263
  self._effects = effects
@@ -267,9 +268,9 @@ class Context:
267
268
  return self._dry_run
268
269
 
269
270
  @property
270
- def yes(self) -> bool:
271
- """True when the framework-owned ``--yes`` flag was passed."""
272
- return self._yes
271
+ def approve_consequential(self) -> bool:
272
+ """True when the framework-owned ``--approve-consequential`` flag was passed."""
273
+ return self._approve_consequential
273
274
 
274
275
  @property
275
276
  def quiet(self) -> bool:
@@ -686,7 +687,7 @@ def _msg_dry_run_aborted(step: int, cmd: str) -> str:
686
687
 
687
688
  def _msg_confirm_prompt(cmd_path: str) -> str:
688
689
  """The confirm prompt. A prompt, not an error, but parity is still checked."""
689
- return f"about to run mutating command '{cmd_path}'. Proceed? [y/N] "
690
+ return f"about to run consequential command '{cmd_path}'. Proceed? [y/N] "
690
691
 
691
692
 
692
693
  def _strip_confirm_line(answer: str) -> str:
@@ -707,13 +708,35 @@ def _strip_confirm_line(answer: str) -> str:
707
708
 
708
709
 
709
710
  def _msg_confirm_non_interactive() -> str:
710
- return "error: stdin is not interactive; pass --yes to confirm"
711
+ return (
712
+ "error: stdin is not interactive; pass --approve-consequential to confirm"
713
+ )
711
714
 
712
715
 
713
716
  def _msg_confirm_declined() -> str:
714
717
  return "aborted"
715
718
 
716
719
 
720
+ def _consequential_grant_warning(cmd_path: str, grant: str, kind: str) -> str:
721
+ """The `consequential-grant-agreement` warning (contract §8.1, §11).
722
+
723
+ A grant exists so a reviewer reading a preview sees WHY a dangerous step is
724
+ there (§6.1) -- the same judgement ``consequential`` makes. When the grant's
725
+ kind is one that leaves this process (``proc_mutate`` runs another program,
726
+ ``net_mutate`` changes remote state), the two declarations should almost
727
+ always agree. They can legitimately disagree, so this is a warning: making
728
+ it an error would push consumers to declare ``consequential`` reflexively
729
+ to clear a gate, which is exactly the reflex the declaration exists to end.
730
+ """
731
+ return (
732
+ f"command '{cmd_path}' declares grant '{grant}' (kind {kind}) but is "
733
+ f"not consequential: a {kind} effect leaves this process and the "
734
+ f"framework cannot walk it back, and the grant already says the step "
735
+ f"is worth explaining. Declare the command consequential, or drop the "
736
+ f"grant if the step is routine."
737
+ )
738
+
739
+
717
740
  def _observe_allowlist_breadth_warning(binary: str) -> str:
718
741
  """The `observe-allowlist-breadth` warning (contract §6.2).
719
742
 
@@ -3005,9 +3028,15 @@ _NON_BOOL_SCALAR_TYPES = (str, int, float)
3005
3028
  # names are unaffected by this ban, and the four flags themselves have no short
3006
3029
  # forms.
3007
3030
  _RESERVED_FRAMEWORK_FLAG_NAMES = frozenset({
3008
- "dry-run", "yes", "quiet", "verbose",
3031
+ "dry-run", "approve-consequential", "quiet", "verbose",
3009
3032
  })
3010
3033
 
3034
+ # `yes` is NOT a framework flag any more -- it was replaced by
3035
+ # --approve-consequential (contract §7.1) -- but it stays banned so nobody
3036
+ # reintroduces a private --yes meaning the same thing. Its ban message points
3037
+ # at the replacement.
3038
+ _BANNED_FLAG_NAMES = frozenset({"yes"})
3039
+
3011
3040
  # Names reserved by the framework for global flags. The pre-existing set is
3012
3041
  # also what a SHORT flag name is checked against (the framework quartet bans
3013
3042
  # long names only).
@@ -3020,7 +3049,7 @@ _RESERVED_GLOBAL_FLAG_NAMES = _RESERVED_GLOBAL_SHORT_NAMES | _RESERVED_FRAMEWORK
3020
3049
  # argv token -> pre-scan result key for the reserved quartet.
3021
3050
  _RESERVED_QUARTET_TOKENS = {
3022
3051
  "--dry-run": "dry_run",
3023
- "--yes": "yes",
3052
+ "--approve-consequential": "approve_consequential",
3024
3053
  "--quiet": "quiet",
3025
3054
  "--verbose": "verbose",
3026
3055
  }
@@ -3030,7 +3059,20 @@ def _raise_flag_name_reserved_by_framework(name: str):
3030
3059
  """Message template: a flag name collides with the reserved quartet."""
3031
3060
  raise ValueError(
3032
3061
  f"flag name '{name}' is reserved by the framework "
3033
- f"(dry-run, yes, quiet, verbose)"
3062
+ f"(dry-run, approve-consequential, quiet, verbose)"
3063
+ )
3064
+
3065
+
3066
+ def _raise_flag_name_yes_banned():
3067
+ """Message template: a flag named `yes` is banned outright.
3068
+
3069
+ `yes` owns no framework flag any more, but a private --yes would restate
3070
+ --approve-consequential in a spelling that IS muscle memory -- which is
3071
+ exactly what the rename removed.
3072
+ """
3073
+ raise ValueError(
3074
+ "flag name 'yes' is banned by the framework: "
3075
+ "the confirmation skip is --approve-consequential"
3034
3076
  )
3035
3077
 
3036
3078
 
@@ -3181,6 +3223,8 @@ class Flag:
3181
3223
  )
3182
3224
  if self.name in _RESERVED_FRAMEWORK_FLAG_NAMES:
3183
3225
  _raise_flag_name_reserved_by_framework(self.name)
3226
+ if self.name in _BANNED_FLAG_NAMES:
3227
+ _raise_flag_name_yes_banned()
3184
3228
  if self.name.startswith("no-"):
3185
3229
  raise ValueError(
3186
3230
  f"flag '{self.name}': names starting with 'no-' are "
@@ -3650,6 +3694,20 @@ def _raise_deprecated_command_effect(name: str):
3650
3694
  )
3651
3695
 
3652
3696
 
3697
+ def _raise_command_read_only_consequential(name: str):
3698
+ """A read_only command cannot be consequential (contract §8.1).
3699
+
3700
+ Classification answers "should a dry run record rather than execute?";
3701
+ ``consequential`` answers "are these effects worth interrupting someone
3702
+ for?". A command that changes nothing has no effects to weigh, so the two
3703
+ declarations cannot both hold.
3704
+ """
3705
+ raise ValueError(
3706
+ f'command "{name}": a read_only command cannot be consequential '
3707
+ f'(a command that changes nothing has nothing to confirm)'
3708
+ )
3709
+
3710
+
3653
3711
  @dataclass(frozen=True)
3654
3712
  class Command:
3655
3713
  """A leaf command with a handler."""
@@ -3658,6 +3716,11 @@ class Command:
3658
3716
  help: str
3659
3717
  handler: Callable | None
3660
3718
  effect: str
3719
+ # Declared per-command (contract §8.1). NOT mandatory -- absence means
3720
+ # "not consequential". It is a property of the COMMAND, deliberately not
3721
+ # named after the framework's reaction to it, so other behaviours can hang
3722
+ # off it later. Today the framework prompts for exactly these commands.
3723
+ consequential: bool = False
3661
3724
  flags: tuple[Flag, ...] = ()
3662
3725
  args: tuple[Arg, ...] = ()
3663
3726
  flag_sets: tuple[FlagSet, ...] = ()
@@ -3679,6 +3742,8 @@ class Command:
3679
3742
  _require_non_empty_str(self.help, "help", "Command")
3680
3743
  if self.effect not in _EFFECT_VALUES:
3681
3744
  _raise_command_effect_invalid(self.name, self.effect)
3745
+ if self.consequential and self.effect == EFFECT_READ_ONLY:
3746
+ _raise_command_read_only_consequential(self.name)
3682
3747
  for tag in self.tags:
3683
3748
  if not _IDENTIFIER_RE.fullmatch(tag):
3684
3749
  raise ValueError(f'invalid tag name "{tag}": must match [a-z][a-z0-9-]*')
@@ -3764,6 +3829,7 @@ class Group:
3764
3829
  *,
3765
3830
  help: str,
3766
3831
  effect: str | None = None,
3832
+ consequential: bool = False,
3767
3833
  args: list[Arg] | None = None,
3768
3834
  flag_sets: list[FlagSet] | None = None,
3769
3835
  mutex: list[MutexGroup] | None = None,
@@ -3785,6 +3851,7 @@ class Group:
3785
3851
  )
3786
3852
  cmd = _build_and_validate_command(
3787
3853
  name, help=help, effect=effect,
3854
+ consequential=consequential,
3788
3855
  handler=func, args=args, flag_sets=flag_sets, mutex=mutex,
3789
3856
  dependencies=dependencies,
3790
3857
  env_prefix=self.env_prefix,
@@ -4322,6 +4389,10 @@ class App:
4322
4389
  # bans the quartet first); kept so the global-flag validation
4323
4390
  # path carries the same message for any other construction route.
4324
4391
  _raise_flag_name_reserved_by_framework(f.name)
4392
+ if f.name in _BANNED_FLAG_NAMES:
4393
+ # Likewise unreachable through Flag(); kept for parity with the
4394
+ # quartet's own belt-and-braces check on this path.
4395
+ _raise_flag_name_yes_banned()
4325
4396
  if f.name in _RESERVED_GLOBAL_FLAG_NAMES:
4326
4397
  raise ValueError(
4327
4398
  f'global flag name "{f.name}" is reserved'
@@ -4338,7 +4409,7 @@ class App:
4338
4409
  # Framework-owned reserved quartet, extracted by the pre-scan and
4339
4410
  # delivered on the Context (never as handler kwargs).
4340
4411
  self._last_dry_run: bool = False
4341
- self._last_yes: bool = False
4412
+ self._last_approve_consequential: bool = False
4342
4413
  self._last_quiet: bool = False
4343
4414
  self._last_verbose: bool = False
4344
4415
 
@@ -4557,6 +4628,24 @@ class App:
4557
4628
 
4558
4629
  return paths
4559
4630
 
4631
+ def _collect_all_commands(self) -> list[tuple[str, "Command"]]:
4632
+ """Enumerate (dotted path, Command) pairs in registration order."""
4633
+ out: list[tuple[str, Command]] = []
4634
+
4635
+ for name, cmd in self._commands.items():
4636
+ out.append((name, cmd))
4637
+
4638
+ def _walk_group(group: Group, prefix: list[str]) -> None:
4639
+ for cmd_name, cmd in group.commands.items():
4640
+ out.append((".".join(prefix + [cmd_name]), cmd))
4641
+ for sub_name, sub_group in group._groups.items():
4642
+ _walk_group(sub_group, prefix + [sub_name])
4643
+
4644
+ for group_name, group in self._groups.items():
4645
+ _walk_group(group, [group_name])
4646
+
4647
+ return out
4648
+
4560
4649
  def _test_coverage_provider(self) -> list[CheckSpec]:
4561
4650
  """Built-in check provider for cli-test-coverage.
4562
4651
 
@@ -4681,17 +4770,20 @@ class App:
4681
4770
  ]
4682
4771
 
4683
4772
  def _effects_bypass_provider(self) -> list[CheckSpec]:
4684
- """Built-in check provider for the two effects-regime lints.
4773
+ """Built-in check provider for the three effects-regime lints.
4685
4774
 
4686
4775
  Registered whenever the check system turns on, so a consumer that
4687
- adopts checks at all gets both without a TOML declaration:
4776
+ adopts checks at all gets all three without a TOML declaration:
4688
4777
 
4689
4778
  - ``effects-bypass`` (error) fails on any direct process,
4690
4779
  filesystem-mutation or network call REACHABLE FROM A REGISTERED
4691
4780
  COMMAND HANDLER;
4692
4781
  - ``observe-allowlist-breadth`` (warn) surfaces short
4693
4782
  ``proc_observe_allowlist`` prefixes, which authorize real execution
4694
- under ``--dry-run``.
4783
+ under ``--dry-run``;
4784
+ - ``consequential-grant-agreement`` (warn) surfaces commands that
4785
+ declare a process- or network-mutating grant but do not declare
4786
+ themselves consequential.
4695
4787
  """
4696
4788
  def impl(ctx: CheckContext, reporter: "ErrorReporter") -> "_CheckOutcome":
4697
4789
  findings = _scan_effects_bypasses(Path(ctx.project_root))
@@ -4722,6 +4814,34 @@ class App:
4722
4814
  "no single-token proc_observe_allowlist prefixes"
4723
4815
  )
4724
4816
 
4817
+ def grant_agreement_impl(ctx: CheckContext,
4818
+ reporter: "WarnReporter") -> "_CheckOutcome":
4819
+ # Only the kinds that leave this process. A file_write or a
4820
+ # proc_spawn is local and ordinarily recoverable; a proc_mutate
4821
+ # runs another program and a net_mutate changes remote state, and
4822
+ # neither can be walked back by the framework. Widening this to
4823
+ # every grant kind would re-create the noise the consequential
4824
+ # declaration exists to remove.
4825
+ escaping = (PROC_MUTATE, NET_MUTATE)
4826
+ found = 0
4827
+ for cmd_path, cmd in self._collect_all_commands():
4828
+ if cmd.consequential:
4829
+ continue
4830
+ for grant in cmd.grants:
4831
+ if grant.kind not in escaping:
4832
+ continue
4833
+ found += 1
4834
+ reporter.warn(_consequential_grant_warning(
4835
+ cmd_path, grant.name, grant.kind,
4836
+ ))
4837
+ if found:
4838
+ return reporter.found(
4839
+ f"{found} grant(s) on non-consequential command(s)"
4840
+ )
4841
+ return reporter.passed(
4842
+ "every escaping grant sits on a consequential command"
4843
+ )
4844
+
4725
4845
  return [
4726
4846
  error_check_spec(
4727
4847
  name="effects-bypass",
@@ -4741,6 +4861,15 @@ class App:
4741
4861
  depends_on=[],
4742
4862
  impl=breadth_impl,
4743
4863
  ),
4864
+ warn_check_spec(
4865
+ name="consequential-grant-agreement",
4866
+ tags=["effects", "quality"],
4867
+ fast=True,
4868
+ pure=True,
4869
+ needs_network=False,
4870
+ depends_on=[],
4871
+ impl=grant_agreement_impl,
4872
+ ),
4744
4873
  ]
4745
4874
 
4746
4875
  @property
@@ -5343,6 +5472,7 @@ class App:
5343
5472
  *,
5344
5473
  help: str,
5345
5474
  effect: str | None = None,
5475
+ consequential: bool = False,
5346
5476
  args: list[Arg] | None = None,
5347
5477
  flag_sets: list[FlagSet] | None = None,
5348
5478
  mutex: list[MutexGroup] | None = None,
@@ -5362,6 +5492,7 @@ class App:
5362
5492
  name,
5363
5493
  help=help,
5364
5494
  effect=effect,
5495
+ consequential=consequential,
5365
5496
  handler=func,
5366
5497
  args=args,
5367
5498
  flag_sets=flag_sets,
@@ -5469,20 +5600,23 @@ class App:
5469
5600
  if name in flag_params
5470
5601
  }
5471
5602
 
5472
- def _confirm_mutating(self, cmd: "Command", cmd_path: str) -> None:
5603
+ def _confirm_consequential(self, cmd: "Command", cmd_path: str) -> None:
5473
5604
  """The framework-owned confirm protocol.
5474
5605
 
5475
- Fires before dispatching a ``mutating`` command on the real CLI path
5476
- when neither --dry-run nor --yes was passed. Never fires for read_only
5477
- commands, and never on the programmatic paths (test/call/_invoke/MCP),
5478
- which have no TTY contract and would hang.
5606
+ Fires before dispatching a command that DECLARES ITSELF consequential,
5607
+ on the real CLI path, when neither --dry-run nor
5608
+ --approve-consequential was passed. A plain ``mutating`` command never
5609
+ prompts: classification answers "should a dry run record rather than
5610
+ execute?", which is a different question from "are these effects worth
5611
+ interrupting someone for?". Never fires on the programmatic paths
5612
+ (test/call/_invoke/MCP), which have no TTY contract and would hang.
5479
5613
 
5480
- A mutating PASSTHROUGH is not exempt: the framework knows LESS about
5481
- what is about to happen, not more.
5614
+ A consequential PASSTHROUGH is not exempt: the framework knows LESS
5615
+ about what is about to happen, not more.
5482
5616
  """
5483
- if cmd.effect != EFFECT_MUTATING:
5617
+ if not cmd.consequential:
5484
5618
  return
5485
- if self._last_dry_run or self._last_yes:
5619
+ if self._last_dry_run or self._last_approve_consequential:
5486
5620
  return
5487
5621
  if not sys.stdin.isatty():
5488
5622
  print(_msg_confirm_non_interactive(), file=sys.stderr)
@@ -6040,7 +6174,7 @@ class App:
6040
6174
  """Pre-scan for the framework-owned reserved flags.
6041
6175
 
6042
6176
  Handles --dump-schema, --mcp, --config, --hermetic and the effects-regime
6043
- quartet --dry-run/--yes/--quiet/--verbose.
6177
+ quartet --dry-run/--approve-consequential/--quiet/--verbose.
6044
6178
 
6045
6179
  Two regions, two rulesets (contract §7.2, amended):
6046
6180
 
@@ -6053,7 +6187,7 @@ class App:
6053
6187
  pre-command-only. See _scan_command_region_quartet.
6054
6188
 
6055
6189
  Returns a dict with keys: dump_schema, serve_mcp, hermetic, config_path,
6056
- dry_run, yes, quiet, verbose, err, cleaned_argv.
6190
+ dry_run, approve_consequential, quiet, verbose, err, cleaned_argv.
6057
6191
  """
6058
6192
  # Build a set of known global flag tokens with value-taking info
6059
6193
  known_flags: dict[str, bool] = {} # token -> takes_value
@@ -6183,7 +6317,7 @@ class App:
6183
6317
  ) -> None:
6184
6318
  """Recognize the reserved quartet in the command region of argv.
6185
6319
 
6186
- Contract §7.2 (amended 2026-08-04): --dry-run/--yes/--quiet/--verbose are
6320
+ Contract §7.2 (amended 2026-08-04): the quartet's four tokens are
6187
6321
  recognized ANYWHERE in argv, exactly like --help/-h, because their
6188
6322
  applicability is per-command -- requiring them before the command name
6189
6323
  was backwards. Only the quartet is recognized here; --hermetic,
@@ -6268,7 +6402,9 @@ class App:
6268
6402
 
6269
6403
  # Record the reserved quartet for the dispatch ctx.
6270
6404
  self._last_dry_run = bool(pre_scan.get("dry_run"))
6271
- self._last_yes = bool(pre_scan.get("yes"))
6405
+ self._last_approve_consequential = bool(
6406
+ pre_scan.get("approve_consequential")
6407
+ )
6272
6408
  self._last_quiet = bool(pre_scan.get("quiet"))
6273
6409
  self._last_verbose = bool(pre_scan.get("verbose"))
6274
6410
 
@@ -6900,12 +7036,13 @@ class App:
6900
7036
  ctx = Context(
6901
7037
  stdout=sys.stdout, stderr=sys.stderr, sources=sources,
6902
7038
  infra=self._infra_access(self._last_hermetic),
6903
- dry_run=self._last_dry_run, yes=self._last_yes,
7039
+ dry_run=self._last_dry_run,
7040
+ approve_consequential=self._last_approve_consequential,
6904
7041
  quiet=self._last_quiet, verbose=self._last_verbose,
6905
7042
  effects=effects,
6906
7043
  )
6907
7044
  # The confirm protocol fires only on the real CLI path.
6908
- self._confirm_mutating(cmd, cmd_path)
7045
+ self._confirm_consequential(cmd, cmd_path)
6909
7046
  # The would-do log renders on EVERY exit path out of the dispatch,
6910
7047
  # not just the normal return: the operator asked for a preview and
6911
7048
  # the effects were recorded, so a handler that unwinds through
@@ -7000,7 +7137,8 @@ class App:
7000
7137
  ctx = Context(
7001
7138
  stdout=stdout_buf, stderr=stderr_buf, sources=sources,
7002
7139
  infra=self._infra_access(self._last_hermetic),
7003
- dry_run=self._last_dry_run, yes=self._last_yes,
7140
+ dry_run=self._last_dry_run,
7141
+ approve_consequential=self._last_approve_consequential,
7004
7142
  quiet=self._last_quiet, verbose=self._last_verbose,
7005
7143
  effects=self._arm_effects(
7006
7144
  cmd, cmd_path, dry_run=self._last_dry_run,
@@ -8119,6 +8257,7 @@ def _build_and_validate_command(
8119
8257
  *,
8120
8258
  help: str,
8121
8259
  effect: str | None,
8260
+ consequential: bool = False,
8122
8261
  handler: Callable,
8123
8262
  args: list[Arg] | None,
8124
8263
  flag_sets: list[FlagSet] | None,
@@ -8156,6 +8295,11 @@ def _build_and_validate_command(
8156
8295
  if effect not in _EFFECT_VALUES:
8157
8296
  _raise_command_effect_invalid(name, effect)
8158
8297
 
8298
+ # A read_only command cannot be consequential: it changes nothing, so
8299
+ # there is nothing to interrupt anyone for (contract §8.1).
8300
+ if consequential and effect == EFFECT_READ_ONLY:
8301
+ _raise_command_read_only_consequential(name)
8302
+
8159
8303
  resolved_grants = _validate_grants(name, grants)
8160
8304
 
8161
8305
  # Declared forwarding: the reason is mandatory and non-empty.
@@ -8213,6 +8357,7 @@ def _build_and_validate_command(
8213
8357
  help=help,
8214
8358
  handler=None,
8215
8359
  effect=effect,
8360
+ consequential=consequential,
8216
8361
  passthrough=passthrough,
8217
8362
  tags=effective_tags,
8218
8363
  hidden=hidden,
@@ -8478,6 +8623,7 @@ def _build_and_validate_command(
8478
8623
  help=help,
8479
8624
  handler=handler,
8480
8625
  effect=effect,
8626
+ consequential=consequential,
8481
8627
  flags=tuple(all_flags),
8482
8628
  args=tuple(all_args),
8483
8629
  flag_sets=tuple(resolved_flag_sets),
@@ -9533,6 +9679,10 @@ def _serialize_command(cmd: Command) -> dict:
9533
9679
  # to omit against.
9534
9680
  "effect": cmd.effect,
9535
9681
  }
9682
+ # Omitted when false: consequential is NOT mandatory, and absence means
9683
+ # "not consequential" (contract §8.1, §13).
9684
+ if cmd.consequential:
9685
+ d["consequential"] = True
9536
9686
  if cmd.passthrough is not None:
9537
9687
  d["passthrough"] = True
9538
9688
  flags = [_serialize_flag(f) for f in cmd.flags]