riskrails 3.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +2 -2
  4. package/README.md +246 -812
  5. package/agents/ai-reviewer.md.tmpl +6 -4
  6. package/ai/policies/review-economy.md +7 -2
  7. package/ai/using-riskrails.md +15 -0
  8. package/bin/claude-1m +15 -14
  9. package/bin/riskrails.js +108 -32
  10. package/codex/hooks.json +25 -2
  11. package/hooks/AGENTS.md +2 -2
  12. package/hooks/CLAUDE.md +2 -2
  13. package/hooks/ai-guard.py +29 -8
  14. package/hooks/ai-path-guard-defaults.json +3 -1
  15. package/hooks/codex-hooks.json +25 -2
  16. package/hooks/context-guard.py +251 -35
  17. package/hooks/dialog-gate.py +105 -0
  18. package/hooks/guard/common.py +5 -0
  19. package/hooks/guard/decision.py +123 -0
  20. package/hooks/guard/dialog.py +598 -0
  21. package/hooks/guard/git.py +49 -3
  22. package/hooks/guard/paths.py +62 -3
  23. package/hooks/hooks.json +37 -2
  24. package/hooks/memory-pre-push.py +28 -0
  25. package/hooks/runtime-gate.py +59 -12
  26. package/hooks/update_notice.py +135 -0
  27. package/instructions/routing.md +4 -4
  28. package/instructions/stub.md +2 -2
  29. package/package.json +1 -1
  30. package/profiles/max.json +3 -1
  31. package/profiles/pro.json +3 -1
  32. package/scripts/merge-codex-config.py +134 -0
  33. package/scripts/resolve-profile.py +15 -3
  34. package/settings.common.json +39 -2
  35. package/skills/AGENTS.md +2 -2
  36. package/skills/CLAUDE.md +2 -2
  37. package/skills/ai-init/templates/.ai/AGENTS.md +2 -2
  38. package/skills/ai-init/templates/.ai/VERSION +1 -1
  39. package/skills/ai-init/templates/.ai/policies/dialog.json +1 -0
  40. package/skills/ai-init/templates/.ai/policies/safety.md +22 -2
  41. package/skills/ai-init/templates/.ai/policies/weight.json +1 -0
  42. package/skills/ai-init/templates/.ai/policies/weight.md +2 -2
  43. package/skills/ai-init/templates/.ai/state/README.md +9 -6
  44. package/skills/ai-init/templates/AGENTS.block.md +1 -1
  45. package/skills/ai-init/templates/CLAUDE.block.md +1 -1
  46. package/skills/ai-init/templates/GEMINI.block.md +1 -1
  47. package/skills/ai-init/templates/junie-guidelines.block.md +1 -1
  48. package/skills/ai-task/memory_compact.py +968 -0
  49. package/skills/ai-task/memory_format.py +775 -0
  50. package/skills/ai-task/memory_store.py +528 -0
  51. package/skills/ai-task/memory_sync.py +1571 -0
  52. package/skills/ai-task/sensors.py +2 -2
  53. package/skills/ai-task/state.py +967 -70
  54. package/skills/ai-task/weight.py +3 -3
  55. package/skills/project-init/templates/constitution.md +1 -1
  56. package/skills/project-update/history/blobs/164cd34c182de88c33de7df35beb29200d792419e1c13a5f0b2104f944e57be7 +1 -0
  57. package/skills/project-update/history/blobs/1d1d907449ec74594d0ee923ac142ae746b9aedf0a1eda9469f075a44d23e3d7 +13 -0
  58. package/skills/project-update/history/blobs/23a42febbacaa7b52a802d428033a99c0694391c81d51fe3b6e1dff608a641d5 +67 -0
  59. package/skills/project-update/history/blobs/5353ca529c46f2443e12947ec205a6887fb594a4de1f87f0eb1c0e6c0f826133 +49 -0
  60. package/skills/project-update/history/blobs/5f65bc7ab3fc3c4e75ae05d49eaf4331fafc398c661abe33c69f7380a720344c +32 -0
  61. package/skills/project-update/history/blobs/7be527cd648b27cca6e267de30f8e33a2ce17d5631a82694f23ae5c0623efe1f +15 -0
  62. package/skills/project-update/history/blobs/7ee99edafe7cec45ff577376c0d0178fdb2690134d4656488f46e129aba3cc5a +67 -0
  63. package/skills/project-update/history/blobs/8b3ef9586491a426767c01da5d01bdd6b0655c576c68c4cc6dfb093367817dc0 +67 -0
  64. package/skills/project-update/history/blobs/917df3320d778ddbaa5c5c7742bc4046bf803c36ed2b050f30844ed206783469 +1 -0
  65. package/skills/project-update/history/blobs/935af1019931058ec3a0b834fe230e127ec914f75edcc4a6c589cfe733b0b979 +13 -0
  66. package/skills/project-update/history/blobs/ae5ce131d6bae401199ff32d8c3dc4d8aec188010a9111624ac64506e2116ff4 +84 -0
  67. package/skills/project-update/history/blobs/ca4499d829268c8766fb3b825a515e88dc55c237e9d202816b50959b4dac031f +13 -0
  68. package/skills/project-update/history/blobs/e9adb7a9905eb719ac78ab96671f055da53e5001810f5ad6d6c94306b0323fe7 +74 -0
  69. package/skills/project-update/history/blobs/fae3064e648cdc55be4aeb1177525b302a5e34ebd752e4a904f47dc0f4557b96 +71 -0
  70. package/skills/project-update/history/index.json +32 -13
  71. package/skills/project-update/migrations/0009_state_file.py +25 -0
  72. package/skills/project-update/migrations/0010_dialog_switch.py +30 -0
  73. package/skills/project-update/migrations/README.md +2 -1
  74. package/skills/project-update/update.py +12 -3
  75. package/skills/rr-build/SKILL.md +14 -5
  76. package/skills/rr-build-all/SKILL.md +190 -0
  77. package/skills/rr-plan/SKILL.md +13 -3
  78. package/skills/rr-review/SKILL.md +18 -8
  79. package/skills/rr-ship/SKILL.md +16 -8
  80. package/skills/rr-status/SKILL.md +2 -2
  81. package/skills/usage-report/usage-report.py +41 -8
@@ -18,6 +18,8 @@ history[] keeps its exact shape and its original event names beside it.
18
18
  Usage:
19
19
  state.py start --goal G --workflow W --files a,b [--tests t] [--declare axis=n:"why"]...
20
20
  [--mode M --by WHO [--why TEXT]] [--steps "id:a,b:t;..." | --plan-file F.json]
21
+ [--batch --by WHO | --no-batch] the batch loop of /rr-build-all (weight.json batch.*);
22
+ a plan-file step may name "after": ["s1", ...]
21
23
  state.py start --amend --files "+c,+d" --why TEXT widen the task in place (R25)
22
24
  state.py verify --scope step|suite|e2e|all [--files a,b] [--expect fail]
23
25
  [--e2e not-applicable WHY] [--after-fix WHAT] [--by WHO]
@@ -25,6 +27,9 @@ Usage:
25
27
  --report -|FILE [--focus security] [--by WHO] | review --closes "H1,M2"
26
28
  state.py close [--abandon] two facts, the mode table, the gate, the archive
27
29
  state.py step-done <id> a multi-step guarded or full task
30
+ state.py steps [--ready] [--json] the steps, their status and after; --ready prints the next wave
31
+ state.py batch on|off --by WHO [--why TEXT] off: the standard loop and budgets from here (anyone);
32
+ on: lowers the rigor — the human's, in their own terminal
28
33
  state.py status [--json] what /rr-status prints
29
34
  state.py ask "<question>" --option "A: text" [--option ...] [--recommend A] [--topic S]
30
35
  state.py ask --batch FILE.json [--topic SLUG]
@@ -55,11 +60,13 @@ it; every other command refuses it.
55
60
  import argparse
56
61
  import base64
57
62
  import contextlib
63
+ import copy
58
64
  import decimal
59
65
  import hashlib
60
66
  import importlib.util
61
67
  import json
62
68
  import os
69
+ import posixpath
63
70
  import re
64
71
  import shlex
65
72
  import subprocess
@@ -98,6 +105,10 @@ EVENT_TYPES = [
98
105
  # R36 (7c): the dialog's decisions and the consent ledger, mirrored by state.py.
99
106
  "decision_opened", "consent_recorded", "consent_used", "consent_voided", "decision_declined",
100
107
  "decision_settled", "decision_fallback", "plan_drift",
108
+ # compact-project-memory §D9: a risk recorded with `memory risk`, compacted at close.
109
+ "risk_recorded",
110
+ # the batch loop (/rr-build-all): the switch, at start or during the task.
111
+ "batch_set",
101
112
  ]
102
113
  # The types `event` will not write: each is the record of an authorization or a
103
114
  # lifecycle decision, and is emitted by the transition that made it. `event` is
@@ -106,7 +117,7 @@ FORGEABLE_EVENTS = {"gate_requested", "gate_approved", "gate_rejected",
106
117
  "task_started", "task_closed", "weight_set", "weight_raised", "mode_set",
107
118
  "verify_run", "review_verdict", "decision_opened", "consent_recorded",
108
119
  "consent_used", "consent_voided", "decision_declined", "decision_settled",
109
- "decision_fallback", "plan_drift"}
120
+ "decision_fallback", "plan_drift", "risk_recorded", "batch_set"}
110
121
 
111
122
  # The questions are written by state.py and never by a model (R1). One regex
112
123
  # per line (I2), so a hand edit that keeps the shape round-trips exactly. A
@@ -155,14 +166,14 @@ ERROR_MAX = 1000
155
166
  RUNTIME = "unknown"
156
167
  # The commands that change the task. Only these take ownership of it: reading a
157
168
  # task from the other runtime, or writing a note about it, is not a handoff.
158
- MUTATING_COMMANDS = {"step-done", "ask", "answer", "close", "start", "verify", "review"}
169
+ MUTATING_COMMANDS = {"step-done", "ask", "answer", "close", "start", "verify", "review", "batch"}
159
170
  # What prunes decision.json and mirrors consent.jsonl first (R36.14, R36.24):
160
171
  # every command that changes something; status and consent stay read-only.
161
172
  DIALOG_SYNC_COMMANDS = MUTATING_COMMANDS | {"approve", "reject", "decision"}
162
173
  # The commands that work on the task in flight; a task the old surface started
163
174
  # refuses them all but close --abandon (route_surface).
164
175
  TASK_COMMANDS = {"step-done", "ask", "answer", "questions", "approve", "reject", "close", "verify",
165
- "review"}
176
+ "review", "steps", "batch"}
166
177
  MUTATING = False
167
178
 
168
179
 
@@ -209,12 +220,12 @@ STATE_NAMES = ("state.json", "current.json")
209
220
 
210
221
 
211
222
  def state_path(root):
212
- """The task's state file, read and written where it is: state.json when a
213
- later version wrote one, else current.json, where every task starts. R34 is
214
- expand/contract: this version reads both names and moves nothing, so a
215
- revert of the move that comes later still finds the task."""
223
+ """The task's state file, read and written where it is: state.json, or
224
+ current.json when that is the file a task in flight sits in. A new task
225
+ starts in state.json (R34's contract half, migration 0009 moves an old one);
226
+ both names stay readable, so a revert of that move still finds the task."""
216
227
  new, legacy = (os.path.join(root, ".ai", "state", name) for name in STATE_NAMES)
217
- return new if os.path.exists(new) else legacy
228
+ return legacy if not os.path.exists(new) and os.path.exists(legacy) else new
218
229
 
219
230
 
220
231
  def remove_state(root):
@@ -331,7 +342,7 @@ def read_journal(root, task_id):
331
342
  def apply_defaults(state):
332
343
  """Schema 2 keys (I6) in memory, so a v1 state answers every command before
333
344
  migration 0002 has run; save() then persists them. A reader that opens
334
- current.json directly — update.py does — sees the file, not these."""
345
+ the state file directly — update.py does — sees the file, not these."""
335
346
  if is_v7(state):
336
347
  return apply_defaults_v7(state)
337
348
  task_id = state.get("task_id") or ""
@@ -443,13 +454,40 @@ def emit(root, state, event, detail="", data=None, legacy=None, actor="agent"):
443
454
  state.get("task_id"), event, actor, state.get("current_stage"), detail, data))
444
455
 
445
456
 
446
- def next_task_id(root):
447
- day = datetime.now(timezone.utc).strftime("%Y-%m-%d")
448
- reports = os.path.join(root, ".ai", "reports")
449
- existing = []
450
- if os.path.isdir(reports):
451
- existing = [n for n in os.listdir(reports) if n.startswith("T-%s-" % day)]
452
- return "T-%s-%03d" % (day, len(existing) + 1)
457
+ def sealed_task_ids(root):
458
+ """The task ids a package under .ai/memory/ seals; empty in files mode."""
459
+ found = set()
460
+ base = os.path.join(root, ".ai", "memory")
461
+ for name in sorted(os.listdir(base)) if os.path.isdir(base) else []:
462
+ try:
463
+ with open(os.path.join(base, name), "rb") as fh:
464
+ for raw in fh:
465
+ if b'"kind":"task"' not in raw:
466
+ continue
467
+ with contextlib.suppress(ValueError, UnicodeDecodeError, AttributeError):
468
+ found.add(json.loads(raw.decode("utf-8")).get("task"))
469
+ except OSError:
470
+ continue
471
+ return found
472
+
473
+
474
+ def next_task_id(root, goal="", slug=None, at=None):
475
+ """R1: T-YYYY-MM-DD-HHMM-<slug>, from the clock and the goal alone. A count
476
+ of the day's report directories gave two clones the same id and handed a
477
+ deleted directory's id to the next task. A local hit — a report directory,
478
+ the raw store under .ai/state/logs/, a sealed key — gets -2 … -9."""
479
+ import memory_format # pylint: disable=import-outside-toplevel
480
+ stamp = at or datetime.now(timezone.utc)
481
+ base = slug or memory_format.slug_of(goal)
482
+ sealed = sealed_task_ids(root)
483
+ for n in range(1, 10):
484
+ task_id = memory_format.new_task_id(stamp, memory_format.with_suffix(base, n))
485
+ if not (os.path.exists(os.path.join(root, ".ai", "reports", task_id))
486
+ or os.path.exists(os.path.join(root, ".ai", "state", "logs", task_id)) or task_id in sealed):
487
+ return task_id
488
+ die("%s and -2 … -9 are all taken this minute: start again with another --slug \"<3-24 characters>\""
489
+ % memory_format.new_task_id(stamp, base), 2)
490
+ return None
453
491
 
454
492
 
455
493
  # ------------------------------------------------------------------ plan profile (WP5)
@@ -542,7 +580,7 @@ def advise(state):
542
580
 
543
581
  def cmd_profile(args, _root):
544
582
  if args.action == "detect":
545
- cmd_profile_detect(args)
583
+ cmd_profile_detect(args, find_root_quiet(args.root))
546
584
  return
547
585
  if args.action == "set":
548
586
  cmd_profile_set(args)
@@ -717,9 +755,16 @@ def detect_codex():
717
755
  SAME_TIER = {"claude": ({"max", "max20"}, {"pro", "team-pro"}, {"max", "team-max"}), "codex": ()}
718
756
 
719
757
 
720
- def installer_command(runtime, plan, version):
721
- return "npx riskrails%s --target %s %s %s --yes" % (
722
- "@%s" % version if version else "", runtime, "--plan" if runtime == "claude" else "--codex-plan", plan)
758
+ def context_mode(prof):
759
+ """The record's --context: "economy" or None (default, or a record from before it)."""
760
+ context = (prof or {}).get("context")
761
+ return "economy" if isinstance(context, dict) and context.get("mode") == "economy" else None
762
+
763
+
764
+ def installer_command(runtime, plan, version, context=None):
765
+ return "npx riskrails%s --target %s %s %s%s --yes" % (
766
+ "@%s" % version if version else "", runtime, "--plan" if runtime == "claude" else "--codex-plan", plan,
767
+ " --context %s" % context if context else "")
723
768
 
724
769
 
725
770
  def profile_detect(runtime):
@@ -738,6 +783,8 @@ def profile_detect(runtime):
738
783
  status = "no_profile"
739
784
  elif have == live["plan"] or (runtime == "claude" and have == "balanced-max" and live["plan"] in ("max", "max20")):
740
785
  status = "same"
786
+ elif acknowledged(runtime, live["fingerprint"], have):
787
+ status = "acknowledged" # the human kept this very plan for this very account (R36.18)
741
788
  else:
742
789
  status = "drift"
743
790
  if any({have, live["plan"]} <= pair for pair in SAME_TIER[runtime]):
@@ -746,18 +793,95 @@ def profile_detect(runtime):
746
793
  shlex.quote(os.path.abspath(__file__)), live["plan"], runtime)
747
794
  else:
748
795
  render = "installer"
749
- command = installer_command(runtime, live["plan"], installed.get("plugin_version"))
796
+ command = installer_command(runtime, live["plan"], installed.get("plugin_version"),
797
+ context_mode(prof))
750
798
  return {"runtime": runtime, "status": status, "installed": installed, "live": live,
751
799
  "render": render, "command": command, "decision": None}
752
800
 
753
801
 
754
- def cmd_profile_detect(args):
755
- """Read-only, exit 0 always (R36.16); `--open` belongs to 7c."""
756
- if args.open:
757
- die("--open opens the plan_drift decision (task 7e)", 1)
802
+ def acknowledged(runtime, fingerprint, installed_plan):
803
+ """True when drift-ack.json holds this fingerprint AND the plan kept is the installed one."""
804
+ entry = read_drift_ack(runtime).get(runtime)
805
+ return isinstance(entry, dict) and entry.get("fingerprint") == fingerprint and \
806
+ entry.get("kept") == installed_plan
807
+
808
+
809
+ def drift_ack_path(runtime):
810
+ return os.path.join(runtime_home(runtime), "riskrails", "drift-ack.json")
811
+
812
+
813
+ def read_drift_ack(runtime):
814
+ """<home>/riskrails/drift-ack.json, or {}: per user, never per project (D2)."""
815
+ return read_json_object(drift_ack_path(runtime)) or {}
816
+
817
+
818
+ def write_drift_ack(runtime, fingerprint, kept, via):
819
+ """"Keep" on a plan_drift decision: the fingerprint, the plan kept and when. Plan
820
+ fields hashed, never an email, an id or a token (R36.16). Atomic."""
821
+ data = read_drift_ack(runtime)
822
+ data = {"version": 1, "claude": data.get("claude"), "codex": data.get("codex")}
823
+ data[runtime] = {"fingerprint": fingerprint, "kept": kept, "at": now(), "via": via}
824
+ path = drift_ack_path(runtime)
825
+ os.makedirs(os.path.dirname(path), exist_ok=True)
826
+ tmp = "%s.%d.tmp" % (path, os.getpid())
827
+ with open(tmp, "w", encoding="utf-8") as fh:
828
+ json.dump(data, fh, indent=2)
829
+ fh.write("\n")
830
+ os.replace(tmp, path)
831
+
832
+
833
+ def open_drift(root, found):
834
+ """`profile detect --open` (R36.18): on drift, with the dialog on, the plan_drift
835
+ decision — option 1 keeps the installed plan (and writes drift-ack.json when
836
+ settled), option 2 is the `command` detect prints. The id of the decision, an
837
+ open one for the same fingerprint included; None when nothing was opened."""
838
+ runtime = found["runtime"]
839
+ if not root or runtime != own_runtime():
840
+ return None
841
+ fingerprint = found["live"]["fingerprint"]
842
+ on = dialog_on(root) if found["status"] == "drift" else None
843
+ session = (on or {}).get("session")
844
+ reuse = None
845
+ for decision in read_decisions(root)["open"]:
846
+ drift = decision.get("drift") or {}
847
+ if decision.get("kind") != "plan_drift" or drift.get("runtime") != runtime:
848
+ continue
849
+ if on and reuse is None and drift.get("fingerprint") == fingerprint and \
850
+ decision.get("session") == session:
851
+ reuse = decision["id"]
852
+ else:
853
+ # resolved, acknowledged elsewhere, another account or another session's: stale
854
+ settle_decision(root, decision["id"], "fallback")
855
+ if not on:
856
+ return None
857
+ if reuse:
858
+ return reuse
859
+ kept = found["installed"]["plan"]
860
+ live = found["live"]
861
+ question = "This %s account is %s (%s); RiskRails is installed for %s.\nKeep %s, or run the command below?" % (
862
+ runtime, live["plan"], live["from"] or "no detail", kept, kept)
863
+ state = load(root, required=False, claim=False)
864
+ state = state if state and is_v7(state) else None
865
+ options = [{"kind": "choice", "effect": "ack", "label": "Keep %s — do not ask again for this account" % kept},
866
+ {"kind": "command", "label": found["command"]}]
867
+ decision, why = open_decision(root, state, "plan_drift", "Plan drift", question, options, "profile detect",
868
+ drift={"runtime": runtime, "fingerprint": fingerprint, "kept": kept})
869
+ if decision is None:
870
+ print("state.py: no dialog — %s" % why, file=sys.stderr)
871
+ return None
872
+ return decision["id"]
873
+
874
+
875
+ def cmd_profile_detect(args, root):
876
+ """Read-only, exit 0 always (R36.16); `--open` also opens the plan_drift decision (R36.18)."""
758
877
  if args.field or args.tier or args.other or args.plan:
759
878
  die("--field, --tier, --other and a plan belong to the profile reader or profile set, not to profile detect")
760
879
  found = profile_detect(args.detect_runtime or own_runtime())
880
+ if args.open:
881
+ try:
882
+ found["decision"] = open_drift(root, found)
883
+ except SystemExit: # decision.json unreadable: stay read-only, exit 0 (R36.16)
884
+ found["decision"] = None
761
885
  if args.json:
762
886
  print(json.dumps(found, ensure_ascii=False))
763
887
  return
@@ -767,6 +891,8 @@ def cmd_profile_detect(args):
767
891
  have, live["plan"] or "unknown", " (%s)" % live["from"] if live["from"] else "", found["status"]))
768
892
  if found["command"]:
769
893
  print("fix: %s" % found["command"])
894
+ if found["decision"]:
895
+ print("decision: %s" % found["decision"])
770
896
 
771
897
  # plan -> (profile, label), as bin/riskrails.js maps --plan and --codex-plan; the
772
898
  # installer and `profile set` must resolve a plan to the same profile.json.
@@ -814,7 +940,7 @@ def cmd_profile_set(args):
814
940
  return
815
941
  refusal = "PROFILE_NEEDS_INSTALLER — %s → %s also changes %s: %s" % (
816
942
  have, args.plan, "settings.json and the agents" if runtime == "claude" else "config.toml and the agents",
817
- installer_command(runtime, args.plan, prof.get("plugin_version")))
943
+ installer_command(runtime, args.plan, prof.get("plugin_version"), context_mode(prof)))
818
944
  if have not in plans or not any({have, args.plan} <= pair for pair in SAME_TIER[runtime]):
819
945
  die(refusal, 6)
820
946
  resolver, src = load_resolver(runtime)
@@ -822,9 +948,12 @@ def cmd_profile_set(args):
822
948
  die("no resolve-profile.py under %s/riskrails/scripts or the plugin; npx riskrails installs it"
823
949
  % runtime_home(runtime))
824
950
  fable = bool(prof.get("fable"))
951
+ # Only an economy record passes context: a resolver installed before --context
952
+ # has no such parameter, and only a later one writes an economy record.
953
+ extra = {"context": "economy"} if context_mode(prof) else {}
825
954
  try:
826
- old = resolver.resolve(src, plans[have][0], have, plans[have][1], fable)
827
- new = resolver.resolve(src, plans[args.plan][0], args.plan, plans[args.plan][1], fable)
955
+ old = resolver.resolve(src, plans[have][0], have, plans[have][1], fable, **extra)
956
+ new = resolver.resolve(src, plans[args.plan][0], args.plan, plans[args.plan][1], fable, **extra)
828
957
  except (OSError, ValueError) as exc:
829
958
  die("cannot resolve the %s profile: %s" % (args.plan, exc))
830
959
  key = resolver.KEY
@@ -890,13 +1019,16 @@ def cmd_weight(args, root):
890
1019
  % item)
891
1020
  declared[m.group(1)] = (int(m.group(2)), m.group(3).strip())
892
1021
  try:
893
- result = weight.compute(root, files, declared, args.goal, policy=policy)
1022
+ result = weigh(root, files, declared, args.goal, policy)
894
1023
  except ValueError as exc:
895
1024
  die(str(exc))
896
1025
  except (TypeError, AttributeError) as exc: # a table whose values have the wrong shape
897
1026
  die("%s is malformed: %s" % (path, exc))
898
1027
  if args.json:
899
- print(json.dumps(dict(result.as_dict(), policy=path), indent=2, ensure_ascii=False))
1028
+ out = dict(result.as_dict(), policy=path)
1029
+ if result.documents_only is not None:
1030
+ out["documents_only"] = result.documents_only
1031
+ print(json.dumps(out, indent=2, ensure_ascii=False))
900
1032
  else:
901
1033
  print(weight.render(result, long=args.long))
902
1034
 
@@ -967,7 +1099,7 @@ def _split_files(text):
967
1099
 
968
1100
  def cmd_event(args, root):
969
1101
  """For hooks and for WP5's model fallback: one journal line, no state write.
970
- current.json keeps its two writers; this command is not one of them."""
1102
+ The state file keeps its two writers; this command is not one of them."""
971
1103
  if args.type not in EVENT_TYPES:
972
1104
  die("unknown event type '%s' (have: %s)" % (args.type, ", ".join(EVENT_TYPES)))
973
1105
  if args.type in FORGEABLE_EVENTS:
@@ -1702,6 +1834,15 @@ def handoff_lines(root, state, reason):
1702
1834
  pending = [q["id"] for q in parse_questions(path)[1] if is_pending(q)]
1703
1835
  out.append("Pending questions: %s" % (
1704
1836
  "%s — %s" % (", ".join(pending), os.path.relpath(path, root)) if pending else "none"))
1837
+ if batch_on(state):
1838
+ try:
1839
+ policy = weight.load_policy(root)[0] if weight else {}
1840
+ except (OSError, ValueError):
1841
+ policy = {} # best effort: the handoff never fails a command
1842
+ out.append("Batch: on · ready %s · review %d/%d · suite %d/%d" % (
1843
+ ", ".join(one_line(s["id"], 40) for s in ready_steps(state)) or "none",
1844
+ (state.get("review") or {}).get("passes", 0), budget(policy, state, "review_rounds_max")[0],
1845
+ (state.get("verify") or {}).get("suite_runs", 0), budget(policy, state, "max_suite_runs")[0]))
1705
1846
 
1706
1847
  notes = recent_notes(root, state)
1707
1848
  for kind, heading in (("decision", "Decisions"), ("rejected", "Rejected"),
@@ -1749,7 +1890,7 @@ def write_handoff(root, state, reason):
1749
1890
  nothing validates.
1750
1891
 
1751
1892
  It mutates `state` in memory only. The caller that is already changing the
1752
- task persists it; `handoff` on its own never writes current.json, because
1893
+ task persists it; `handoff` on its own never writes the state file, because
1753
1894
  the hook calls it while another command may be mid-write."""
1754
1895
  path = handoff_path(root)
1755
1896
  try:
@@ -1826,7 +1967,7 @@ def handoff_to(args, root):
1826
1967
  emit(root, state, "runtime_handoff", "%s -> %s (%s)" % (source, args.to, via), data)
1827
1968
  write_handoff(root, state, "manual")
1828
1969
  save(root, state)
1829
- print("Handed %s to %s (%s). In %s, run /ai-task: it resumes from .ai/state/current.json."
1970
+ print("Handed %s to %s (%s). In %s, run /ai-task: it resumes from the state file in .ai/state/."
1830
1971
  % (state.get("task_id"), args.to, via, args.to))
1831
1972
  print("headless (not run): cd %s && %s" % (shlex.quote(root), RESUME_COMMAND[args.to]))
1832
1973
 
@@ -2170,7 +2311,7 @@ INTERPRETER_RE = re.compile(r"^python3?$") # a bare name: a path could be any
2170
2311
  PUSH_REF_RE = re.compile(r"^[A-Za-z0-9._/-]+$") # no '+' (a force push), no ':' (a refspec), no option
2171
2312
  INTERPRETER_FLAG_RE = re.compile(r"^-[IS]+$")
2172
2313
  NPX_PACKAGE_RE = re.compile(r"^riskrails@[0-9A-Za-z.+-]+$")
2173
- SETTLE_EFFECT = {"defer": "defer", "keep": "ack", "answer": "answered"}
2314
+ SETTLE_EFFECT = {"defer": "defer", "keep": "ack", "ack": "ack", "answer": "answered"}
2174
2315
  DIALOG_PICK = None # the pick human_route() consumed in this process
2175
2316
 
2176
2317
 
@@ -2490,7 +2631,8 @@ def open_decision(root, state, kind, header, question, options, opened_by, multi
2490
2631
  return None, "CONSENT_LABEL_INVALID — two options carry the same label"
2491
2632
  decision = dict({
2492
2633
  "id": next_decision_id(data, read_ledger(root)), "kind": kind, "task": (state or {}).get("task_id"),
2493
- "runtime": own_runtime(), "tree": current_tree(root), "opened_at": now(), "opened_by": opened_by,
2634
+ "runtime": own_runtime(), "session": (dialog_on(root) or {}).get("session"),
2635
+ "tree": current_tree(root), "opened_at": now(), "opened_by": opened_by,
2494
2636
  "header": one_line(header, HEADER_MAX), "multi_select": bool(multi),
2495
2637
  "question": (question or "").strip()[:QUESTION_MAX],
2496
2638
  "options": [dict(option, n=n) for n, option in enumerate(options, 1)],
@@ -2564,7 +2706,8 @@ def settle_decision(root, decision_id, effect, pick=None):
2564
2706
 
2565
2707
  def prune_decisions(root):
2566
2708
  """On every run: a decision with a settle line, or whose latest round's
2567
- command picks are all used, leaves decision.json (R36.14)."""
2709
+ command picks are all used, leaves decision.json (R36.14); with the
2710
+ project's dialog switch off, every open one falls back (R36.15)."""
2568
2711
  try:
2569
2712
  with open(decision_path(root), encoding="utf-8") as fh:
2570
2713
  data = json.load(fh)
@@ -2575,11 +2718,17 @@ def prune_decisions(root):
2575
2718
  settled = {e.get("decision") for e in ledger if e["type"] == "settle"}
2576
2719
  used = {e.get("id") for e in ledger if e["type"] == "use"}
2577
2720
  rounds = latest_rounds(ledger)
2721
+ off = dialog_policy(root) is None
2578
2722
  for decision in list(keep):
2579
2723
  commands = {o.get("n") for o in decision.get("options") or [] if o.get("kind") == "command"}
2580
2724
  picks = [e for e in rounds.get(decision.get("id"), []) if e.get("option") in commands]
2581
2725
  if decision.get("id") in settled:
2582
2726
  keep.remove(decision)
2727
+ elif off:
2728
+ with contextlib.suppress(OSError), ledger_lock(root):
2729
+ ledger_append(root, {"type": "settle", "decision": decision["id"], "pick": None,
2730
+ "effect": "fallback"})
2731
+ keep.remove(decision)
2583
2732
  elif picks and all(p.get("id") in used for p in picks):
2584
2733
  parsed = label_argv(picks[-1].get("label") or "")
2585
2734
  effect = "declined" if parsed and subcommand(parsed[1])[:1] == ["reject"] else "approved"
@@ -2727,7 +2876,11 @@ def cmd_decision(args, root):
2727
2876
  return
2728
2877
  data = read_decisions(root)
2729
2878
  if args.action == "show":
2730
- chosen = [d for d in data["open"] if not args.id or d["id"] == args.id]
2879
+ # A session sees the decisions it opened (and those that name none):
2880
+ # the hooks' gate and Stop reminder hold only the opener to them.
2881
+ session = os.environ.get(SESSION_ENV.get(own_runtime(), ""))
2882
+ chosen = [d for d in data["open"] if (not args.id or d["id"] == args.id) and (
2883
+ not session or (d.get("session") in (None, "", session) and d.get("runtime") in (None, "", own_runtime())))]
2731
2884
  if not chosen:
2732
2885
  die("no open decision%s" % (" " + args.id if args.id else ""))
2733
2886
  if args.json:
@@ -2823,6 +2976,9 @@ def decision_settle(args, root, decision):
2823
2976
 
2824
2977
  def apply_choice(root, decision, option, pick_id, by, via):
2825
2978
  effect = SETTLE_EFFECT.get(option.get("effect"), "defer")
2979
+ drift = decision.get("drift") or {}
2980
+ if decision.get("kind") == "plan_drift" and effect == "ack" and drift.get("fingerprint"):
2981
+ write_drift_ack(drift.get("runtime") or own_runtime(), drift["fingerprint"], drift.get("kept"), via)
2826
2982
  if effect == "answered":
2827
2983
  cmd_answer(argparse.Namespace(topic=None, prose=None, by=by, via=via,
2828
2984
  assignment=["%s=%s" % (decision.get("question_id"), option["answer"])]),
@@ -2919,6 +3075,11 @@ def apply_defaults_v7(state):
2919
3075
  ("findings", []), ("diff", {}), ("gate", None), ("mode_override", None)):
2920
3076
  if not isinstance(state.get(key), type(default)) and not (default is None and key in state):
2921
3077
  state[key] = default
3078
+ for step in state["steps"]:
3079
+ if not isinstance(step.get("after"), list):
3080
+ step["after"] = [] # a step from before the batch loop depends on nothing
3081
+ if not isinstance(state.get("batch"), dict):
3082
+ state["batch"] = {"on": False, "by": None, "why": None, "since": None}
2922
3083
  if not isinstance(state.get("verify"), dict):
2923
3084
  state["verify"] = {}
2924
3085
  for key, default in (("status", "not_run"), ("e2e", "not_run"), ("suite_runs", 0), ("runs", [])):
@@ -2978,6 +3139,60 @@ def mode_setting(policy, mode, key, default=None):
2978
3139
  return default
2979
3140
 
2980
3141
 
3142
+ # The batch loop of /rr-build-all: one task for a whole plan, its own budgets.
3143
+ BATCH_DEFAULTS = {"default": False, "review_rounds_max": 3, "max_suite_runs": 8, "parallel": 3, "tier": "BALANCED"}
3144
+
3145
+
3146
+ BATCH_TIERS = ["FAST", "BALANCED", "STRONG", "EXPERT"]
3147
+
3148
+
3149
+ def batch_policy(policy, strict=False):
3150
+ """weight.json's batch section with every key filled in and checked. A table
3151
+ from before the section runs with the shipped numbers (R13). A bad value is
3152
+ refused where the task starts or the loop is switched (strict); a reader —
3153
+ status, the handoff — falls back to the shipped value and never crashes."""
3154
+ section = policy.get("batch") if isinstance(policy.get("batch"), dict) else {}
3155
+ out, problems = dict(BATCH_DEFAULTS), []
3156
+ for key, value in section.items():
3157
+ if key not in BATCH_DEFAULTS:
3158
+ continue
3159
+ if key == "default":
3160
+ if isinstance(value, bool):
3161
+ out[key] = value
3162
+ else:
3163
+ problems.append("batch.default must be true or false, not %r" % (value,))
3164
+ elif key == "tier":
3165
+ if isinstance(value, str) and value.upper() in BATCH_TIERS:
3166
+ out[key] = value.upper()
3167
+ else:
3168
+ problems.append("batch.tier must be one of %s, not %r" % ("/".join(BATCH_TIERS), value))
3169
+ elif isinstance(value, bool) or not isinstance(value, int) or value < 1:
3170
+ problems.append("batch.%s must be a whole number of at least 1, not %r" % (key, value))
3171
+ else:
3172
+ out[key] = value
3173
+ if out["parallel"] >= 3 and out["tier"] not in ("FAST", "BALANCED"):
3174
+ problems.append("batch.tier %s with batch.parallel %d: three or more parallel agents run at FAST or "
3175
+ "BALANCED (model-routing.md, Fan-out)" % (out["tier"], out["parallel"]))
3176
+ if problems and strict:
3177
+ die("weight.json: " + "; ".join(problems))
3178
+ return out
3179
+
3180
+
3181
+ def batch_on(state):
3182
+ return bool((state.get("batch") or {}).get("on"))
3183
+
3184
+
3185
+ def budget(policy, state, key):
3186
+ """(limit, the table key the refusal names) for review_rounds_max or
3187
+ max_suite_runs: the batch section's while the batch loop is on, else the
3188
+ standard one — a task switched off mid-way keeps what it already spent."""
3189
+ if batch_on(state):
3190
+ return int(batch_policy(policy)[key]), "batch.%s" % key
3191
+ if key == "max_suite_runs":
3192
+ return int((policy.get("tests") or {}).get(key, 2)), "tests.max_suite_runs"
3193
+ return int(policy.get(key, 2)), key
3194
+
3195
+
2981
3196
  def read_text(root, rel):
2982
3197
  try:
2983
3198
  with open(os.path.join(root, rel), "rb") as fh:
@@ -3108,7 +3323,8 @@ def task_md_path(root, state):
3108
3323
 
3109
3324
 
3110
3325
  def parse_steps(text, plan_file):
3111
- """--steps "id:a,b:t;id2:c" or --plan-file F.json ([{id, files, tests}])."""
3326
+ """--steps "id:a,b:t;id2:c" or --plan-file F.json ([{id, files, tests, after}]);
3327
+ `after` (plan-file only) names the steps this one waits for (R2)."""
3112
3328
  raw = []
3113
3329
  if plan_file:
3114
3330
  try:
@@ -3117,24 +3333,91 @@ def parse_steps(text, plan_file):
3117
3333
  except (OSError, ValueError) as exc:
3118
3334
  die("--plan-file %s: %s" % (plan_file, exc))
3119
3335
  if not isinstance(raw, list) or not all(isinstance(s, dict) for s in raw):
3120
- die("--plan-file holds a JSON list of {\"id\", \"files\", \"tests\"}")
3121
- raw = [(str(s.get("id") or ""), ",".join(s.get("files") or []), ",".join(s.get("tests") or []))
3122
- for s in raw]
3336
+ die("--plan-file holds a JSON list of {\"id\", \"files\", \"tests\", \"after\"}")
3337
+ for s in raw:
3338
+ after = s.get("after") or []
3339
+ if not isinstance(after, list) or not all(isinstance(a, str) and a.strip() for a in after):
3340
+ die("step %s: \"after\" is a JSON list of step ids" % (s.get("id") or "?"))
3341
+ raw = [(str(s.get("id") or ""), ",".join(s.get("files") or []), ",".join(s.get("tests") or []),
3342
+ [one_line(a, 40) for a in s.get("after") or []]) for s in raw]
3123
3343
  for chunk in [c for c in (text or "").split(";") if c.strip()]:
3124
3344
  parts = chunk.split(":")
3125
- raw.append((parts[0], parts[1] if len(parts) > 1 else "", parts[2] if len(parts) > 2 else ""))
3345
+ raw.append((parts[0], parts[1] if len(parts) > 1 else "", parts[2] if len(parts) > 2 else "", []))
3126
3346
  steps = []
3127
- for step_id, files, tests in raw:
3347
+ for step_id, files, tests, after in raw:
3128
3348
  step_id = one_line(step_id, 40)
3129
3349
  if not step_id or not _split_files(files):
3130
3350
  die("a step is id:files[:tests], with at least one file: %r" % ":".join((step_id, files, tests)))
3131
3351
  if step_id in [s["id"] for s in steps]:
3132
3352
  die("step id %s appears twice" % step_id)
3133
3353
  steps.append({"id": step_id, "files": _split_files(files), "tests": _split_files(tests),
3134
- "status": "pending", "tree_before": None, "tree_after": None})
3354
+ "after": list(dict.fromkeys(after)), "status": "pending", "tree_before": None,
3355
+ "tree_after": None})
3135
3356
  return steps
3136
3357
 
3137
3358
 
3359
+ def _overlap(a, b):
3360
+ """One path or pattern covers the other: equal once normalised, or one
3361
+ matches the other as a glob — so `src/*.py` and `./src/a.py` count as shared."""
3362
+ a, b = os.path.normpath(a), os.path.normpath(b)
3363
+ return a == b or bool(weight and (weight.match(a, [b]) or weight.match(b, [a])))
3364
+
3365
+
3366
+ def order_shared_files(steps):
3367
+ """Two steps that name one file or pattern — among their files or their
3368
+ tests — never run side by side: the later one is put after the earlier (R2).
3369
+ Returns the implied edges as (later, earlier, what they share)."""
3370
+ implied = []
3371
+ for n, later in enumerate(steps):
3372
+ for earlier in steps[:n]:
3373
+ shared = [f for f in later["files"] + later["tests"]
3374
+ if any(_overlap(f, g) for g in earlier["files"] + earlier["tests"])]
3375
+ if shared and earlier["id"] not in later["after"]:
3376
+ later["after"].append(earlier["id"])
3377
+ implied.append((later["id"], earlier["id"], ", ".join(dict.fromkeys(shared))))
3378
+ return implied
3379
+
3380
+
3381
+ def check_steps(steps, implied=()):
3382
+ """Every `after` names another, existing step, and the steps form no cycle (R2).
3383
+ A cycle closed by an implied edge (order_shared_files) names that edge."""
3384
+ ids = [s["id"] for s in steps]
3385
+ for step in steps:
3386
+ for dep in step["after"]:
3387
+ if dep == step["id"]:
3388
+ die("step %s lists itself in after" % step["id"])
3389
+ if dep not in ids:
3390
+ die("step %s is after %s, which is not a step (have: %s)" % (step["id"], dep, ", ".join(ids)))
3391
+ by_id = {s["id"]: s for s in steps}
3392
+ seen = {} # id -> "visiting" | "done"
3393
+
3394
+ def visit(step_id, trail):
3395
+ if seen.get(step_id) == "done":
3396
+ return
3397
+ if seen.get(step_id) == "visiting":
3398
+ loop = trail + [step_id]
3399
+ hints = ["%s after %s is implied: they share %s" % edge for edge in implied
3400
+ if edge[0] in loop and edge[1] in loop]
3401
+ die("the steps' after form a cycle: %s%s" % (
3402
+ " -> ".join(loop), " (%s — list the dependent row after the one it needs)" % "; ".join(hints)
3403
+ if hints else ""))
3404
+ seen[step_id] = "visiting"
3405
+ for dep in by_id[step_id]["after"]:
3406
+ visit(dep, trail + [step_id])
3407
+ seen[step_id] = "done"
3408
+
3409
+ for step in steps:
3410
+ visit(step["id"], [])
3411
+
3412
+
3413
+ def ready_steps(state):
3414
+ """The steps not yet done whose `after` are all done, in plan order: the next
3415
+ wave of /rr-build-all, and the step step-done starts next."""
3416
+ steps = state.get("steps") or []
3417
+ done = {s["id"] for s in steps if s.get("status") == "done"}
3418
+ return [s for s in steps if s.get("status") != "done" and all(d in done for d in s.get("after") or [])]
3419
+
3420
+
3138
3421
  def parse_declares(items):
3139
3422
  declared = {}
3140
3423
  for item in items or []:
@@ -3147,6 +3430,10 @@ def parse_declares(items):
3147
3430
 
3148
3431
 
3149
3432
  def next_action_for(state):
3433
+ if batch_on(state):
3434
+ return ("batch loop: 'steps --ready' → implementers, step-done each; 'verify --scope all', red fixed "
3435
+ "as one batch; review, findings fixed as one batch; again until it passes; the pass past the "
3436
+ "batch budget is the human's")
3150
3437
  return {
3151
3438
  "light": "edit, then 'state.py verify --scope all', then 'state.py close'",
3152
3439
  "guarded": "edit, 'state.py verify --scope all', one review ('state.py review'), then 'state.py close'",
@@ -3180,9 +3467,23 @@ def cmd_start(args, root):
3180
3467
  die("start needs a git repository: the two facts close decides by are measured on its tree")
3181
3468
  steps = parse_steps(args.steps, args.plan_file)
3182
3469
  policy = weight_policy(root)
3470
+ table = batch_policy(policy, strict=True) # a malformed section is refused here, not at status
3471
+ if args.batch and args.no_batch:
3472
+ die("--batch and --no-batch exclude each other")
3473
+ if args.batch and not args.by:
3474
+ die("--batch needs --by \"<who asked for the batch loop>\": it is journaled with its author")
3475
+ batch = {"on": False, "by": None, "why": None, "since": None}
3476
+ if args.batch:
3477
+ batch = {"on": True, "by": args.by, "why": args.why or None, "since": now()}
3478
+ elif not args.no_batch and table["default"]:
3479
+ batch = {"on": True, "by": "weight.json batch.default", "why": None, "since": now()}
3480
+ # Shared files order steps only in a batch task, where steps run side by
3481
+ # side; a standard task's steps run one after another, as before.
3482
+ implied = order_shared_files(steps) if batch["on"] else []
3483
+ check_steps(steps, implied)
3183
3484
  concrete = [f for f in files if not any(c in f for c in "*?[")]
3184
3485
  try:
3185
- result = weight.compute(root, concrete, parse_declares(args.declare), args.goal, policy=policy)
3486
+ result = weigh(root, concrete, parse_declares(args.declare), args.goal, policy)
3186
3487
  except ValueError as exc:
3187
3488
  die(str(exc))
3188
3489
  except (TypeError, AttributeError) as exc:
@@ -3211,24 +3512,33 @@ def cmd_start(args, root):
3211
3512
  args.mode, shlex.quote(args.by), why), 5)
3212
3513
  override = {"from": mode, "to": args.mode, "by": args.by, "why": args.why, "ts": now()}
3213
3514
  mode = args.mode
3214
- task_id = valid_task_id(args.task_id) if args.task_id else next_task_id(root)
3515
+ if args.slug is not None:
3516
+ import memory_format # pylint: disable=import-outside-toplevel
3517
+ if args.task_id:
3518
+ die("--slug and --task-id both name the id: pass one", 2)
3519
+ if not memory_format.SLUG_RE.match(args.slug):
3520
+ die("--slug takes 3-24 characters of [a-z0-9] words joined by '-', not %r" % args.slug, 2)
3521
+ task_id = valid_task_id(args.task_id) if args.task_id else next_task_id(root, args.goal, args.slug)
3215
3522
  base_tree = current_tree(root)
3216
3523
  state = {
3217
3524
  "schema": SCHEMA, "task_id": task_id, "goal": args.goal, "workflow": args.workflow,
3218
3525
  "mode": mode, "weight": dict(result.as_dict(), lines=weight.render(result).split("\n"),
3219
3526
  tree=base_tree, computed_at=now(), rescores=[], operations=[]),
3220
3527
  "mode_override": override, "stage": "build", "files": files, "tests": tests,
3221
- "steps": steps, "amendments": [], "findings": [],
3528
+ "steps": steps, "amendments": [], "findings": [], "batch": batch,
3222
3529
  "diff": {"base_tree": base_tree, "base_commit": safe_sensor(sensors.head_commit, root)},
3223
3530
  "gate": None, "owner_runtime": RUNTIME if RUNTIME in RUNTIMES else None,
3224
3531
  "created": now(), "created_at": now(), "history": [],
3225
3532
  }
3533
+ if result.documents_only is not None:
3534
+ state["weight"]["documents_only"] = result.documents_only
3226
3535
  if os.path.exists(ledger_path(root)):
3227
3536
  # The ledger is per project: a task mirrors only the lines after its start.
3228
3537
  state["dialog"] = {"ledger_pos": os.path.getsize(ledger_path(root))}
3229
3538
  apply_defaults_v7(state)
3230
3539
  if steps:
3231
- steps[0].update(status="in_progress", tree_before=base_tree)
3540
+ # The first step whose dependencies are done — steps[0] in a plan without `after`.
3541
+ next(iter(ready_steps(state)), steps[0]).update(status="in_progress", tree_before=base_tree)
3232
3542
  arm_scope(state)
3233
3543
  state["next_action"] = next_action_for(state)
3234
3544
  emit(root, state, "task_started", one_line(args.goal, 200),
@@ -3243,6 +3553,9 @@ def cmd_start(args, root):
3243
3553
  actor="human" if MODES.index(mode) < MODES.index(override["from"]) else "agent")
3244
3554
  emit(root, state, "tier_set", "%s (mode %s, compat)" % (MODE_TIER[mode], mode),
3245
3555
  {"tier": MODE_TIER[mode], "mode": mode, "w": result.w})
3556
+ if batch["on"]:
3557
+ emit(root, state, "batch_set", "on by %s" % batch["by"],
3558
+ {"on": True, "by": batch["by"], "why": batch["why"], "via": None, "review_passes": 0, "suite_runs": 0})
3246
3559
  set_resume_point(state)
3247
3560
  write_handoff(root, state, "start")
3248
3561
  save(root, state)
@@ -3250,6 +3563,11 @@ def cmd_start(args, root):
3250
3563
  print(weight.render(result))
3251
3564
  if override:
3252
3565
  print("mode: %s (override of %s by %s)" % (mode, override["from"], args.by))
3566
+ for later, earlier, shared in implied:
3567
+ print("ordered: %s after %s (shares %s)" % (later, earlier, shared))
3568
+ if batch["on"]:
3569
+ print("batch: on by %s · review %s passes · suite %s runs · %s at once on %s"
3570
+ % (batch["by"], table["review_rounds_max"], table["max_suite_runs"], table["parallel"], table["tier"]))
3253
3571
  advice = int((policy.get("rules") or {}).get("steps_advice_over", 8))
3254
3572
  if len(steps) > advice:
3255
3573
  print("advice: %d steps, over %d — fewer, larger steps cost fewer handoffs (not a gate)"
@@ -3291,6 +3609,18 @@ def rescore_v7(root, state, policy, at):
3291
3609
  return
3292
3610
  current = state["weight"].get("axes") or {}
3293
3611
  got = rescore_weight(root, state["diff"].get("base_tree"), [d["path"] for d in diff], current, policy)
3612
+ # The documents rule (faster-task-cycle (c)): the axes it zeroed come back, upward, once the
3613
+ # diff holds a path that is neither a document nor a test — `start --amend` does not re-weigh.
3614
+ zeroed = (state["weight"].get("documents_only") or {}).get("zeroed") or {}
3615
+ tests = ((policy.get("axes") or {}).get("coverage") or {}).get("test_globs")
3616
+ outside = next((d["path"] for d in diff if not is_document(d["path"], policy)
3617
+ and not weight.match(d["path"], tests)), None)
3618
+ if outside:
3619
+ for axis, value in zeroed.items():
3620
+ if int(got["axes"].get(axis) or 0) < int(value):
3621
+ got["raised"].append({"axis": axis, "from": int(got["axes"].get(axis) or 0), "to": int(value),
3622
+ "evidence": "a non-document changed (%s)" % outside})
3623
+ got["axes"][axis] = int(value)
3294
3624
  before = state["mode"]
3295
3625
  ops = sorted(set(state["weight"].get("operations") or []) | set(got["operations"]))
3296
3626
  # Only what the diff moved can move the mode: an unchanged measurement must
@@ -3327,6 +3657,86 @@ def suite_green(state):
3327
3657
  return any(full_suite(r) and r.get("exit") == 0 for r in state["verify"]["runs"])
3328
3658
 
3329
3659
 
3660
+ # Never a document, whatever weight.json's `documents` says: the policies and the per-directory rules bind the agents.
3661
+ NEVER_DOCUMENTS = (".ai/policies/**", ".ai/rules/**")
3662
+
3663
+
3664
+ def is_document(path, policy):
3665
+ """The one definition of a document (faster-task-cycle D4): `documents.globs`, minus
3666
+ `documents.except`, NEVER_DOCUMENTS, code and tests; no `documents` key, no document."""
3667
+ docs = policy.get("documents") or {}
3668
+ cov = (policy.get("axes") or {}).get("coverage") or {}
3669
+ return bool(weight.match(path, docs.get("globs"))
3670
+ and not any(weight.match(path, pats) for pats in (
3671
+ docs.get("except"), NEVER_DOCUMENTS, cov.get("code_globs"), cov.get("test_globs"))))
3672
+
3673
+
3674
+ DOCUMENTS_ZEROED = ("behaviour", "domain", "novelty")
3675
+
3676
+
3677
+ def weigh(root, files, declared, goal, policy):
3678
+ """weight.compute, plus the documents rule (faster-task-cycle (c), in state.py because
3679
+ weight.py is at its line cap). Predicate P: at least one concrete file, every file a
3680
+ document (is_document) or a test, at least one a document. When P holds, file text no
3681
+ longer scores behaviour (the goal's keyword still does, exactly as in compute), and
3682
+ behaviour, domain and novelty still UNKNOWN become 0 with the evidence "documents only"; hints and
3683
+ declarations were applied inside compute. `documents_only` is {"zeroed": {axis: what it
3684
+ would have scored}}, None when P does not hold or the policy has no `documents` key."""
3685
+ concrete = [posixpath.normpath(f.strip().replace(os.sep, "/")) for f in files if f.strip()]
3686
+ tests = ((policy.get("axes") or {}).get("coverage") or {}).get("test_globs")
3687
+ inside = all(not f.startswith(("/", "../")) and f != ".." for f in concrete)
3688
+ if not (inside and any(is_document(f, policy) for f in concrete)
3689
+ and all(is_document(f, policy) or weight.match(f, tests) for f in concrete)):
3690
+ result = weight.compute(root, files, declared, goal, policy=policy)
3691
+ result.documents_only = None
3692
+ return result
3693
+ scored = copy.deepcopy(policy)
3694
+ keywords = list(((policy.get("axes") or {}).get("behaviour") or {}).get("keywords") or [])
3695
+ if not (goal and any(re.search(r"\b%s\b" % re.escape(k), goal, re.I) for k in keywords)):
3696
+ # No goal keyword: file text must not score. With one, compute reads the goal first
3697
+ # (value 1 whatever the files hold), so keeping the list scores exactly as plain compute does.
3698
+ scored["axes"]["behaviour"] = dict(scored["axes"].get("behaviour") or {}, keywords=[])
3699
+ result = weight.compute(root, files, declared, goal, policy=scored)
3700
+ zeroed = {}
3701
+ for axis in DOCUMENTS_ZEROED:
3702
+ if axis not in result.unknown:
3703
+ continue
3704
+ if result.axes[axis] != 0:
3705
+ zeroed[axis] = result.axes[axis]
3706
+ result.axes[axis], result.evidence[axis] = 0, "documents only"
3707
+ result.unknown.remove(axis)
3708
+ result.w = sum(result.axes.values())
3709
+ result.mode = mode_for(result.axes, policy)
3710
+ result.documents_only = {"zeroed": zeroed}
3711
+ return result
3712
+
3713
+
3714
+ def documents_cover(root, state, policy, tree):
3715
+ """Fact (a) through the documents rule (faster-task-cycle D1-D5): (ok, why, facts).
3716
+ why is "" when the rule is off or does not apply: today's refusal text stands."""
3717
+ off = (False, "", None)
3718
+ tests = (safe_sensor(sensors.read_commands, root) or {}).get("doc_tests", "").split()
3719
+ if not (policy.get("documents") or {}).get("globs") or not tests or tests == ["none"]:
3720
+ return off # D1
3721
+ runs = [r for r in state["verify"]["runs"] if full_suite(r) and r.get("exit") is not None and r.get("tree")]
3722
+ g = runs[-1] if runs else None
3723
+ if not g or g["exit"] != 0 or any(r["tree"] == g["tree"] and r["exit"] != 0 for r in runs):
3724
+ return off # D2
3725
+ diff = safe_sensor(sensors.diff_files, root, g["tree"], tree)
3726
+ if not diff or not all(is_document(d["path"], policy) for d in diff):
3727
+ return off # D3, D4
3728
+ paths = [d["path"] for d in diff]
3729
+ steps = [r for r in state["verify"]["runs"] if r.get("scope") == "step" and not r.get("expected_fail")
3730
+ and r.get("tree") == tree]
3731
+ s = steps[-1] if steps else None # D5
3732
+ if not (s and s["n"] > g["n"] and s.get("exit") == 0 and all(t in (s.get("files") or []) for t in tests)):
3733
+ return (False, "only documents changed since run %d (%d files): 'verify --scope step --files \"%s\"'"
3734
+ % (g["n"], len(paths), ",".join(tests)), None)
3735
+ return (True, "documents only since run %d (%d files: %s); doc_tests green in run %d on %s"
3736
+ % (g["n"], len(paths), ", ".join(paths[:5]) + (", …" if len(paths) > 5 else ""), s["n"], tree[:9]),
3737
+ {"since_run": g["n"], "since_tree": g["tree"], "run": s["n"], "paths": paths[:20], "tests": tests})
3738
+
3739
+
3330
3740
  def run_v7(root, state, scope, args, tcfg):
3331
3741
  """One scope's command, to the end, logged under .ai/state/logs/; returns the run."""
3332
3742
  command = safe_sensor(sensors.command_for, root, scope) or ""
@@ -3446,19 +3856,20 @@ def cmd_verify(args, root):
3446
3856
  die("--files narrows one scope; --scope all runs the whole suite and e2e")
3447
3857
  if args.e2e and e2e_na_refusal(root, state):
3448
3858
  die(e2e_na_refusal(root, state)) # before the suite runs, not after (N3)
3449
- max_runs = int(tcfg.get("max_suite_runs", 2))
3859
+ max_runs, budget_name = budget(policy, state, "max_suite_runs")
3450
3860
  if scope in ("suite", "all") and state["verify"]["suite_runs"] >= max_runs:
3451
3861
  if not args.by:
3452
3862
  offer_decision(root, state, "human_set", "Budget",
3453
- "%s\nSUITE_BUDGET: %d suite runs; weight.json allows %d. Run it once more?"
3454
- % (task_facts(root, state), state["verify"]["suite_runs"], max_runs),
3863
+ "%s\nSUITE_BUDGET: %d suite runs; weight.json allows %d%s. Run it once more?"
3864
+ % (task_facts(root, state), state["verify"]["suite_runs"], max_runs,
3865
+ " (%s)" % budget_name if batch_on(state) else ""),
3455
3866
  [{"kind": "choice", "effect": "keep", "label": "Keep the budget — stop here"},
3456
3867
  {"kind": "command",
3457
3868
  "label": state_label(root, sys.argv[1:] + ["--by", human_name(root)])}], "verify")
3458
- die("SUITE_BUDGET — %d suite runs; weight.json allows %d (tests.max_suite_runs). Running it "
3869
+ die("SUITE_BUDGET — %d suite runs; weight.json allows %d (%s). Running it "
3459
3870
  "again is not the next move: the human decides. In their own terminal:\n python3 %s "
3460
3871
  "--root %s verify --scope %s --by \"<name>\""
3461
- % (state["verify"]["suite_runs"], max_runs, shlex.quote(os.path.abspath(__file__)),
3872
+ % (state["verify"]["suite_runs"], max_runs, budget_name, shlex.quote(os.path.abspath(__file__)),
3462
3873
  shlex.quote(root), scope), 6)
3463
3874
  via, why = human_route(root, allow_dialog=True)
3464
3875
  if via is None:
@@ -3555,6 +3966,20 @@ def verdict_matches_findings(verdict, text):
3555
3966
  % ", ".join(highs))
3556
3967
 
3557
3968
 
3969
+ def review_next_action(state):
3970
+ """What follows the last verdict — also after `batch off`, which changes what
3971
+ a blockers_open verdict asks for: a full pass in batch, a scoped re-review outside."""
3972
+ status = state["review"].get("status")
3973
+ if status == "blockers_open":
3974
+ if batch_on(state):
3975
+ return ("fix every BLOCKER and HIGH as one parallel batch, 'state.py verify --scope all "
3976
+ "--after-fix \"…\"', then a full review pass again")
3977
+ return "fix the blockers, 'state.py verify --scope all --after-fix \"…\"', one scoped re-review"
3978
+ if status in VERDICT_STATUS.values():
3979
+ return "'state.py close'"
3980
+ return next_action_for(state)
3981
+
3982
+
3558
3983
  def cmd_review(args, root):
3559
3984
  """One pass recorded; the pass past review_rounds_max is the human's (exit 6)."""
3560
3985
  state = load_v7(root)
@@ -3565,22 +3990,23 @@ def cmd_review(args, root):
3565
3990
  return
3566
3991
  if not args.verdict or not args.tier:
3567
3992
  die("review needs --verdict pass|pass_with_findings|blockers_open and --tier BALANCED|STRONG")
3568
- rounds = int(policy.get("review_rounds_max", 2))
3993
+ rounds, budget_name = budget(policy, state, "review_rounds_max")
3569
3994
  by_human = None
3570
3995
  if review["passes"] >= rounds:
3571
3996
  if not args.by:
3572
3997
  report = ["--report", os.path.abspath(args.report)] if args.report and os.path.isfile(args.report) else []
3573
3998
  offer_decision(root, state, "human_set", "Review",
3574
- "%s\nREVIEW_BUDGET: %d review passes; weight.json allows %d. The next verdict is yours."
3575
- % (task_facts(root, state), review["passes"], rounds),
3999
+ "%s\nREVIEW_BUDGET: %d review passes; weight.json allows %d%s. The next verdict is yours."
4000
+ % (task_facts(root, state), review["passes"], rounds,
4001
+ " (%s)" % budget_name if batch_on(state) else ""),
3576
4002
  [{"kind": "choice", "effect": "keep", "label": "Keep it open — no verdict now"}] +
3577
4003
  [{"kind": "command",
3578
4004
  "label": state_label(root, ["review", "--verdict", verdict, "--tier", args.tier] +
3579
4005
  report + ["--by", human_name(root)])}
3580
4006
  for verdict in ("pass", "pass_with_findings", "blockers_open")], "review")
3581
- die("REVIEW_BUDGET — %d review passes; weight.json allows %d (review_rounds_max). The next "
4007
+ die("REVIEW_BUDGET — %d review passes; weight.json allows %d (%s). The next "
3582
4008
  "verdict is the human's, in their own terminal:\n python3 %s --root %s review --verdict "
3583
- "<verdict> --tier %s --by \"<name>\"" % (review["passes"], rounds,
4009
+ "<verdict> --tier %s --by \"<name>\"" % (review["passes"], rounds, budget_name,
3584
4010
  shlex.quote(os.path.abspath(__file__)),
3585
4011
  shlex.quote(root), args.tier), 6)
3586
4012
  via, why = human_route(root, allow_dialog=True)
@@ -3613,8 +4039,7 @@ def cmd_review(args, root):
3613
4039
  emit(root, state, "review_verdict", "%s on %s (pass %d)" % (args.verdict, args.tier, review["passes"]),
3614
4040
  {"verdict": args.verdict, "tier": args.tier, "pass_n": review["passes"], "labels": labels,
3615
4041
  "focus": args.focus})
3616
- state["next_action"] = ("fix the blockers, 'state.py verify --scope all --after-fix \"…\"', one scoped "
3617
- "re-review" if status == "blockers_open" else "'state.py close'")
4042
+ state["next_action"] = review_next_action(state)
3618
4043
  write_handoff(root, state, "review")
3619
4044
  save(root, state)
3620
4045
  write_task_md(root, state)
@@ -3657,6 +4082,7 @@ def two_facts(root, state, tree):
3657
4082
  runs = [r for r in state["verify"]["runs"] if full_suite(r)]
3658
4083
  here = [r for r in runs if tree and r.get("tree") == tree]
3659
4084
  green = [r for r in here if r.get("exit") == 0]
4085
+ documents = None
3660
4086
  if not tree:
3661
4087
  a = (False, "the tree cannot be measured (not a git repository)")
3662
4088
  elif not runs:
@@ -3664,6 +4090,9 @@ def two_facts(root, state, tree):
3664
4090
  elif not here:
3665
4091
  a = (False, "the tree changed after the last verify (run %d on %s, now %s)"
3666
4092
  % (runs[-1]["n"], (runs[-1].get("tree") or "?")[:9], tree[:9]))
4093
+ ok, why, documents = documents_cover(root, state, weight_policy(root), tree)
4094
+ if ok or why:
4095
+ a = (ok, why)
3667
4096
  elif not green:
3668
4097
  a = (False, "the suite is red on this tree (run %d, exit %s)" % (here[-1]["n"], here[-1].get("exit")))
3669
4098
  else:
@@ -3696,7 +4125,7 @@ def two_facts(root, state, tree):
3696
4125
  + (" (+%d more)" % (len(outside) - 5) if len(outside) > 5 else ""))
3697
4126
  else:
3698
4127
  b = (True, "%d changed files, all in the task's files" % len(diff))
3699
- return {"a": a, "b": b, "diff": diff or [], "outside": outside}
4128
+ return {"a": a, "b": b, "diff": diff or [], "outside": outside, "documents": documents}
3700
4129
 
3701
4130
 
3702
4131
  def close_owed(state, facts, tree, policy):
@@ -3713,7 +4142,8 @@ def close_owed(state, facts, tree, policy):
3713
4142
  "'start --amend' for a file the task needs)" % "; ".join(red)))
3714
4143
  return owed
3715
4144
  if review["status"] == "blockers_open":
3716
- owed.append(("review", "the blockers fixed, 'verify --scope all --after-fix \"…\"', one scoped re-review"))
4145
+ owed.append(("review", "the blockers fixed, 'verify --scope all --after-fix \"…\"', %s"
4146
+ % ("a full review pass again" if batch_on(state) else "one scoped re-review")))
3717
4147
  elif not reviewed:
3718
4148
  owed.append(("review", "one review verdict (%s mode: %s)"
3719
4149
  % (mode, mode_setting(policy, mode, "reviewer", "BALANCED"))))
@@ -3780,6 +4210,7 @@ def cmd_close_v7(args, root):
3780
4210
  state = load_v7(root)
3781
4211
  if args.abandon:
3782
4212
  archive_v7(root, state, current_tree(root), "abandoned")
4213
+ memory_after_close(root, state["task_id"])
3783
4214
  return
3784
4215
  policy = weight_policy(root)
3785
4216
  tree = current_tree(root)
@@ -3799,6 +4230,8 @@ def cmd_close_v7(args, root):
3799
4230
  rescore_v7(root, state, policy, "close")
3800
4231
  facts = two_facts(root, state, tree)
3801
4232
  state["facts"] = {"a": list(facts["a"]), "b": list(facts["b"]), "tree": tree, "at": now()}
4233
+ if facts["documents"]:
4234
+ state["facts"]["documents"] = facts["documents"]
3802
4235
  owed = close_owed(state, facts, tree, policy)
3803
4236
  if owed:
3804
4237
  state["next_action"] = owed[0][1]
@@ -3842,6 +4275,7 @@ def cmd_close_v7(args, root):
3842
4275
  state["gate"].update(approved_by=approval.get("granted_by"), approved_at=approval.get("granted_at"),
3843
4276
  via=approval.get("via"), approved_tree=state["gate"].get("tree"))
3844
4277
  archive_v7(root, state, tree, "closed", facts)
4278
+ memory_after_close(root, state["task_id"])
3845
4279
 
3846
4280
 
3847
4281
  def archive_v7(root, state, tree, result, facts=None):
@@ -3895,16 +4329,98 @@ def cmd_step_done_v7(args, root):
3895
4329
  die("SCOPE_CHANGE_REQUIRED — step %s changed %s, outside the task's files. Widen the task in "
3896
4330
  "place: state.py start --amend --files \"%s\" --why \"…\""
3897
4331
  % (step["id"], ", ".join(outside), ",".join("+" + p for p in outside)), 6)
4332
+ # In a wave the diff since the step began holds other steps' work too. The
4333
+ # journal says which changed paths were this step's own (its files and
4334
+ # tests) and which were not, so a step that strayed into another's files is
4335
+ # visible; `unready` names dependencies it was recorded ahead of.
4336
+ own_scope = list(step.get("files") or []) + list(step.get("tests") or [])
4337
+ own = [d["path"] for d in diff or [] if sensors.scope_any(d["path"], own_scope)]
4338
+ others = [d["path"] for d in diff or [] if d["path"] not in own]
4339
+ unready = [dep for dep in step.get("after") or [] if any(s["id"] == dep and s["status"] != "done" for s in steps)]
3898
4340
  step.update(status="done", tree_after=tree)
3899
4341
  emit(root, state, "step_done", "%s: %d files changed" % (step["id"], len(diff or [])),
3900
- {"step_id": step["id"], "files": len(diff or [])}, legacy="step_completed")
3901
- following = next((s for s in steps if s["status"] == "pending"), None)
4342
+ {"step_id": step["id"], "files": len(diff or []), "own": own, "others": others, "unready": unready},
4343
+ legacy="step_completed")
4344
+ # The first pending step whose dependencies are done — the first pending one
4345
+ # when no step has `after`, as before the batch loop.
4346
+ following = next((s for s in ready_steps(state) if s["status"] == "pending"), None)
3902
4347
  if following:
3903
4348
  following.update(status="in_progress", tree_before=tree)
3904
4349
  emit(root, state, "step_started", following["id"], {"step_id": following["id"], "kind": "step"})
3905
4350
  save(root, state)
3906
4351
  write_task_md(root, state)
3907
- print("step %s done%s" % (step["id"], "; next: %s" % following["id"] if following else "; all steps done"))
4352
+ open_steps = [s["id"] for s in steps if s["status"] != "done"]
4353
+ if unready:
4354
+ print("note: %s was done before %s, which it is after" % (step["id"], ", ".join(unready)))
4355
+ if others and batch_on(state):
4356
+ print("note: the diff also holds %d file(s) outside this step's own — %s"
4357
+ % (len(others), ", ".join(others[:4]) + (" …" if len(others) > 4 else "")))
4358
+ print("step %s done%s" % (step["id"], "; next: %s" % following["id"] if following
4359
+ else "; %d still open: %s" % (len(open_steps), ", ".join(open_steps)) if open_steps
4360
+ else "; all steps done"))
4361
+
4362
+
4363
+ def cmd_steps(args, root):
4364
+ """Read-only: the steps, their status and `after`; --ready prints the next
4365
+ wave (R3), nothing when there is none."""
4366
+ state = load_v7(root)
4367
+ steps = state.get("steps") or []
4368
+ ready = [s["id"] for s in ready_steps(state)]
4369
+ if args.json:
4370
+ print(json.dumps({"task_id": state["task_id"], "batch": batch_on(state), "ready": ready, "steps": steps,
4371
+ "batch_policy": batch_policy(weight_policy(root))}, indent=2, ensure_ascii=False))
4372
+ return
4373
+ if args.ready:
4374
+ for step_id in ready:
4375
+ print(step_id)
4376
+ return
4377
+ if not steps:
4378
+ print("steps: one scope — no plan-file steps")
4379
+ return
4380
+ for s in steps:
4381
+ print("%s · %s · after %s · %s" % (s["id"], s["status"], ", ".join(s.get("after") or []) or "-",
4382
+ "ready" if s["id"] in ready else "waiting" if s["status"] != "done" else "-"))
4383
+
4384
+
4385
+ def cmd_batch(args, root):
4386
+ """The batch loop's switch during a task (R9). `off` raises the rigor — the
4387
+ standard budgets from here, counting what was spent — and is anyone's with
4388
+ --by and --why. `on` lowers it, so it takes the human route or exits 5."""
4389
+ state = load_v7(root)
4390
+ want = args.action == "on"
4391
+ if not args.by:
4392
+ die("batch %s needs --by \"<who>\"" % args.action)
4393
+ if want == batch_on(state):
4394
+ print("batch is already %s" % args.action)
4395
+ return
4396
+ via = None
4397
+ if want:
4398
+ via, why = human_route(root)
4399
+ if via is None:
4400
+ die("APPROVAL_REFUSED — turning the batch loop on during a task lowers the rigor, so it is the "
4401
+ "human's. In their own terminal:\n python3 %s --root %s batch on --by \"<name>\"\nRefused "
4402
+ "because %s." % (shlex.quote(os.path.abspath(__file__)), shlex.quote(root), why), 5)
4403
+ elif not (args.why or "").strip():
4404
+ die("batch off needs --why \"<why the standard loop from here>\"")
4405
+ policy = weight_policy(root)
4406
+ if want:
4407
+ batch_policy(policy, strict=True) # a malformed section is refused before the loop starts
4408
+ state["batch"] = {"on": want, "by": args.by, "why": args.why or None, "since": now()}
4409
+ if state.get("stage") in ("build", "verify", "review"):
4410
+ # At the gate (stage ship) the next action is the human's and stays so.
4411
+ state["next_action"] = review_next_action(state)
4412
+ emit(root, state, "batch_set", "%s by %s%s" % (args.action, args.by, " via %s" % via if via else ""),
4413
+ {"on": want, "by": args.by, "why": args.why or None, "via": via,
4414
+ "review_passes": state["review"]["passes"], "suite_runs": state["verify"]["suite_runs"]},
4415
+ actor="human" if via == "terminal" else "agent")
4416
+ set_resume_point(state)
4417
+ write_handoff(root, state, "batch")
4418
+ save(root, state)
4419
+ write_task_md(root, state)
4420
+ print("batch %s by %s · review %d/%d · suite %d/%d" % (
4421
+ args.action, args.by, state["review"]["passes"], budget(policy, state, "review_rounds_max")[0],
4422
+ state["verify"]["suite_runs"], budget(policy, state, "max_suite_runs")[0]))
4423
+ print("next: %s" % state["next_action"])
3908
4424
 
3909
4425
 
3910
4426
  def cmd_approve_v7(args, root):
@@ -3975,12 +4491,13 @@ def status_lines(root, state):
3975
4491
  + (" (+%d more)" % (len(task_files(state)) - 4)
3976
4492
  if len(task_files(state)) > 4 else "")),
3977
4493
  "verify: suite %s · runs %d/%s · e2e %s" % (ver["status"], ver["suite_runs"],
3978
- (policy.get("tests") or {}).get("max_suite_runs", 2),
4494
+ budget(policy, state, "max_suite_runs")[0],
3979
4495
  ver.get("e2e")),
3980
4496
  "facts: (a) %s — %s" % ("green" if facts["a"][0] else "RED", facts["a"][1]),
3981
4497
  " (b) %s — %s" % ("green" if facts["b"][0] else "RED", facts["b"][1]),
3982
4498
  "review: %s (%d of %s) · tier %s%s" % (review["status"], review["passes"],
3983
- policy.get("review_rounds_max", 2), review.get("tier") or "-",
4499
+ budget(policy, state, "review_rounds_max")[0],
4500
+ review.get("tier") or "-",
3984
4501
  " · open %s" % ", ".join(review["findings"])
3985
4502
  if review["findings"] else ""),
3986
4503
  "gate: %s" % ("approved by %s%s" % (gate["approved_by"], " · confirm \"%s\"" % gate["confirm"]
@@ -3990,6 +4507,13 @@ def status_lines(root, state):
3990
4507
  if state["mode"] in ("full", "full_irreversible") else "none in %s mode" % state["mode"]),
3991
4508
  "findings: %d recorded in task.md" % len(state.get("findings") or []),
3992
4509
  ]
4510
+ if batch_on(state):
4511
+ # Before the truncation point, so the 20-line cap never cuts it.
4512
+ lines.insert(3, "batch: on by %s · review %d/%d · suite %d/%d · ready %s" % (
4513
+ one_line(state["batch"].get("by"), 40), review["passes"],
4514
+ budget(policy, state, "review_rounds_max")[0], ver["suite_runs"],
4515
+ budget(policy, state, "max_suite_runs")[0],
4516
+ ", ".join(s["id"] for s in ready_steps(state)) or "none"))
3993
4517
  lines += ["questions: %d pending%s" % (len(pending), " — %s" % ", ".join(map(str, pending[:4])) if pending else ""),
3994
4518
  schema_line(root),
3995
4519
  "last event: %s" % (" ".join(one_line(last.get(k), 80) for k in ("ts", "event", "detail")
@@ -4053,11 +4577,16 @@ def task_md_lines(root, state):
4053
4577
  " — %s" % one_line(override["why"])
4054
4578
  if override.get("why") else ""))
4055
4579
  out += ["re-score %s" % one_line(line, 300) for line in w.get("rescores") or []]
4580
+ if batch_on(state):
4581
+ batch = state["batch"]
4582
+ out.append("batch: on by %s at %s%s" % (one_line(batch.get("by"), 80), batch.get("since"),
4583
+ " — %s" % one_line(batch["why"]) if batch.get("why") else ""))
4056
4584
  out += ["", "## Plan", "", "files: %s" % (", ".join(state.get("files") or []) or "none"),
4057
4585
  "tests: %s" % (", ".join(state.get("tests") or []) or "none")]
4058
4586
  for step in state.get("steps") or []:
4059
- out.append("- %s · %s · %s · %s" % (step["id"], ", ".join(step["files"]),
4060
- ", ".join(step["tests"]) or "no tests", step["status"]))
4587
+ out.append("- %s · %s · %s · %s%s" % (step["id"], ", ".join(step["files"]),
4588
+ ", ".join(step["tests"]) or "no tests", step["status"],
4589
+ " · after %s" % ", ".join(step["after"]) if step.get("after") else ""))
4061
4590
  out += ["amended %s: +%s — %s" % (a["ts"], ", ".join(a["added"]), one_line(a["why"]))
4062
4591
  for a in state.get("amendments") or []]
4063
4592
  out += ["", QUESTIONS_SECTION, "", "none"]
@@ -4180,6 +4709,360 @@ def route_surface(root, args):
4180
4709
  die(old_surface_refusal(state))
4181
4710
 
4182
4711
 
4712
+ # ------------------------------------------------------------------ the compact record (compact-project-memory)
4713
+ def memory_modules():
4714
+ """memory_sync and memory_store, imported only by the commands that use them."""
4715
+ try:
4716
+ import memory_store # pylint: disable=import-outside-toplevel
4717
+ import memory_sync # pylint: disable=import-outside-toplevel
4718
+ except ImportError as exc:
4719
+ die("the memory modules are not installed beside state.py (%s)" % exc, 2)
4720
+ return memory_sync, memory_store
4721
+
4722
+
4723
+ def memory_after_close(root, task_id):
4724
+ """§D5: packages mode exports the closed task. Files mode (no memory.json)
4725
+ imports nothing and prints nothing (R15)."""
4726
+ if not os.path.exists(os.path.join(root, ".ai", "policies", "memory.json")):
4727
+ return
4728
+ try:
4729
+ import memory_sync # pylint: disable=import-outside-toplevel
4730
+ except ImportError:
4731
+ return
4732
+ memory_sync.after_close(root, task_id)
4733
+
4734
+
4735
+ def memory_config(sync, root):
4736
+ try:
4737
+ return sync.load_config(root)
4738
+ except sync.MemoryError_ as exc:
4739
+ die(str(exc), exc.code)
4740
+ return None
4741
+
4742
+
4743
+ def memory_human(root, what):
4744
+ """R16: enable, disable, cleanup, pack, redact and move-logs take the human's route, as approve does."""
4745
+ via, why = human_route(root)
4746
+ if via is None:
4747
+ die("APPROVAL_REFUSED — 'memory %s' is the human's, from their own terminal:\n python3 %s --root %s "
4748
+ "%s\nRefused because %s." % (what, shlex.quote(os.path.abspath(__file__)), shlex.quote(root),
4749
+ " ".join(shlex.quote(a) for a in sys.argv[sys.argv.index("memory"):]
4750
+ if "memory" in sys.argv), why), 5)
4751
+ return via
4752
+
4753
+
4754
+ def memory_confirm(cfg, name, what):
4755
+ if not name:
4756
+ die("'memory %s' deletes or moves files: add --confirm-delete %s" % (what, cfg.author), 2)
4757
+ if name != cfg.author:
4758
+ die("--confirm-delete names %r; this clone's author is %r (.ai/local/memory/config.json)"
4759
+ % (name, cfg.author), 2)
4760
+
4761
+
4762
+ def in_flight(root):
4763
+ state = load(root, required=False, claim=False)
4764
+ return state if state and state.get("current_stage") != "done" and state.get("task_id") else None
4765
+
4766
+
4767
+ def cmd_recall(args, root):
4768
+ """R10: the open risks, residual findings, decisions, scope changes and task
4769
+ one-liners that touch these files, within the budget. Read-only."""
4770
+ sync, store = memory_modules()
4771
+ cfg = memory_config(sync, root)
4772
+ files = _split_files(args.files)
4773
+ if not files:
4774
+ die("recall needs --files \"a,b,dir/**\"", 2)
4775
+ kinds = _split_files(args.kinds) or None
4776
+ history = store.open_history(root, cfg)
4777
+ budget = args.budget if args.budget is not None else cfg.recall_budget
4778
+ text, result = history.recall(files, budget, cfg.recall_max_items, kinds, args.all_risks, args.offset)
4779
+ if args.json:
4780
+ print(json.dumps({"schema": 1, "files": files, "budget": budget, "used": result["used"],
4781
+ "items": [{k: i[k] for k in ("rid", "kind", "task", "ts", "line", "src")}
4782
+ for i in result["items"]],
4783
+ "more": result["more"], "next": result["next"], "source": history.source()},
4784
+ ensure_ascii=False, sort_keys=True))
4785
+ return
4786
+ sys.stdout.write(text)
4787
+
4788
+
4789
+ def cmd_memory(args, root):
4790
+ sync, store = memory_modules()
4791
+ action = args.memory_action
4792
+ try:
4793
+ MEMORY_ACTIONS[action](args, root, sync, store)
4794
+ except sync.MemoryError_ as exc:
4795
+ die(str(exc), exc.code)
4796
+
4797
+
4798
+ def memory_status(args, root, sync, _store):
4799
+ lines = sync.status_lines(root)
4800
+ if args.json:
4801
+ print(json.dumps(dict(line.split(": ", 1) for line in lines), ensure_ascii=False, sort_keys=True))
4802
+ return
4803
+ print("\n".join(lines))
4804
+
4805
+
4806
+ def memory_enable(args, root, sync, _store):
4807
+ memory_human(root, "enable")
4808
+ hook = sync.enable(root, args.author, args.period, args.index)
4809
+ print("memory: packages mode on (%s); every teammate's next close exports" % sync.POLICY_REL)
4810
+ print("pre-push: %s" % hook)
4811
+ if hook in ("foreign", "hookspath"):
4812
+ print("not written — %s. Add these lines to your own pre-push hook:\n%s" % (
4813
+ "core.hooksPath is set" if hook == "hookspath" else "a pre-push hook exists", sync.snippet_text()))
4814
+ print("\n".join(sync.status_lines(root)))
4815
+
4816
+
4817
+ def memory_disable(_args, root, sync, _store):
4818
+ memory_human(root, "disable")
4819
+ print("memory: files mode; packages and local data kept; pre-push: %s" % sync.disable(root))
4820
+
4821
+
4822
+ def memory_export(args, root, sync, _store):
4823
+ cfg = memory_config(sync, root)
4824
+ ids = "pending" if args.pending or not args.ids else [valid_task_id(t, "ID") for t in args.ids]
4825
+ result = sync.export(root, ids, cfg)
4826
+ for line in sync.record_lines(result):
4827
+ print(line)
4828
+ print("exported: +%d entries%s" % (result.added, "" if result.added else " — nothing pending"))
4829
+ for task_id, why in result.failures:
4830
+ print("memory: export of %s failed (%s)" % (task_id, why))
4831
+ if result.failures:
4832
+ sys.exit(1)
4833
+
4834
+
4835
+ def memory_verify(args, root, sync, _store):
4836
+ stats = None
4837
+ if args.range:
4838
+ if ".." not in args.range:
4839
+ die("--range takes OLD..NEW", 2)
4840
+ old, new = args.range.split("..", 1)
4841
+ findings, stats = sync.validate_range(root, sync.rev(root, old), sync.rev(root, new) or new, local=False)
4842
+ findings = findings or []
4843
+ elif args.fresh_clone:
4844
+ findings = sync.verify_fresh_clone(root)
4845
+ else:
4846
+ findings = sync.verify(root, "all", [valid_task_id(t, "ID") for t in args.ids] or None)
4847
+ failing = [f for f in findings if not f.check.endswith("-warn")]
4848
+ if args.json:
4849
+ print(json.dumps({"findings": [f._asdict() for f in findings], "ok": not failing, "stats": stats},
4850
+ ensure_ascii=False, sort_keys=True))
4851
+ else:
4852
+ for f in findings:
4853
+ print(sync.format_finding(f))
4854
+ print("memory verify: %s" % ("ok" if not failing else "%d finding(s)" % len(failing)))
4855
+ if failing:
4856
+ sys.exit(1)
4857
+
4858
+
4859
+ def memory_show(args, root, sync, _store):
4860
+ print("\n".join(sync.show(root, valid_task_id(args.id, "ID"), "json" if args.json else "md", args.entry)))
4861
+
4862
+
4863
+ def memory_search(args, root, sync, store):
4864
+ cfg = memory_config(sync, root)
4865
+ filters = {"kind": args.kind, "by": args.by, "task": args.task, "since": args.since, "until": args.until,
4866
+ "files": _split_files(args.file) or None}
4867
+ history = store.open_history(root, cfg)
4868
+ hits, total = history.search(args.query or "", filters, args.limit, args.offset)
4869
+ if args.json:
4870
+ print(json.dumps({"schema": 1, "query": args.query or "", "total": total, "offset": args.offset,
4871
+ "items": [{"rid": e["rid"], "kind": e["kind"], "task": e.get("task"), "ts": e.get("ts"),
4872
+ "line": store.mf.search_line(e)} for e in hits],
4873
+ "source": history.source()}, ensure_ascii=False, sort_keys=True))
4874
+ return
4875
+ print("# Search — \"%s\" · %d of %d matches · offset %d · index %s" % (
4876
+ args.query or "", len(hits), total, args.offset, history.source()["index"]))
4877
+ for e in hits:
4878
+ print("- %s" % store.mf.search_line(e))
4879
+ if args.offset + len(hits) < total:
4880
+ print("… %d more: --offset %d" % (total - args.offset - len(hits), args.offset + len(hits)))
4881
+
4882
+
4883
+ def risk_scope(state):
4884
+ """C4 (§D9): with a task in flight, files mode edits known-risks.md only
4885
+ when the task names it."""
4886
+ rel = ".ai/project/known-risks.md"
4887
+ if state and not sensors.scope_any(rel, task_files(state)):
4888
+ die("SCOPE_CHANGE_REQUIRED — 'memory risk' writes %s, which task %s does not name:\n"
4889
+ " state.py start --amend --files \"+%s\" --why \"<the risk you record>\"" % (rel, state["task_id"], rel),
4890
+ 6)
4891
+
4892
+
4893
+ def memory_risk(args, root, sync, store):
4894
+ cfg = memory_config(sync, root)
4895
+ state = in_flight(root)
4896
+ if args.risk_action == "add":
4897
+ where = _split_files(args.where)
4898
+ if args.label not in store.mf.LABELS:
4899
+ die("--label takes one of: %s" % ", ".join(store.mf.LABELS), 2)
4900
+ if args.status not in store.mf.RISK_STATUS:
4901
+ die("--status takes one of: %s" % ", ".join(store.mf.RISK_STATUS), 2)
4902
+ if not cfg.packages:
4903
+ risk_scope(state)
4904
+ found = "%s, by %s" % (now()[:10], state["task_id"] if state else "no task")
4905
+ rkey = sync.risk_add_files(root, args.title, where, args.label, args.what, args.likely, args.fix,
4906
+ args.status, found)
4907
+ print("risk: %s added to .ai/project/known-risks.md" % rkey)
4908
+ return
4909
+ data = sync.risk_data(args.title, where, args.label, args.what, args.likely, args.fix, args.status)
4910
+ else:
4911
+ if args.status not in ("fixed", "accepted", "ticketed"):
4912
+ die("--status takes fixed, accepted or ticketed", 2)
4913
+ if not cfg.packages:
4914
+ risk_scope(state)
4915
+ sync.risk_close_files(root, args.rkey, args.status, args.why)
4916
+ print("risk: %s is %s" % (args.rkey, args.status))
4917
+ return
4918
+ history = store.FileHistory(root, cfg)
4919
+ found = [e for e in history.risks("all") if e.get("rkey") == args.rkey]
4920
+ if not found:
4921
+ die("no risk %s ('state.py memory risks --status all')" % args.rkey, 1)
4922
+ last = found[0]
4923
+ data = sync.risk_data(last["title"], last.get("where"), last.get("label"), last.get("what"),
4924
+ last.get("likelihood"), last.get("fix"), args.status, args.why, last["rid"])
4925
+ if state:
4926
+ state = load(root)
4927
+ emit(root, state, "risk_recorded", "%s: %s" % (data["rkey"], one_line(data["title"], 200)), data)
4928
+ save(root, state)
4929
+ print("risk: %s recorded in %s's journal (exported at close)" % (data["rkey"], state["task_id"]))
4930
+ return
4931
+ entry = sync.standalone_risk(root, cfg, data, RUNTIME)
4932
+ print("risk: %s recorded (%s)" % (data["rkey"], entry["rid"]))
4933
+
4934
+
4935
+ def memory_risks(args, root, sync, store):
4936
+ cfg = memory_config(sync, root)
4937
+ risks = store.open_history(root, cfg).risks(args.status)
4938
+ if args.json:
4939
+ for e in risks:
4940
+ print(store.mf.canonical(e).decode("utf-8"))
4941
+ return
4942
+ if args.md:
4943
+ out = ["# Known risks", ""]
4944
+ for e in risks:
4945
+ out += (e["text"].split("\n") if e.get("text") else store.mc.risk_section(
4946
+ e.get("title"), store.mf.where_list(e.get("where")), e.get("label"), e.get("what"),
4947
+ e.get("likelihood"), e.get("fix"), "%s%s" % (e.get("status"), " — %s" % e["status_why"]
4948
+ if e.get("status_why") else ""),
4949
+ e.get("found_by") or "-")) + [""]
4950
+ print("\n".join(out).rstrip("\n"))
4951
+ return
4952
+ print("# Risks — %s · %d" % (args.status, len(risks)))
4953
+ for e in risks:
4954
+ where = store.mf.where_list(e.get("where"))
4955
+ print("- [%s %s] %s — %s (%s)" % (e.get("status"), e.get("label"), where[0] if where else "-",
4956
+ e.get("title"), e.get("rkey")))
4957
+
4958
+
4959
+ def memory_migrate(args, root, sync, _store):
4960
+ cfg = memory_config(sync, root)
4961
+ if args.move_logs:
4962
+ memory_human(root, "migrate --move-logs")
4963
+ memory_confirm(cfg, args.confirm_delete, "migrate --move-logs")
4964
+ moved, command = sync.move_logs(root)
4965
+ print("moved %d committed log(s) into .ai/state/logs/<id>/legacy/; now, as its own commit (C3):\n %s"
4966
+ % (len(moved), command))
4967
+ return
4968
+ if args.apply:
4969
+ print("\n".join(sync.migrate_apply(root, risks=args.risks)))
4970
+ return
4971
+ report = sync.migrate_report(root)
4972
+ print("report: %s (%d lines) — read it, then 'state.py memory migrate --apply'"
4973
+ % (os.path.join(sync.LOCAL_REL, "migration-report.md"), report.count("\n")))
4974
+
4975
+
4976
+ def memory_rebuild(_args, root, sync, store):
4977
+ cfg = memory_config(sync, root)
4978
+ print("index: rebuilt from %d sources (%s)" % (store.SqliteHistory(root, cfg).rebuild(), store.INDEX_REL))
4979
+
4980
+
4981
+ def memory_cleanup(args, root, sync, _store):
4982
+ cfg = memory_config(sync, root)
4983
+ memory_human(root, "cleanup")
4984
+ memory_confirm(cfg, args.confirm_delete, "cleanup")
4985
+ if not args.all and not args.ids:
4986
+ die("memory cleanup takes IDs or --all", 2)
4987
+ print("\n".join(sync.cleanup(root, "all" if args.all else [valid_task_id(t, "ID") for t in args.ids])))
4988
+
4989
+
4990
+ def memory_restore(args, root, sync, _store):
4991
+ if not args.all and not args.ids:
4992
+ die("memory restore takes IDs or --all", 2)
4993
+ print("\n".join(sync.restore(root, "all" if args.all else [valid_task_id(t, "ID") for t in args.ids])))
4994
+
4995
+
4996
+ def memory_pack(args, root, sync, _store):
4997
+ cfg = memory_config(sync, root)
4998
+ memory_human(root, "pack")
4999
+ memory_confirm(cfg, args.confirm_delete, "pack")
5000
+ target, removed, count = sync.pack(root, args.period)
5001
+ print("pack: %s (%d records; %d batch(es) removed)" % (os.path.join(sync.MEMORY_REL, target), count,
5002
+ len(removed)))
5003
+
5004
+
5005
+ def memory_redact(args, root, sync, _store):
5006
+ via = memory_human(root, "redact")
5007
+ rid, name = sync.redact(root, args.rid, args.field, args.why, by="%s via %s" % (os.environ.get("USER") or
5008
+ "unknown", via))
5009
+ print("redacted: %s in %s (new rid %s)" % (args.field, os.path.join(sync.MEMORY_REL, name), rid))
5010
+
5011
+
5012
+ MEMORY_ACTIONS = {"status": memory_status, "enable": memory_enable, "disable": memory_disable,
5013
+ "export": memory_export, "verify": memory_verify, "show": memory_show, "search": memory_search,
5014
+ "risk": memory_risk, "risks": memory_risks, "migrate": memory_migrate, "rebuild": memory_rebuild,
5015
+ "cleanup": memory_cleanup, "restore": memory_restore, "pack": memory_pack,
5016
+ "redact": memory_redact}
5017
+
5018
+
5019
+ def memory_parsers(sub):
5020
+ p = sub.add_parser("recall", help="what the record holds about these files, within a token budget")
5021
+ p.add_argument("--files", default=""); p.add_argument("--budget", type=int)
5022
+ p.add_argument("--kinds", default=""); p.add_argument("--all-risks", dest="all_risks", action="store_true")
5023
+ p.add_argument("--offset", type=int, default=0); p.add_argument("--json", action="store_true")
5024
+ p.set_defaults(func=cmd_recall)
5025
+ p = sub.add_parser("memory", help="the compact project record (docs/local-memory.md)")
5026
+ p.set_defaults(func=cmd_memory)
5027
+ msub = p.add_subparsers(dest="memory_action", required=True)
5028
+ m = msub.add_parser("status"); m.add_argument("--json", action="store_true")
5029
+ m = msub.add_parser("enable"); m.add_argument("--author"); m.add_argument("--period", choices=["month", "quarter", "year"])
5030
+ m.add_argument("--index", choices=["none", "sqlite"])
5031
+ msub.add_parser("disable")
5032
+ m = msub.add_parser("export"); m.add_argument("ids", nargs="*"); m.add_argument("--pending", action="store_true")
5033
+ m = msub.add_parser("verify"); m.add_argument("ids", nargs="*"); m.add_argument("--all", action="store_true")
5034
+ m.add_argument("--range"); m.add_argument("--fresh-clone", dest="fresh_clone", action="store_true")
5035
+ m.add_argument("--json", action="store_true")
5036
+ m = msub.add_parser("show"); m.add_argument("id"); m.add_argument("--md", action="store_true")
5037
+ m.add_argument("--json", action="store_true"); m.add_argument("--entry")
5038
+ m = msub.add_parser("search"); m.add_argument("query", nargs="?", default="")
5039
+ for flag in ("--kind", "--by", "--file", "--task", "--since", "--until"):
5040
+ m.add_argument(flag)
5041
+ m.add_argument("--limit", type=int, default=20); m.add_argument("--offset", type=int, default=0)
5042
+ m.add_argument("--json", action="store_true")
5043
+ m = msub.add_parser("risk"); rsub = m.add_subparsers(dest="risk_action", required=True)
5044
+ r = rsub.add_parser("add"); r.add_argument("--title", required=True); r.add_argument("--where", default="")
5045
+ r.add_argument("--label", required=True); r.add_argument("--what", required=True)
5046
+ r.add_argument("--likely"); r.add_argument("--fix"); r.add_argument("--status", default="open")
5047
+ r = rsub.add_parser("close"); r.add_argument("rkey"); r.add_argument("--status", required=True)
5048
+ r.add_argument("--why", required=True)
5049
+ m = msub.add_parser("risks"); m.add_argument("--status", choices=["open", "ticketed", "accepted", "fixed", "all"],
5050
+ default="open")
5051
+ m.add_argument("--md", action="store_true"); m.add_argument("--json", action="store_true")
5052
+ m = msub.add_parser("migrate"); g = m.add_mutually_exclusive_group(required=True)
5053
+ g.add_argument("--report", action="store_true"); g.add_argument("--apply", action="store_true")
5054
+ g.add_argument("--move-logs", dest="move_logs", action="store_true")
5055
+ m.add_argument("--risks", action="store_true"); m.add_argument("--confirm-delete", dest="confirm_delete")
5056
+ msub.add_parser("rebuild")
5057
+ m = msub.add_parser("cleanup"); m.add_argument("ids", nargs="*"); m.add_argument("--all", action="store_true")
5058
+ m.add_argument("--confirm-delete", dest="confirm_delete")
5059
+ m = msub.add_parser("restore"); m.add_argument("ids", nargs="*"); m.add_argument("--all", action="store_true")
5060
+ m = msub.add_parser("pack"); m.add_argument("--period", required=True)
5061
+ m.add_argument("--confirm-delete", dest="confirm_delete")
5062
+ m = msub.add_parser("redact"); m.add_argument("rid"); m.add_argument("--field", required=True)
5063
+ m.add_argument("--why", required=True)
5064
+
5065
+
4183
5066
  def main():
4184
5067
  parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
4185
5068
  parser.add_argument("--root", default=".", help="project directory (default: search upwards for .ai/)")
@@ -4256,9 +5139,21 @@ def main():
4256
5139
  p.add_argument("--tests", default=""); p.add_argument("--declare", action="append", default=[])
4257
5140
  p.add_argument("--mode"); p.add_argument("--by", default=""); p.add_argument("--why", default="")
4258
5141
  p.add_argument("--steps", default=""); p.add_argument("--plan-file", dest="plan_file")
4259
- p.add_argument("--task-id"); p.add_argument("--amend", action="store_true")
5142
+ p.add_argument("--task-id", help="an id of your own; a legacy T-YYYY-MM-DD-NNN id is still accepted")
5143
+ p.add_argument("--slug", help="3-24 characters of [a-z0-9] words joined by '-': the id becomes "
5144
+ "T-YYYY-MM-DD-HHMM-<slug> (UTC). Default: derived from --goal")
5145
+ p.add_argument("--amend", action="store_true")
5146
+ p.add_argument("--batch", action="store_true", help="the batch loop of /rr-build-all (needs --by)")
5147
+ p.add_argument("--no-batch", dest="no_batch", action="store_true", help="opt out of weight.json batch.default")
4260
5148
  p.set_defaults(func=cmd_start)
4261
5149
 
5150
+ p = sub.add_parser("steps"); p.add_argument("--ready", action="store_true")
5151
+ p.add_argument("--json", action="store_true"); p.set_defaults(func=cmd_steps)
5152
+
5153
+ p = sub.add_parser("batch"); p.add_argument("action", choices=["on", "off"])
5154
+ p.add_argument("--by", default=""); p.add_argument("--why", default="")
5155
+ p.set_defaults(func=cmd_batch)
5156
+
4262
5157
  p = sub.add_parser("verify")
4263
5158
  p.add_argument("--scope", required=True, choices=["step", "suite", "e2e", "all"])
4264
5159
  p.add_argument("--files", default=""); p.add_argument("--expect", choices=["fail"])
@@ -4282,6 +5177,8 @@ def main():
4282
5177
  p = sub.add_parser("consent"); p.add_argument("action", nargs="?", choices=["audit"])
4283
5178
  p.add_argument("--json", action="store_true"); p.set_defaults(func=cmd_consent)
4284
5179
 
5180
+ memory_parsers(sub)
5181
+
4285
5182
  global RUNTIME, MUTATING # pylint: disable=global-statement
4286
5183
  args = parser.parse_args()
4287
5184
  if getattr(args, "topic", None):