riskrails 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) 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 +259 -812
  5. package/agents/ai-reviewer.md.tmpl +6 -4
  6. package/ai/policies/model-routing.md +7 -7
  7. package/ai/policies/production.md +3 -3
  8. package/ai/policies/release.md +2 -2
  9. package/ai/policies/review-economy.md +34 -32
  10. package/ai/using-riskrails.md +15 -0
  11. package/bin/claude-1m +15 -14
  12. package/bin/riskrails.js +113 -37
  13. package/codex/hooks.json +25 -2
  14. package/hooks/AGENTS.md +2 -2
  15. package/hooks/CLAUDE.md +2 -2
  16. package/hooks/ai-guard.py +29 -8
  17. package/hooks/ai-path-guard-defaults.json +3 -1
  18. package/hooks/codex-hooks.json +25 -2
  19. package/hooks/context-guard.py +252 -36
  20. package/hooks/dialog-gate.py +105 -0
  21. package/hooks/guard/common.py +7 -0
  22. package/hooks/guard/decision.py +123 -0
  23. package/hooks/guard/dialog.py +598 -0
  24. package/hooks/guard/git.py +49 -3
  25. package/hooks/guard/paths.py +89 -19
  26. package/hooks/hooks.json +37 -2
  27. package/hooks/memory-pre-push.py +28 -0
  28. package/hooks/project-scaffold.py +1 -5
  29. package/hooks/runtime-gate.py +65 -18
  30. package/hooks/update_notice.py +141 -0
  31. package/instructions/routing.md +4 -4
  32. package/instructions/stub.md +3 -3
  33. package/package.json +1 -1
  34. package/profiles/max.json +3 -1
  35. package/profiles/pro.json +3 -1
  36. package/scripts/merge-codex-config.py +134 -0
  37. package/scripts/resolve-profile.py +15 -3
  38. package/settings.common.json +39 -2
  39. package/skills/AGENTS.md +3 -3
  40. package/skills/CLAUDE.md +3 -3
  41. package/skills/ai-init/templates/.ai/AGENTS.md +3 -3
  42. package/skills/ai-init/templates/.ai/VERSION +1 -1
  43. package/skills/ai-init/templates/.ai/policies/dialog.json +1 -0
  44. package/skills/ai-init/templates/.ai/policies/safety.md +22 -2
  45. package/skills/ai-init/templates/.ai/policies/security.md +7 -1
  46. package/skills/ai-init/templates/.ai/policies/testing.md +19 -6
  47. package/skills/ai-init/templates/.ai/policies/weight.json +1 -0
  48. package/skills/ai-init/templates/.ai/policies/weight.md +2 -2
  49. package/skills/ai-init/templates/.ai/state/README.md +9 -6
  50. package/skills/ai-init/templates/AGENTS.block.md +2 -2
  51. package/skills/ai-init/templates/CLAUDE.block.md +2 -2
  52. package/skills/ai-init/templates/GEMINI.block.md +2 -2
  53. package/skills/ai-init/templates/gitignore.snippet +1 -0
  54. package/skills/ai-init/templates/junie-guidelines.block.md +2 -2
  55. package/skills/ai-task/memory_compact.py +968 -0
  56. package/skills/ai-task/memory_format.py +775 -0
  57. package/skills/ai-task/memory_store.py +528 -0
  58. package/skills/ai-task/memory_sync.py +1571 -0
  59. package/skills/ai-task/sensors.py +25 -5
  60. package/skills/ai-task/state.py +999 -79
  61. package/skills/ai-task/weight.py +3 -3
  62. package/skills/project-init/templates/constitution.md +1 -1
  63. package/skills/project-init/templates/sdlc-README.md +20 -14
  64. package/skills/project-update/history/blobs/00289f98434fed230eebe768dfb3425f83d737866a3aebc97aa95167c4fa0c6e +131 -0
  65. package/skills/project-update/history/blobs/079a91f976ad69b5cf49ccb9a28849576e4735ddd9485fc26599e528a69fd2f4 +30 -0
  66. package/skills/project-update/history/blobs/07ff7fa6f93d471ff533f553b4e523e4105ccde1f3dab70132c5237728f68422 +13 -0
  67. package/skills/project-update/history/blobs/0ef4a360c76da290173dea0f8073949cb0f8113710a38df95acae5d96bdd1286 +123 -0
  68. package/skills/project-update/history/blobs/164cd34c182de88c33de7df35beb29200d792419e1c13a5f0b2104f944e57be7 +1 -0
  69. package/skills/project-update/history/blobs/18c7a2753ce9cb6f6d3d1e9d5d1125d75950b879d56b7325225b200869631b29 +15 -0
  70. package/skills/project-update/history/blobs/1d1d907449ec74594d0ee923ac142ae746b9aedf0a1eda9469f075a44d23e3d7 +13 -0
  71. package/skills/project-update/history/blobs/23a42febbacaa7b52a802d428033a99c0694391c81d51fe3b6e1dff608a641d5 +67 -0
  72. package/skills/project-update/history/blobs/25d4f2a86deb5e2574bb3210b67bb24fcc4afb19f93a7b65a057daa874a9d18e +1 -0
  73. package/skills/project-update/history/blobs/48d9950f16e31d55475f566a80dc95f64b35f1c804d2f51729e313a4f29bac8d +13 -0
  74. package/skills/project-update/history/blobs/5353ca529c46f2443e12947ec205a6887fb594a4de1f87f0eb1c0e6c0f826133 +49 -0
  75. package/skills/project-update/history/blobs/5a93f78b4ed4d416c171139b2ce610a117f9c1dd46fbb56ca86729cb4c53d10c +6 -0
  76. package/skills/project-update/history/blobs/5f65bc7ab3fc3c4e75ae05d49eaf4331fafc398c661abe33c69f7380a720344c +32 -0
  77. package/skills/project-update/history/blobs/6a3be0e624e5ad91e44d08857b7183ee53b7903e1e8d56ff196dbc9dde271e4a +75 -0
  78. package/skills/project-update/history/blobs/7170553410bba904ffb7edb4b231e3b3579b85542d84de717299980938f82c9d +13 -0
  79. package/skills/project-update/history/blobs/774b1e3a04ef0d49d33c44a2cfecd92eb6f6b738a12b6a681103ba7fc13e7b86 +67 -0
  80. package/skills/project-update/history/blobs/7be527cd648b27cca6e267de30f8e33a2ce17d5631a82694f23ae5c0623efe1f +15 -0
  81. package/skills/project-update/history/blobs/7ee99edafe7cec45ff577376c0d0178fdb2690134d4656488f46e129aba3cc5a +67 -0
  82. package/skills/project-update/history/blobs/7f063e79195a5c85c37b7260dd20c184d3b9bea85e37202e0bbca73ad84bba49 +13 -0
  83. package/skills/project-update/history/blobs/8b3ef9586491a426767c01da5d01bdd6b0655c576c68c4cc6dfb093367817dc0 +67 -0
  84. package/skills/project-update/history/blobs/917df3320d778ddbaa5c5c7742bc4046bf803c36ed2b050f30844ed206783469 +1 -0
  85. package/skills/project-update/history/blobs/935af1019931058ec3a0b834fe230e127ec914f75edcc4a6c589cfe733b0b979 +13 -0
  86. package/skills/project-update/history/blobs/ae5ce131d6bae401199ff32d8c3dc4d8aec188010a9111624ac64506e2116ff4 +84 -0
  87. package/skills/project-update/history/blobs/c8573ca1f810ce6c4ac49456a4c1725b93e0d3b74c54ce95d46d8747777895f0 +13 -0
  88. package/skills/project-update/history/blobs/ca4499d829268c8766fb3b825a515e88dc55c237e9d202816b50959b4dac031f +13 -0
  89. package/skills/project-update/history/blobs/d6e5b34d475e7187d2a8cb2aac7e7f1a427142baf6461bda387d70ceb4d8a8c1 +30 -0
  90. package/skills/project-update/history/blobs/e120ff4e32b2e950fc94ada4e787d57dc9c790da37223e5a202d74f7e3aadf80 +13 -0
  91. package/skills/project-update/history/blobs/e9adb7a9905eb719ac78ab96671f055da53e5001810f5ad6d6c94306b0323fe7 +74 -0
  92. package/skills/project-update/history/blobs/fae3064e648cdc55be4aeb1177525b302a5e34ebd752e4a904f47dc0f4557b96 +71 -0
  93. package/skills/project-update/history/index.json +60 -20
  94. package/skills/project-update/migrations/0009_state_file.py +25 -0
  95. package/skills/project-update/migrations/0010_dialog_switch.py +30 -0
  96. package/skills/project-update/migrations/0011_retire_risk_tiers.py +37 -0
  97. package/skills/project-update/migrations/README.md +2 -1
  98. package/skills/project-update/migrations/__init__.py +6 -1
  99. package/skills/project-update/render_instructions.py +1 -1
  100. package/skills/project-update/update.py +385 -89
  101. package/skills/rr-build/SKILL.md +14 -5
  102. package/skills/rr-build-all/SKILL.md +190 -0
  103. package/skills/rr-plan/SKILL.md +16 -4
  104. package/skills/rr-review/SKILL.md +24 -9
  105. package/skills/rr-setup/SKILL.md +15 -2
  106. package/skills/rr-setup/setup.py +7 -1
  107. package/skills/rr-ship/SKILL.md +16 -8
  108. package/skills/rr-spec/SKILL.md +4 -2
  109. package/skills/rr-status/SKILL.md +2 -2
  110. package/skills/usage-report/usage-report.py +45 -10
  111. package/skills/ai-init/templates/.ai/policies/risk-tiers.json +0 -628
  112. package/skills/ai-init/templates/.ai/policies/risk-tiers.md +0 -180
@@ -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
 
@@ -183,7 +194,7 @@ def find_root(start):
183
194
  return d
184
195
  parent = os.path.dirname(d)
185
196
  if parent == d:
186
- die("no .ai/ directory found above %s — run /ai-init first" % start)
197
+ die("no .ai/ directory found above %s — run /rr-setup first" % start)
187
198
  d = parent
188
199
 
189
200
 
@@ -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 ""
@@ -394,7 +405,7 @@ def claim_runtime(root, state):
394
405
  "state.py --runtime %s handoff --to %s (this shell is neither runtime; "
395
406
  "name the one taking it)" % ((other_runtime(pending),) * 2))
396
407
  die("RUNTIME_HANDOFF_PENDING: task %s was handed to %s at %s. Resume it there "
397
- "(/ai-task), or take it back here with: %s"
408
+ "(/rr-build), or take it back here with: %s"
398
409
  % (state.get("task_id"), pending, state["handoff"].get("pending_since"), back), 7)
399
410
  # The receiving runtime's first change: the handoff is complete. The
400
411
  # move itself was journaled when it was made, so there is no second event.
@@ -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:
@@ -988,7 +1120,8 @@ def cmd_event(args, root):
988
1120
  die("--data must be a JSON object")
989
1121
  state = load(root)
990
1122
  append_journal(root, state["task_id"], journal_line(
991
- state["task_id"], args.type, "hook", state.get("current_stage"), args.detail, data))
1123
+ state["task_id"], args.type, "agent" if args.type == "note" else "hook",
1124
+ state.get("current_stage"), args.detail, data))
992
1125
  print("%s recorded" % args.type)
993
1126
 
994
1127
 
@@ -1002,7 +1135,7 @@ def find_docs_root(start):
1002
1135
  return d
1003
1136
  parent = os.path.dirname(d)
1004
1137
  if parent == d:
1005
- die("no docs/sdlc/ directory found above %s — run /sdlc-intent first" % start)
1138
+ die("no docs/sdlc/ directory found above %s — run /rr-spec first" % start)
1006
1139
  d = parent
1007
1140
 
1008
1141
 
@@ -1702,6 +1835,15 @@ def handoff_lines(root, state, reason):
1702
1835
  pending = [q["id"] for q in parse_questions(path)[1] if is_pending(q)]
1703
1836
  out.append("Pending questions: %s" % (
1704
1837
  "%s — %s" % (", ".join(pending), os.path.relpath(path, root)) if pending else "none"))
1838
+ if batch_on(state):
1839
+ try:
1840
+ policy = weight.load_policy(root)[0] if weight else {}
1841
+ except (OSError, ValueError):
1842
+ policy = {} # best effort: the handoff never fails a command
1843
+ out.append("Batch: on · ready %s · review %d/%d · suite %d/%d" % (
1844
+ ", ".join(one_line(s["id"], 40) for s in ready_steps(state)) or "none",
1845
+ (state.get("review") or {}).get("passes", 0), budget(policy, state, "review_rounds_max")[0],
1846
+ (state.get("verify") or {}).get("suite_runs", 0), budget(policy, state, "max_suite_runs")[0]))
1705
1847
 
1706
1848
  notes = recent_notes(root, state)
1707
1849
  for kind, heading in (("decision", "Decisions"), ("rejected", "Rejected"),
@@ -1722,7 +1864,7 @@ def handoff_lines(root, state, reason):
1722
1864
  pending_to = (state.get("handoff") or {}).get("pending_to")
1723
1865
  if pending_to:
1724
1866
  review = state.get("cross_vendor_review") or {}
1725
- out.append("Handed to %s at %s%s — resume there with /ai-task" % (
1867
+ out.append("Handed to %s at %s%s — resume there with /rr-build" % (
1726
1868
  pending_to, (state.get("handoff") or {}).get("pending_since"),
1727
1869
  " for the cross-vendor review" if review.get("status") == "requested" else ""))
1728
1870
  advice = advisory_line(state)
@@ -1749,7 +1891,7 @@ def write_handoff(root, state, reason):
1749
1891
  nothing validates.
1750
1892
 
1751
1893
  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
1894
+ task persists it; `handoff` on its own never writes the state file, because
1753
1895
  the hook calls it while another command may be mid-write."""
1754
1896
  path = handoff_path(root)
1755
1897
  try:
@@ -1826,7 +1968,7 @@ def handoff_to(args, root):
1826
1968
  emit(root, state, "runtime_handoff", "%s -> %s (%s)" % (source, args.to, via), data)
1827
1969
  write_handoff(root, state, "manual")
1828
1970
  save(root, state)
1829
- print("Handed %s to %s (%s). In %s, run /ai-task: it resumes from .ai/state/current.json."
1971
+ print("Handed %s to %s (%s). In %s, run /rr-build: it resumes from the state file in .ai/state/."
1830
1972
  % (state.get("task_id"), args.to, via, args.to))
1831
1973
  print("headless (not run): cd %s && %s" % (shlex.quote(root), RESUME_COMMAND[args.to]))
1832
1974
 
@@ -2170,7 +2312,7 @@ INTERPRETER_RE = re.compile(r"^python3?$") # a bare name: a path could be any
2170
2312
  PUSH_REF_RE = re.compile(r"^[A-Za-z0-9._/-]+$") # no '+' (a force push), no ':' (a refspec), no option
2171
2313
  INTERPRETER_FLAG_RE = re.compile(r"^-[IS]+$")
2172
2314
  NPX_PACKAGE_RE = re.compile(r"^riskrails@[0-9A-Za-z.+-]+$")
2173
- SETTLE_EFFECT = {"defer": "defer", "keep": "ack", "answer": "answered"}
2315
+ SETTLE_EFFECT = {"defer": "defer", "keep": "ack", "ack": "ack", "answer": "answered"}
2174
2316
  DIALOG_PICK = None # the pick human_route() consumed in this process
2175
2317
 
2176
2318
 
@@ -2490,7 +2632,8 @@ def open_decision(root, state, kind, header, question, options, opened_by, multi
2490
2632
  return None, "CONSENT_LABEL_INVALID — two options carry the same label"
2491
2633
  decision = dict({
2492
2634
  "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,
2635
+ "runtime": own_runtime(), "session": (dialog_on(root) or {}).get("session"),
2636
+ "tree": current_tree(root), "opened_at": now(), "opened_by": opened_by,
2494
2637
  "header": one_line(header, HEADER_MAX), "multi_select": bool(multi),
2495
2638
  "question": (question or "").strip()[:QUESTION_MAX],
2496
2639
  "options": [dict(option, n=n) for n, option in enumerate(options, 1)],
@@ -2564,7 +2707,8 @@ def settle_decision(root, decision_id, effect, pick=None):
2564
2707
 
2565
2708
  def prune_decisions(root):
2566
2709
  """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)."""
2710
+ command picks are all used, leaves decision.json (R36.14); with the
2711
+ project's dialog switch off, every open one falls back (R36.15)."""
2568
2712
  try:
2569
2713
  with open(decision_path(root), encoding="utf-8") as fh:
2570
2714
  data = json.load(fh)
@@ -2575,11 +2719,17 @@ def prune_decisions(root):
2575
2719
  settled = {e.get("decision") for e in ledger if e["type"] == "settle"}
2576
2720
  used = {e.get("id") for e in ledger if e["type"] == "use"}
2577
2721
  rounds = latest_rounds(ledger)
2722
+ off = dialog_policy(root) is None
2578
2723
  for decision in list(keep):
2579
2724
  commands = {o.get("n") for o in decision.get("options") or [] if o.get("kind") == "command"}
2580
2725
  picks = [e for e in rounds.get(decision.get("id"), []) if e.get("option") in commands]
2581
2726
  if decision.get("id") in settled:
2582
2727
  keep.remove(decision)
2728
+ elif off:
2729
+ with contextlib.suppress(OSError), ledger_lock(root):
2730
+ ledger_append(root, {"type": "settle", "decision": decision["id"], "pick": None,
2731
+ "effect": "fallback"})
2732
+ keep.remove(decision)
2583
2733
  elif picks and all(p.get("id") in used for p in picks):
2584
2734
  parsed = label_argv(picks[-1].get("label") or "")
2585
2735
  effect = "declined" if parsed and subcommand(parsed[1])[:1] == ["reject"] else "approved"
@@ -2727,7 +2877,11 @@ def cmd_decision(args, root):
2727
2877
  return
2728
2878
  data = read_decisions(root)
2729
2879
  if args.action == "show":
2730
- chosen = [d for d in data["open"] if not args.id or d["id"] == args.id]
2880
+ # A session sees the decisions it opened (and those that name none):
2881
+ # the hooks' gate and Stop reminder hold only the opener to them.
2882
+ session = os.environ.get(SESSION_ENV.get(own_runtime(), ""))
2883
+ chosen = [d for d in data["open"] if (not args.id or d["id"] == args.id) and (
2884
+ not session or (d.get("session") in (None, "", session) and d.get("runtime") in (None, "", own_runtime())))]
2731
2885
  if not chosen:
2732
2886
  die("no open decision%s" % (" " + args.id if args.id else ""))
2733
2887
  if args.json:
@@ -2823,6 +2977,9 @@ def decision_settle(args, root, decision):
2823
2977
 
2824
2978
  def apply_choice(root, decision, option, pick_id, by, via):
2825
2979
  effect = SETTLE_EFFECT.get(option.get("effect"), "defer")
2980
+ drift = decision.get("drift") or {}
2981
+ if decision.get("kind") == "plan_drift" and effect == "ack" and drift.get("fingerprint"):
2982
+ write_drift_ack(drift.get("runtime") or own_runtime(), drift["fingerprint"], drift.get("kept"), via)
2826
2983
  if effect == "answered":
2827
2984
  cmd_answer(argparse.Namespace(topic=None, prose=None, by=by, via=via,
2828
2985
  assignment=["%s=%s" % (decision.get("question_id"), option["answer"])]),
@@ -2919,6 +3076,11 @@ def apply_defaults_v7(state):
2919
3076
  ("findings", []), ("diff", {}), ("gate", None), ("mode_override", None)):
2920
3077
  if not isinstance(state.get(key), type(default)) and not (default is None and key in state):
2921
3078
  state[key] = default
3079
+ for step in state["steps"]:
3080
+ if not isinstance(step.get("after"), list):
3081
+ step["after"] = [] # a step from before the batch loop depends on nothing
3082
+ if not isinstance(state.get("batch"), dict):
3083
+ state["batch"] = {"on": False, "by": None, "why": None, "since": None}
2922
3084
  if not isinstance(state.get("verify"), dict):
2923
3085
  state["verify"] = {}
2924
3086
  for key, default in (("status", "not_run"), ("e2e", "not_run"), ("suite_runs", 0), ("runs", [])):
@@ -2978,6 +3140,60 @@ def mode_setting(policy, mode, key, default=None):
2978
3140
  return default
2979
3141
 
2980
3142
 
3143
+ # The batch loop of /rr-build-all: one task for a whole plan, its own budgets.
3144
+ BATCH_DEFAULTS = {"default": False, "review_rounds_max": 3, "max_suite_runs": 8, "parallel": 3, "tier": "BALANCED"}
3145
+
3146
+
3147
+ BATCH_TIERS = ["FAST", "BALANCED", "STRONG", "EXPERT"]
3148
+
3149
+
3150
+ def batch_policy(policy, strict=False):
3151
+ """weight.json's batch section with every key filled in and checked. A table
3152
+ from before the section runs with the shipped numbers (R13). A bad value is
3153
+ refused where the task starts or the loop is switched (strict); a reader —
3154
+ status, the handoff — falls back to the shipped value and never crashes."""
3155
+ section = policy.get("batch") if isinstance(policy.get("batch"), dict) else {}
3156
+ out, problems = dict(BATCH_DEFAULTS), []
3157
+ for key, value in section.items():
3158
+ if key not in BATCH_DEFAULTS:
3159
+ continue
3160
+ if key == "default":
3161
+ if isinstance(value, bool):
3162
+ out[key] = value
3163
+ else:
3164
+ problems.append("batch.default must be true or false, not %r" % (value,))
3165
+ elif key == "tier":
3166
+ if isinstance(value, str) and value.upper() in BATCH_TIERS:
3167
+ out[key] = value.upper()
3168
+ else:
3169
+ problems.append("batch.tier must be one of %s, not %r" % ("/".join(BATCH_TIERS), value))
3170
+ elif isinstance(value, bool) or not isinstance(value, int) or value < 1:
3171
+ problems.append("batch.%s must be a whole number of at least 1, not %r" % (key, value))
3172
+ else:
3173
+ out[key] = value
3174
+ if out["parallel"] >= 3 and out["tier"] not in ("FAST", "BALANCED"):
3175
+ problems.append("batch.tier %s with batch.parallel %d: three or more parallel agents run at FAST or "
3176
+ "BALANCED (model-routing.md, Fan-out)" % (out["tier"], out["parallel"]))
3177
+ if problems and strict:
3178
+ die("weight.json: " + "; ".join(problems))
3179
+ return out
3180
+
3181
+
3182
+ def batch_on(state):
3183
+ return bool((state.get("batch") or {}).get("on"))
3184
+
3185
+
3186
+ def budget(policy, state, key):
3187
+ """(limit, the table key the refusal names) for review_rounds_max or
3188
+ max_suite_runs: the batch section's while the batch loop is on, else the
3189
+ standard one — a task switched off mid-way keeps what it already spent."""
3190
+ if batch_on(state):
3191
+ return int(batch_policy(policy)[key]), "batch.%s" % key
3192
+ if key == "max_suite_runs":
3193
+ return int((policy.get("tests") or {}).get(key, 2)), "tests.max_suite_runs"
3194
+ return int(policy.get(key, 2)), key
3195
+
3196
+
2981
3197
  def read_text(root, rel):
2982
3198
  try:
2983
3199
  with open(os.path.join(root, rel), "rb") as fh:
@@ -3036,7 +3252,10 @@ def rescore_weight(root, base_tree, files, current, policy):
3036
3252
  if line is not None:
3037
3253
  # The operation's keyword (DROP, ALTER …), not the pattern's
3038
3254
  # source: it is what a human names in approve --confirm.
3039
- operations.append(rx.search(line).group(0).split()[0].upper())
3255
+ # The first word of the match; a match of only blanks or punctuation
3256
+ # is named IRREVERSIBLE, never an IndexError.
3257
+ word = re.search(r"[A-Za-z]+", rx.search(line).group(0))
3258
+ operations.append(word.group(0).upper() if word else "IRREVERSIBLE")
3040
3259
  if found.get("reversibility", (0,))[0] < 2:
3041
3260
  found["reversibility"] = (2, "%s adds %r (irreversible_content %r)"
3042
3261
  % (rel, line.strip()[:60], pattern))
@@ -3108,7 +3327,8 @@ def task_md_path(root, state):
3108
3327
 
3109
3328
 
3110
3329
  def parse_steps(text, plan_file):
3111
- """--steps "id:a,b:t;id2:c" or --plan-file F.json ([{id, files, tests}])."""
3330
+ """--steps "id:a,b:t;id2:c" or --plan-file F.json ([{id, files, tests, after}]);
3331
+ `after` (plan-file only) names the steps this one waits for (R2)."""
3112
3332
  raw = []
3113
3333
  if plan_file:
3114
3334
  try:
@@ -3117,24 +3337,91 @@ def parse_steps(text, plan_file):
3117
3337
  except (OSError, ValueError) as exc:
3118
3338
  die("--plan-file %s: %s" % (plan_file, exc))
3119
3339
  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]
3340
+ die("--plan-file holds a JSON list of {\"id\", \"files\", \"tests\", \"after\"}")
3341
+ for s in raw:
3342
+ after = s.get("after") or []
3343
+ if not isinstance(after, list) or not all(isinstance(a, str) and a.strip() for a in after):
3344
+ die("step %s: \"after\" is a JSON list of step ids" % (s.get("id") or "?"))
3345
+ raw = [(str(s.get("id") or ""), ",".join(s.get("files") or []), ",".join(s.get("tests") or []),
3346
+ [one_line(a, 40) for a in s.get("after") or []]) for s in raw]
3123
3347
  for chunk in [c for c in (text or "").split(";") if c.strip()]:
3124
3348
  parts = chunk.split(":")
3125
- raw.append((parts[0], parts[1] if len(parts) > 1 else "", parts[2] if len(parts) > 2 else ""))
3349
+ raw.append((parts[0], parts[1] if len(parts) > 1 else "", parts[2] if len(parts) > 2 else "", []))
3126
3350
  steps = []
3127
- for step_id, files, tests in raw:
3351
+ for step_id, files, tests, after in raw:
3128
3352
  step_id = one_line(step_id, 40)
3129
3353
  if not step_id or not _split_files(files):
3130
3354
  die("a step is id:files[:tests], with at least one file: %r" % ":".join((step_id, files, tests)))
3131
3355
  if step_id in [s["id"] for s in steps]:
3132
3356
  die("step id %s appears twice" % step_id)
3133
3357
  steps.append({"id": step_id, "files": _split_files(files), "tests": _split_files(tests),
3134
- "status": "pending", "tree_before": None, "tree_after": None})
3358
+ "after": list(dict.fromkeys(after)), "status": "pending", "tree_before": None,
3359
+ "tree_after": None})
3135
3360
  return steps
3136
3361
 
3137
3362
 
3363
+ def _overlap(a, b):
3364
+ """One path or pattern covers the other: equal once normalised, or one
3365
+ matches the other as a glob — so `src/*.py` and `./src/a.py` count as shared."""
3366
+ a, b = os.path.normpath(a), os.path.normpath(b)
3367
+ return a == b or bool(weight and (weight.match(a, [b]) or weight.match(b, [a])))
3368
+
3369
+
3370
+ def order_shared_files(steps):
3371
+ """Two steps that name one file or pattern — among their files or their
3372
+ tests — never run side by side: the later one is put after the earlier (R2).
3373
+ Returns the implied edges as (later, earlier, what they share)."""
3374
+ implied = []
3375
+ for n, later in enumerate(steps):
3376
+ for earlier in steps[:n]:
3377
+ shared = [f for f in later["files"] + later["tests"]
3378
+ if any(_overlap(f, g) for g in earlier["files"] + earlier["tests"])]
3379
+ if shared and earlier["id"] not in later["after"]:
3380
+ later["after"].append(earlier["id"])
3381
+ implied.append((later["id"], earlier["id"], ", ".join(dict.fromkeys(shared))))
3382
+ return implied
3383
+
3384
+
3385
+ def check_steps(steps, implied=()):
3386
+ """Every `after` names another, existing step, and the steps form no cycle (R2).
3387
+ A cycle closed by an implied edge (order_shared_files) names that edge."""
3388
+ ids = [s["id"] for s in steps]
3389
+ for step in steps:
3390
+ for dep in step["after"]:
3391
+ if dep == step["id"]:
3392
+ die("step %s lists itself in after" % step["id"])
3393
+ if dep not in ids:
3394
+ die("step %s is after %s, which is not a step (have: %s)" % (step["id"], dep, ", ".join(ids)))
3395
+ by_id = {s["id"]: s for s in steps}
3396
+ seen = {} # id -> "visiting" | "done"
3397
+
3398
+ def visit(step_id, trail):
3399
+ if seen.get(step_id) == "done":
3400
+ return
3401
+ if seen.get(step_id) == "visiting":
3402
+ loop = trail + [step_id]
3403
+ hints = ["%s after %s is implied: they share %s" % edge for edge in implied
3404
+ if edge[0] in loop and edge[1] in loop]
3405
+ die("the steps' after form a cycle: %s%s" % (
3406
+ " -> ".join(loop), " (%s — list the dependent row after the one it needs)" % "; ".join(hints)
3407
+ if hints else ""))
3408
+ seen[step_id] = "visiting"
3409
+ for dep in by_id[step_id]["after"]:
3410
+ visit(dep, trail + [step_id])
3411
+ seen[step_id] = "done"
3412
+
3413
+ for step in steps:
3414
+ visit(step["id"], [])
3415
+
3416
+
3417
+ def ready_steps(state):
3418
+ """The steps not yet done whose `after` are all done, in plan order: the next
3419
+ wave of /rr-build-all, and the step step-done starts next."""
3420
+ steps = state.get("steps") or []
3421
+ done = {s["id"] for s in steps if s.get("status") == "done"}
3422
+ return [s for s in steps if s.get("status") != "done" and all(d in done for d in s.get("after") or [])]
3423
+
3424
+
3138
3425
  def parse_declares(items):
3139
3426
  declared = {}
3140
3427
  for item in items or []:
@@ -3147,6 +3434,10 @@ def parse_declares(items):
3147
3434
 
3148
3435
 
3149
3436
  def next_action_for(state):
3437
+ if batch_on(state):
3438
+ return ("batch loop: 'steps --ready' → implementers, step-done each; 'verify --scope all', red fixed "
3439
+ "as one batch; review, findings fixed as one batch; again until it passes; the pass past the "
3440
+ "batch budget is the human's")
3150
3441
  return {
3151
3442
  "light": "edit, then 'state.py verify --scope all', then 'state.py close'",
3152
3443
  "guarded": "edit, 'state.py verify --scope all', one review ('state.py review'), then 'state.py close'",
@@ -3180,9 +3471,24 @@ def cmd_start(args, root):
3180
3471
  die("start needs a git repository: the two facts close decides by are measured on its tree")
3181
3472
  steps = parse_steps(args.steps, args.plan_file)
3182
3473
  policy = weight_policy(root)
3474
+ table = batch_policy(policy, strict=True) # a malformed section is refused here, not at status
3475
+ if args.batch and args.no_batch:
3476
+ die("--batch and --no-batch exclude each other")
3477
+ if args.batch and not args.by:
3478
+ die("--batch needs --by \"<who asked for the batch loop>\": it is journaled with its author")
3479
+ batch = {"on": False, "by": None, "why": None, "since": None}
3480
+ if args.batch:
3481
+ batch = {"on": True, "by": args.by, "why": args.why or None, "since": now()}
3482
+ elif not args.no_batch and table["default"]:
3483
+ batch = {"on": True, "by": "weight.json batch.default", "why": None, "since": now()}
3484
+ # Shared files order steps only in a batch task, where steps run side by
3485
+ # side; a standard task's steps run one after another, as before.
3486
+ implied = order_shared_files(steps) if batch["on"] else []
3487
+ check_steps(steps, implied)
3183
3488
  concrete = [f for f in files if not any(c in f for c in "*?[")]
3184
3489
  try:
3185
- result = weight.compute(root, concrete, parse_declares(args.declare), args.goal, policy=policy)
3490
+ result = weigh(root, concrete, parse_declares(args.declare), args.goal, policy,
3491
+ [f for f in files if f not in concrete])
3186
3492
  except ValueError as exc:
3187
3493
  die(str(exc))
3188
3494
  except (TypeError, AttributeError) as exc:
@@ -3211,24 +3517,33 @@ def cmd_start(args, root):
3211
3517
  args.mode, shlex.quote(args.by), why), 5)
3212
3518
  override = {"from": mode, "to": args.mode, "by": args.by, "why": args.why, "ts": now()}
3213
3519
  mode = args.mode
3214
- task_id = valid_task_id(args.task_id) if args.task_id else next_task_id(root)
3520
+ if args.slug is not None:
3521
+ import memory_format # pylint: disable=import-outside-toplevel
3522
+ if args.task_id:
3523
+ die("--slug and --task-id both name the id: pass one", 2)
3524
+ if not memory_format.SLUG_RE.match(args.slug):
3525
+ die("--slug takes 3-24 characters of [a-z0-9] words joined by '-', not %r" % args.slug, 2)
3526
+ task_id = valid_task_id(args.task_id) if args.task_id else next_task_id(root, args.goal, args.slug)
3215
3527
  base_tree = current_tree(root)
3216
3528
  state = {
3217
3529
  "schema": SCHEMA, "task_id": task_id, "goal": args.goal, "workflow": args.workflow,
3218
3530
  "mode": mode, "weight": dict(result.as_dict(), lines=weight.render(result).split("\n"),
3219
3531
  tree=base_tree, computed_at=now(), rescores=[], operations=[]),
3220
3532
  "mode_override": override, "stage": "build", "files": files, "tests": tests,
3221
- "steps": steps, "amendments": [], "findings": [],
3533
+ "steps": steps, "amendments": [], "findings": [], "batch": batch,
3222
3534
  "diff": {"base_tree": base_tree, "base_commit": safe_sensor(sensors.head_commit, root)},
3223
3535
  "gate": None, "owner_runtime": RUNTIME if RUNTIME in RUNTIMES else None,
3224
3536
  "created": now(), "created_at": now(), "history": [],
3225
3537
  }
3538
+ if result.documents_only is not None:
3539
+ state["weight"]["documents_only"] = result.documents_only
3226
3540
  if os.path.exists(ledger_path(root)):
3227
3541
  # The ledger is per project: a task mirrors only the lines after its start.
3228
3542
  state["dialog"] = {"ledger_pos": os.path.getsize(ledger_path(root))}
3229
3543
  apply_defaults_v7(state)
3230
3544
  if steps:
3231
- steps[0].update(status="in_progress", tree_before=base_tree)
3545
+ # The first step whose dependencies are done — steps[0] in a plan without `after`.
3546
+ next(iter(ready_steps(state)), steps[0]).update(status="in_progress", tree_before=base_tree)
3232
3547
  arm_scope(state)
3233
3548
  state["next_action"] = next_action_for(state)
3234
3549
  emit(root, state, "task_started", one_line(args.goal, 200),
@@ -3243,6 +3558,9 @@ def cmd_start(args, root):
3243
3558
  actor="human" if MODES.index(mode) < MODES.index(override["from"]) else "agent")
3244
3559
  emit(root, state, "tier_set", "%s (mode %s, compat)" % (MODE_TIER[mode], mode),
3245
3560
  {"tier": MODE_TIER[mode], "mode": mode, "w": result.w})
3561
+ if batch["on"]:
3562
+ emit(root, state, "batch_set", "on by %s" % batch["by"],
3563
+ {"on": True, "by": batch["by"], "why": batch["why"], "via": None, "review_passes": 0, "suite_runs": 0})
3246
3564
  set_resume_point(state)
3247
3565
  write_handoff(root, state, "start")
3248
3566
  save(root, state)
@@ -3250,6 +3568,11 @@ def cmd_start(args, root):
3250
3568
  print(weight.render(result))
3251
3569
  if override:
3252
3570
  print("mode: %s (override of %s by %s)" % (mode, override["from"], args.by))
3571
+ for later, earlier, shared in implied:
3572
+ print("ordered: %s after %s (shares %s)" % (later, earlier, shared))
3573
+ if batch["on"]:
3574
+ print("batch: on by %s · review %s passes · suite %s runs · %s at once on %s"
3575
+ % (batch["by"], table["review_rounds_max"], table["max_suite_runs"], table["parallel"], table["tier"]))
3253
3576
  advice = int((policy.get("rules") or {}).get("steps_advice_over", 8))
3254
3577
  if len(steps) > advice:
3255
3578
  print("advice: %d steps, over %d — fewer, larger steps cost fewer handoffs (not a gate)"
@@ -3291,6 +3614,18 @@ def rescore_v7(root, state, policy, at):
3291
3614
  return
3292
3615
  current = state["weight"].get("axes") or {}
3293
3616
  got = rescore_weight(root, state["diff"].get("base_tree"), [d["path"] for d in diff], current, policy)
3617
+ # The documents rule (faster-task-cycle (c)): the axes it zeroed come back, upward, once the
3618
+ # diff holds a path that is neither a document nor a test — `start --amend` does not re-weigh.
3619
+ zeroed = (state["weight"].get("documents_only") or {}).get("zeroed") or {}
3620
+ tests = ((policy.get("axes") or {}).get("coverage") or {}).get("test_globs")
3621
+ outside = next((d["path"] for d in diff if not is_document(d["path"], policy)
3622
+ and not weight.match(d["path"], tests)), None)
3623
+ if outside:
3624
+ for axis, value in zeroed.items():
3625
+ if int(got["axes"].get(axis) or 0) < int(value):
3626
+ got["raised"].append({"axis": axis, "from": int(got["axes"].get(axis) or 0), "to": int(value),
3627
+ "evidence": "a non-document changed (%s)" % outside})
3628
+ got["axes"][axis] = int(value)
3294
3629
  before = state["mode"]
3295
3630
  ops = sorted(set(state["weight"].get("operations") or []) | set(got["operations"]))
3296
3631
  # Only what the diff moved can move the mode: an unchanged measurement must
@@ -3327,6 +3662,92 @@ def suite_green(state):
3327
3662
  return any(full_suite(r) and r.get("exit") == 0 for r in state["verify"]["runs"])
3328
3663
 
3329
3664
 
3665
+ # Never a document, whatever weight.json's `documents` says: the policies and the per-directory rules bind the agents.
3666
+ NEVER_DOCUMENTS = (".ai/policies/**", ".ai/rules/**")
3667
+
3668
+
3669
+ def is_document(path, policy):
3670
+ """The one definition of a document (faster-task-cycle D4): `documents.globs`, minus
3671
+ `documents.except`, NEVER_DOCUMENTS, code and tests; no `documents` key, no document."""
3672
+ docs = policy.get("documents") or {}
3673
+ cov = (policy.get("axes") or {}).get("coverage") or {}
3674
+ return bool(weight.match(path, docs.get("globs"))
3675
+ and not any(weight.match(path, pats) for pats in (
3676
+ docs.get("except"), NEVER_DOCUMENTS, cov.get("code_globs"), cov.get("test_globs"))))
3677
+
3678
+
3679
+ DOCUMENTS_ZEROED = ("behaviour", "domain", "novelty")
3680
+
3681
+
3682
+ def weigh(root, files, declared, goal, policy, globs=()):
3683
+ """weight.compute, plus the documents rule (faster-task-cycle (c), in state.py because
3684
+ weight.py is at its line cap). Predicate P: at least one concrete file, every file a
3685
+ document (is_document) or a test, at least one a document. When P holds, file text no
3686
+ longer scores behaviour (the goal's keyword still does, exactly as in compute), and
3687
+ behaviour, domain and novelty still UNKNOWN become 0 with the evidence "documents only"; hints and
3688
+ declarations were applied inside compute. `documents_only` is {"zeroed": {axis: what it
3689
+ would have scored}}, None when P does not hold or the policy has no `documents` key.
3690
+ `globs` are the task's glob entries, which score nothing themselves: P fails when one is
3691
+ not itself a document or test glob."""
3692
+ concrete = [posixpath.normpath(f.strip().replace(os.sep, "/")) for f in files if f.strip()]
3693
+ tests = ((policy.get("axes") or {}).get("coverage") or {}).get("test_globs")
3694
+ inside = all(not f.startswith(("/", "../")) and f != ".." for f in concrete)
3695
+ if not (inside and all(is_document(g, policy) or weight.match(g, tests) for g in globs)
3696
+ and any(is_document(f, policy) for f in concrete)
3697
+ and all(is_document(f, policy) or weight.match(f, tests) for f in concrete)):
3698
+ result = weight.compute(root, files, declared, goal, policy=policy)
3699
+ result.documents_only = None
3700
+ return result
3701
+ scored = copy.deepcopy(policy)
3702
+ keywords = list(((policy.get("axes") or {}).get("behaviour") or {}).get("keywords") or [])
3703
+ if not (goal and any(re.search(r"\b%s\b" % re.escape(k), goal, re.I) for k in keywords)):
3704
+ # No goal keyword: file text must not score. With one, compute reads the goal first
3705
+ # (value 1 whatever the files hold), so keeping the list scores exactly as plain compute does.
3706
+ scored["axes"]["behaviour"] = dict(scored["axes"].get("behaviour") or {}, keywords=[])
3707
+ result = weight.compute(root, files, declared, goal, policy=scored)
3708
+ zeroed = {}
3709
+ for axis in DOCUMENTS_ZEROED:
3710
+ if axis not in result.unknown:
3711
+ continue
3712
+ if result.axes[axis] != 0:
3713
+ zeroed[axis] = result.axes[axis]
3714
+ result.axes[axis], result.evidence[axis] = 0, "documents only"
3715
+ result.unknown.remove(axis)
3716
+ result.w = sum(result.axes.values())
3717
+ result.mode = mode_for(result.axes, policy)
3718
+ result.documents_only = {"zeroed": zeroed}
3719
+ return result
3720
+
3721
+
3722
+ def documents_cover(root, state, policy, tree):
3723
+ """Fact (a) through the documents rule (faster-task-cycle D1-D5): (ok, why, facts).
3724
+ why is "" when the rule is off or does not apply: today's refusal text stands."""
3725
+ off = (False, "", None)
3726
+ tests = (safe_sensor(sensors.read_commands, root) or {}).get("doc_tests", "").split()
3727
+ if not (policy.get("documents") or {}).get("globs") or not tests or tests == ["none"]:
3728
+ return off # D1
3729
+ runs = [r for r in state["verify"]["runs"] if full_suite(r) and r.get("exit") is not None and r.get("tree")]
3730
+ g = runs[-1] if runs else None
3731
+ if not g or g["exit"] != 0 or any(r["tree"] == g["tree"] and r["exit"] != 0 for r in runs):
3732
+ return off # D2
3733
+ # A full-suite run after G that is not green stops the rule, whatever its tree (it may be None).
3734
+ if any(full_suite(r) and r["n"] > g["n"] and r.get("exit") != 0 for r in state["verify"]["runs"]):
3735
+ return off
3736
+ diff = safe_sensor(sensors.diff_files, root, g["tree"], tree)
3737
+ if not diff or not all(is_document(d["path"], policy) for d in diff):
3738
+ return off # D3, D4
3739
+ paths = [d["path"] for d in diff]
3740
+ steps = [r for r in state["verify"]["runs"] if r.get("scope") == "step" and not r.get("expected_fail")
3741
+ and r.get("tree") == tree]
3742
+ s = steps[-1] if steps else None # D5
3743
+ 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)):
3744
+ return (False, "only documents changed since run %d (%d files): 'verify --scope step --files \"%s\"'"
3745
+ % (g["n"], len(paths), ",".join(tests)), None)
3746
+ return (True, "documents only since run %d (%d files: %s); doc_tests green in run %d on %s"
3747
+ % (g["n"], len(paths), ", ".join(paths[:5]) + (", …" if len(paths) > 5 else ""), s["n"], tree[:9]),
3748
+ {"since_run": g["n"], "since_tree": g["tree"], "run": s["n"], "paths": paths[:20], "tests": tests})
3749
+
3750
+
3330
3751
  def run_v7(root, state, scope, args, tcfg):
3331
3752
  """One scope's command, to the end, logged under .ai/state/logs/; returns the run."""
3332
3753
  command = safe_sensor(sensors.command_for, root, scope) or ""
@@ -3446,19 +3867,20 @@ def cmd_verify(args, root):
3446
3867
  die("--files narrows one scope; --scope all runs the whole suite and e2e")
3447
3868
  if args.e2e and e2e_na_refusal(root, state):
3448
3869
  die(e2e_na_refusal(root, state)) # before the suite runs, not after (N3)
3449
- max_runs = int(tcfg.get("max_suite_runs", 2))
3870
+ max_runs, budget_name = budget(policy, state, "max_suite_runs")
3450
3871
  if scope in ("suite", "all") and state["verify"]["suite_runs"] >= max_runs:
3451
3872
  if not args.by:
3452
3873
  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),
3874
+ "%s\nSUITE_BUDGET: %d suite runs; weight.json allows %d%s. Run it once more?"
3875
+ % (task_facts(root, state), state["verify"]["suite_runs"], max_runs,
3876
+ " (%s)" % budget_name if batch_on(state) else ""),
3455
3877
  [{"kind": "choice", "effect": "keep", "label": "Keep the budget — stop here"},
3456
3878
  {"kind": "command",
3457
3879
  "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 "
3880
+ die("SUITE_BUDGET — %d suite runs; weight.json allows %d (%s). Running it "
3459
3881
  "again is not the next move: the human decides. In their own terminal:\n python3 %s "
3460
3882
  "--root %s verify --scope %s --by \"<name>\""
3461
- % (state["verify"]["suite_runs"], max_runs, shlex.quote(os.path.abspath(__file__)),
3883
+ % (state["verify"]["suite_runs"], max_runs, budget_name, shlex.quote(os.path.abspath(__file__)),
3462
3884
  shlex.quote(root), scope), 6)
3463
3885
  via, why = human_route(root, allow_dialog=True)
3464
3886
  if via is None:
@@ -3480,7 +3902,9 @@ def cmd_verify(args, root):
3480
3902
  run = run_v7(root, state, "suite" if scope == "all" else scope, args, tcfg)
3481
3903
  code = run["exit"]
3482
3904
  if args.expect == "fail":
3483
- run["bites"] = code not in (0, None)
3905
+ # exit 1 is a failing test; the runner's own usage error (2) is not, unless
3906
+ # testing.md's bite_codes names it (pytest 1,2 — 2 is a collection error)
3907
+ run["bites"] = code in (safe_sensor(sensors.bite_codes, root) or (1,))
3484
3908
  print("expected fail: %s" % ("the test fails without the fix — it bites" if run["bites"]
3485
3909
  else "the test passes without the fix — it does not bite yet"))
3486
3910
  code = 0 if run["bites"] else 1
@@ -3555,6 +3979,20 @@ def verdict_matches_findings(verdict, text):
3555
3979
  % ", ".join(highs))
3556
3980
 
3557
3981
 
3982
+ def review_next_action(state):
3983
+ """What follows the last verdict — also after `batch off`, which changes what
3984
+ a blockers_open verdict asks for: a full pass in batch, a scoped re-review outside."""
3985
+ status = state["review"].get("status")
3986
+ if status == "blockers_open":
3987
+ if batch_on(state):
3988
+ return ("fix every BLOCKER and HIGH as one parallel batch, 'state.py verify --scope all "
3989
+ "--after-fix \"…\"', then a full review pass again")
3990
+ return "fix the blockers, 'state.py verify --scope all --after-fix \"…\"', one scoped re-review"
3991
+ if status in VERDICT_STATUS.values():
3992
+ return "'state.py close'"
3993
+ return next_action_for(state)
3994
+
3995
+
3558
3996
  def cmd_review(args, root):
3559
3997
  """One pass recorded; the pass past review_rounds_max is the human's (exit 6)."""
3560
3998
  state = load_v7(root)
@@ -3565,22 +4003,23 @@ def cmd_review(args, root):
3565
4003
  return
3566
4004
  if not args.verdict or not args.tier:
3567
4005
  die("review needs --verdict pass|pass_with_findings|blockers_open and --tier BALANCED|STRONG")
3568
- rounds = int(policy.get("review_rounds_max", 2))
4006
+ rounds, budget_name = budget(policy, state, "review_rounds_max")
3569
4007
  by_human = None
3570
4008
  if review["passes"] >= rounds:
3571
4009
  if not args.by:
3572
4010
  report = ["--report", os.path.abspath(args.report)] if args.report and os.path.isfile(args.report) else []
3573
4011
  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),
4012
+ "%s\nREVIEW_BUDGET: %d review passes; weight.json allows %d%s. The next verdict is yours."
4013
+ % (task_facts(root, state), review["passes"], rounds,
4014
+ " (%s)" % budget_name if batch_on(state) else ""),
3576
4015
  [{"kind": "choice", "effect": "keep", "label": "Keep it open — no verdict now"}] +
3577
4016
  [{"kind": "command",
3578
4017
  "label": state_label(root, ["review", "--verdict", verdict, "--tier", args.tier] +
3579
4018
  report + ["--by", human_name(root)])}
3580
4019
  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 "
4020
+ die("REVIEW_BUDGET — %d review passes; weight.json allows %d (%s). The next "
3582
4021
  "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,
4022
+ "<verdict> --tier %s --by \"<name>\"" % (review["passes"], rounds, budget_name,
3584
4023
  shlex.quote(os.path.abspath(__file__)),
3585
4024
  shlex.quote(root), args.tier), 6)
3586
4025
  via, why = human_route(root, allow_dialog=True)
@@ -3613,8 +4052,7 @@ def cmd_review(args, root):
3613
4052
  emit(root, state, "review_verdict", "%s on %s (pass %d)" % (args.verdict, args.tier, review["passes"]),
3614
4053
  {"verdict": args.verdict, "tier": args.tier, "pass_n": review["passes"], "labels": labels,
3615
4054
  "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'")
4055
+ state["next_action"] = review_next_action(state)
3618
4056
  write_handoff(root, state, "review")
3619
4057
  save(root, state)
3620
4058
  write_task_md(root, state)
@@ -3657,6 +4095,7 @@ def two_facts(root, state, tree):
3657
4095
  runs = [r for r in state["verify"]["runs"] if full_suite(r)]
3658
4096
  here = [r for r in runs if tree and r.get("tree") == tree]
3659
4097
  green = [r for r in here if r.get("exit") == 0]
4098
+ documents = None
3660
4099
  if not tree:
3661
4100
  a = (False, "the tree cannot be measured (not a git repository)")
3662
4101
  elif not runs:
@@ -3664,6 +4103,9 @@ def two_facts(root, state, tree):
3664
4103
  elif not here:
3665
4104
  a = (False, "the tree changed after the last verify (run %d on %s, now %s)"
3666
4105
  % (runs[-1]["n"], (runs[-1].get("tree") or "?")[:9], tree[:9]))
4106
+ ok, why, documents = documents_cover(root, state, weight_policy(root), tree)
4107
+ if ok or why:
4108
+ a = (ok, why)
3667
4109
  elif not green:
3668
4110
  a = (False, "the suite is red on this tree (run %d, exit %s)" % (here[-1]["n"], here[-1].get("exit")))
3669
4111
  else:
@@ -3696,7 +4138,7 @@ def two_facts(root, state, tree):
3696
4138
  + (" (+%d more)" % (len(outside) - 5) if len(outside) > 5 else ""))
3697
4139
  else:
3698
4140
  b = (True, "%d changed files, all in the task's files" % len(diff))
3699
- return {"a": a, "b": b, "diff": diff or [], "outside": outside}
4141
+ return {"a": a, "b": b, "diff": diff or [], "outside": outside, "documents": documents}
3700
4142
 
3701
4143
 
3702
4144
  def close_owed(state, facts, tree, policy):
@@ -3713,7 +4155,8 @@ def close_owed(state, facts, tree, policy):
3713
4155
  "'start --amend' for a file the task needs)" % "; ".join(red)))
3714
4156
  return owed
3715
4157
  if review["status"] == "blockers_open":
3716
- owed.append(("review", "the blockers fixed, 'verify --scope all --after-fix \"…\"', one scoped re-review"))
4158
+ owed.append(("review", "the blockers fixed, 'verify --scope all --after-fix \"…\"', %s"
4159
+ % ("a full review pass again" if batch_on(state) else "one scoped re-review")))
3717
4160
  elif not reviewed:
3718
4161
  owed.append(("review", "one review verdict (%s mode: %s)"
3719
4162
  % (mode, mode_setting(policy, mode, "reviewer", "BALANCED"))))
@@ -3780,6 +4223,7 @@ def cmd_close_v7(args, root):
3780
4223
  state = load_v7(root)
3781
4224
  if args.abandon:
3782
4225
  archive_v7(root, state, current_tree(root), "abandoned")
4226
+ memory_after_close(root, state["task_id"])
3783
4227
  return
3784
4228
  policy = weight_policy(root)
3785
4229
  tree = current_tree(root)
@@ -3799,6 +4243,8 @@ def cmd_close_v7(args, root):
3799
4243
  rescore_v7(root, state, policy, "close")
3800
4244
  facts = two_facts(root, state, tree)
3801
4245
  state["facts"] = {"a": list(facts["a"]), "b": list(facts["b"]), "tree": tree, "at": now()}
4246
+ if facts["documents"]:
4247
+ state["facts"]["documents"] = facts["documents"]
3802
4248
  owed = close_owed(state, facts, tree, policy)
3803
4249
  if owed:
3804
4250
  state["next_action"] = owed[0][1]
@@ -3842,6 +4288,7 @@ def cmd_close_v7(args, root):
3842
4288
  state["gate"].update(approved_by=approval.get("granted_by"), approved_at=approval.get("granted_at"),
3843
4289
  via=approval.get("via"), approved_tree=state["gate"].get("tree"))
3844
4290
  archive_v7(root, state, tree, "closed", facts)
4291
+ memory_after_close(root, state["task_id"])
3845
4292
 
3846
4293
 
3847
4294
  def archive_v7(root, state, tree, result, facts=None):
@@ -3865,9 +4312,11 @@ def archive_v7(root, state, tree, result, facts=None):
3865
4312
  ensure_logs(root, state["task_id"])
3866
4313
  target = os.path.join(logs_dir(root, state["task_id"]), "state.json")
3867
4314
  state["updated_at"] = now()
3868
- with open(target, "w", encoding="utf-8") as fh:
4315
+ tmp = "%s.%d.tmp" % (target, os.getpid())
4316
+ with open(tmp, "w", encoding="utf-8") as fh:
3869
4317
  json.dump(state, fh, indent=2, ensure_ascii=False)
3870
4318
  fh.write("\n")
4319
+ os.replace(tmp, target)
3871
4320
  with contextlib.suppress(OSError):
3872
4321
  os.remove(handoff_path(root))
3873
4322
  remove_state(root)
@@ -3895,16 +4344,98 @@ def cmd_step_done_v7(args, root):
3895
4344
  die("SCOPE_CHANGE_REQUIRED — step %s changed %s, outside the task's files. Widen the task in "
3896
4345
  "place: state.py start --amend --files \"%s\" --why \"…\""
3897
4346
  % (step["id"], ", ".join(outside), ",".join("+" + p for p in outside)), 6)
4347
+ # In a wave the diff since the step began holds other steps' work too. The
4348
+ # journal says which changed paths were this step's own (its files and
4349
+ # tests) and which were not, so a step that strayed into another's files is
4350
+ # visible; `unready` names dependencies it was recorded ahead of.
4351
+ own_scope = list(step.get("files") or []) + list(step.get("tests") or [])
4352
+ own = [d["path"] for d in diff or [] if sensors.scope_any(d["path"], own_scope)]
4353
+ others = [d["path"] for d in diff or [] if d["path"] not in own]
4354
+ unready = [dep for dep in step.get("after") or [] if any(s["id"] == dep and s["status"] != "done" for s in steps)]
3898
4355
  step.update(status="done", tree_after=tree)
3899
4356
  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)
4357
+ {"step_id": step["id"], "files": len(diff or []), "own": own, "others": others, "unready": unready},
4358
+ legacy="step_completed")
4359
+ # The first pending step whose dependencies are done — the first pending one
4360
+ # when no step has `after`, as before the batch loop.
4361
+ following = next((s for s in ready_steps(state) if s["status"] == "pending"), None)
3902
4362
  if following:
3903
4363
  following.update(status="in_progress", tree_before=tree)
3904
4364
  emit(root, state, "step_started", following["id"], {"step_id": following["id"], "kind": "step"})
3905
4365
  save(root, state)
3906
4366
  write_task_md(root, state)
3907
- print("step %s done%s" % (step["id"], "; next: %s" % following["id"] if following else "; all steps done"))
4367
+ open_steps = [s["id"] for s in steps if s["status"] != "done"]
4368
+ if unready:
4369
+ print("note: %s was done before %s, which it is after" % (step["id"], ", ".join(unready)))
4370
+ if others and batch_on(state):
4371
+ print("note: the diff also holds %d file(s) outside this step's own — %s"
4372
+ % (len(others), ", ".join(others[:4]) + (" …" if len(others) > 4 else "")))
4373
+ print("step %s done%s" % (step["id"], "; next: %s" % following["id"] if following
4374
+ else "; %d still open: %s" % (len(open_steps), ", ".join(open_steps)) if open_steps
4375
+ else "; all steps done"))
4376
+
4377
+
4378
+ def cmd_steps(args, root):
4379
+ """Read-only: the steps, their status and `after`; --ready prints the next
4380
+ wave (R3), nothing when there is none."""
4381
+ state = load_v7(root)
4382
+ steps = state.get("steps") or []
4383
+ ready = [s["id"] for s in ready_steps(state)]
4384
+ if args.json:
4385
+ print(json.dumps({"task_id": state["task_id"], "batch": batch_on(state), "ready": ready, "steps": steps,
4386
+ "batch_policy": batch_policy(weight_policy(root))}, indent=2, ensure_ascii=False))
4387
+ return
4388
+ if args.ready:
4389
+ for step_id in ready:
4390
+ print(step_id)
4391
+ return
4392
+ if not steps:
4393
+ print("steps: one scope — no plan-file steps")
4394
+ return
4395
+ for s in steps:
4396
+ print("%s · %s · after %s · %s" % (s["id"], s["status"], ", ".join(s.get("after") or []) or "-",
4397
+ "ready" if s["id"] in ready else "waiting" if s["status"] != "done" else "-"))
4398
+
4399
+
4400
+ def cmd_batch(args, root):
4401
+ """The batch loop's switch during a task (R9). `off` raises the rigor — the
4402
+ standard budgets from here, counting what was spent — and is anyone's with
4403
+ --by and --why. `on` lowers it, so it takes the human route or exits 5."""
4404
+ state = load_v7(root)
4405
+ want = args.action == "on"
4406
+ if not args.by:
4407
+ die("batch %s needs --by \"<who>\"" % args.action)
4408
+ if want == batch_on(state):
4409
+ print("batch is already %s" % args.action)
4410
+ return
4411
+ via = None
4412
+ if want:
4413
+ via, why = human_route(root)
4414
+ if via is None:
4415
+ die("APPROVAL_REFUSED — turning the batch loop on during a task lowers the rigor, so it is the "
4416
+ "human's. In their own terminal:\n python3 %s --root %s batch on --by \"<name>\"\nRefused "
4417
+ "because %s." % (shlex.quote(os.path.abspath(__file__)), shlex.quote(root), why), 5)
4418
+ elif not (args.why or "").strip():
4419
+ die("batch off needs --why \"<why the standard loop from here>\"")
4420
+ policy = weight_policy(root)
4421
+ if want:
4422
+ batch_policy(policy, strict=True) # a malformed section is refused before the loop starts
4423
+ state["batch"] = {"on": want, "by": args.by, "why": args.why or None, "since": now()}
4424
+ if state.get("stage") in ("build", "verify", "review"):
4425
+ # At the gate (stage ship) the next action is the human's and stays so.
4426
+ state["next_action"] = review_next_action(state)
4427
+ emit(root, state, "batch_set", "%s by %s%s" % (args.action, args.by, " via %s" % via if via else ""),
4428
+ {"on": want, "by": args.by, "why": args.why or None, "via": via,
4429
+ "review_passes": state["review"]["passes"], "suite_runs": state["verify"]["suite_runs"]},
4430
+ actor="human" if via == "terminal" else "agent")
4431
+ set_resume_point(state)
4432
+ write_handoff(root, state, "batch")
4433
+ save(root, state)
4434
+ write_task_md(root, state)
4435
+ print("batch %s by %s · review %d/%d · suite %d/%d" % (
4436
+ args.action, args.by, state["review"]["passes"], budget(policy, state, "review_rounds_max")[0],
4437
+ state["verify"]["suite_runs"], budget(policy, state, "max_suite_runs")[0]))
4438
+ print("next: %s" % state["next_action"])
3908
4439
 
3909
4440
 
3910
4441
  def cmd_approve_v7(args, root):
@@ -3945,6 +4476,12 @@ def status_lines(root, state):
3945
4476
  events, _ = read_journal(root, state["task_id"])
3946
4477
  last = events[-1] if events else {}
3947
4478
  pending = (state.get("questions") or {}).get("pending") or []
4479
+ if is_v7(state):
4480
+ # The cache lags a hand-filled [Answer]:; the file is the truth, as guard_pending reads it.
4481
+ with contextlib.suppress(OSError, ValueError, KeyError):
4482
+ _, asked = parse_questions(questions_path(root, state, None))
4483
+ gate_id = open_gate(state)
4484
+ pending = [q["id"] for q in asked if (q["kind"] != "G" and is_pending(q)) or q["id"] == gate_id]
3948
4485
  lines.append("task: %s — %s" % (state["task_id"], one_line(state.get("goal"), 140)))
3949
4486
  if not is_v7(state):
3950
4487
  lines += ["tier: %s · workflow %s · stage %s" % (state.get("risk_tier") or "?", state.get("workflow"),
@@ -3975,12 +4512,13 @@ def status_lines(root, state):
3975
4512
  + (" (+%d more)" % (len(task_files(state)) - 4)
3976
4513
  if len(task_files(state)) > 4 else "")),
3977
4514
  "verify: suite %s · runs %d/%s · e2e %s" % (ver["status"], ver["suite_runs"],
3978
- (policy.get("tests") or {}).get("max_suite_runs", 2),
4515
+ budget(policy, state, "max_suite_runs")[0],
3979
4516
  ver.get("e2e")),
3980
4517
  "facts: (a) %s — %s" % ("green" if facts["a"][0] else "RED", facts["a"][1]),
3981
4518
  " (b) %s — %s" % ("green" if facts["b"][0] else "RED", facts["b"][1]),
3982
4519
  "review: %s (%d of %s) · tier %s%s" % (review["status"], review["passes"],
3983
- policy.get("review_rounds_max", 2), review.get("tier") or "-",
4520
+ budget(policy, state, "review_rounds_max")[0],
4521
+ review.get("tier") or "-",
3984
4522
  " · open %s" % ", ".join(review["findings"])
3985
4523
  if review["findings"] else ""),
3986
4524
  "gate: %s" % ("approved by %s%s" % (gate["approved_by"], " · confirm \"%s\"" % gate["confirm"]
@@ -3990,6 +4528,13 @@ def status_lines(root, state):
3990
4528
  if state["mode"] in ("full", "full_irreversible") else "none in %s mode" % state["mode"]),
3991
4529
  "findings: %d recorded in task.md" % len(state.get("findings") or []),
3992
4530
  ]
4531
+ if batch_on(state):
4532
+ # Before the truncation point, so the 20-line cap never cuts it.
4533
+ lines.insert(3, "batch: on by %s · review %d/%d · suite %d/%d · ready %s" % (
4534
+ one_line(state["batch"].get("by"), 40), review["passes"],
4535
+ budget(policy, state, "review_rounds_max")[0], ver["suite_runs"],
4536
+ budget(policy, state, "max_suite_runs")[0],
4537
+ ", ".join(s["id"] for s in ready_steps(state)) or "none"))
3993
4538
  lines += ["questions: %d pending%s" % (len(pending), " — %s" % ", ".join(map(str, pending[:4])) if pending else ""),
3994
4539
  schema_line(root),
3995
4540
  "last event: %s" % (" ".join(one_line(last.get(k), 80) for k in ("ts", "event", "detail")
@@ -4053,11 +4598,16 @@ def task_md_lines(root, state):
4053
4598
  " — %s" % one_line(override["why"])
4054
4599
  if override.get("why") else ""))
4055
4600
  out += ["re-score %s" % one_line(line, 300) for line in w.get("rescores") or []]
4601
+ if batch_on(state):
4602
+ batch = state["batch"]
4603
+ out.append("batch: on by %s at %s%s" % (one_line(batch.get("by"), 80), batch.get("since"),
4604
+ " — %s" % one_line(batch["why"]) if batch.get("why") else ""))
4056
4605
  out += ["", "## Plan", "", "files: %s" % (", ".join(state.get("files") or []) or "none"),
4057
4606
  "tests: %s" % (", ".join(state.get("tests") or []) or "none")]
4058
4607
  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"]))
4608
+ out.append("- %s · %s · %s · %s%s" % (step["id"], ", ".join(step["files"]),
4609
+ ", ".join(step["tests"]) or "no tests", step["status"],
4610
+ " · after %s" % ", ".join(step["after"]) if step.get("after") else ""))
4061
4611
  out += ["amended %s: +%s — %s" % (a["ts"], ", ".join(a["added"]), one_line(a["why"]))
4062
4612
  for a in state.get("amendments") or []]
4063
4613
  out += ["", QUESTIONS_SECTION, "", "none"]
@@ -4155,9 +4705,11 @@ def abandon_old(_args, root):
4155
4705
  ensure_logs(root, state["task_id"])
4156
4706
  target = os.path.join(logs_dir(root, state["task_id"]), "state.json")
4157
4707
  state["updated_at"] = now()
4158
- with open(target, "w", encoding="utf-8") as fh:
4708
+ tmp = "%s.%d.tmp" % (target, os.getpid())
4709
+ with open(tmp, "w", encoding="utf-8") as fh:
4159
4710
  json.dump(state, fh, indent=2, ensure_ascii=False)
4160
4711
  fh.write("\n")
4712
+ os.replace(tmp, target)
4161
4713
  with contextlib.suppress(OSError):
4162
4714
  os.remove(handoff_path(root))
4163
4715
  remove_state(root)
@@ -4180,6 +4732,360 @@ def route_surface(root, args):
4180
4732
  die(old_surface_refusal(state))
4181
4733
 
4182
4734
 
4735
+ # ------------------------------------------------------------------ the compact record (compact-project-memory)
4736
+ def memory_modules():
4737
+ """memory_sync and memory_store, imported only by the commands that use them."""
4738
+ try:
4739
+ import memory_store # pylint: disable=import-outside-toplevel
4740
+ import memory_sync # pylint: disable=import-outside-toplevel
4741
+ except ImportError as exc:
4742
+ die("the memory modules are not installed beside state.py (%s)" % exc, 2)
4743
+ return memory_sync, memory_store
4744
+
4745
+
4746
+ def memory_after_close(root, task_id):
4747
+ """§D5: packages mode exports the closed task. Files mode (no memory.json)
4748
+ imports nothing and prints nothing (R15)."""
4749
+ if not os.path.exists(os.path.join(root, ".ai", "policies", "memory.json")):
4750
+ return
4751
+ try:
4752
+ import memory_sync # pylint: disable=import-outside-toplevel
4753
+ except ImportError:
4754
+ return
4755
+ memory_sync.after_close(root, task_id)
4756
+
4757
+
4758
+ def memory_config(sync, root):
4759
+ try:
4760
+ return sync.load_config(root)
4761
+ except sync.MemoryError_ as exc:
4762
+ die(str(exc), exc.code)
4763
+ return None
4764
+
4765
+
4766
+ def memory_human(root, what):
4767
+ """R16: enable, disable, cleanup, pack, redact and move-logs take the human's route, as approve does."""
4768
+ via, why = human_route(root)
4769
+ if via is None:
4770
+ die("APPROVAL_REFUSED — 'memory %s' is the human's, from their own terminal:\n python3 %s --root %s "
4771
+ "%s\nRefused because %s." % (what, shlex.quote(os.path.abspath(__file__)), shlex.quote(root),
4772
+ " ".join(shlex.quote(a) for a in sys.argv[sys.argv.index("memory"):]
4773
+ if "memory" in sys.argv), why), 5)
4774
+ return via
4775
+
4776
+
4777
+ def memory_confirm(cfg, name, what):
4778
+ if not name:
4779
+ die("'memory %s' deletes or moves files: add --confirm-delete %s" % (what, cfg.author), 2)
4780
+ if name != cfg.author:
4781
+ die("--confirm-delete names %r; this clone's author is %r (.ai/local/memory/config.json)"
4782
+ % (name, cfg.author), 2)
4783
+
4784
+
4785
+ def in_flight(root):
4786
+ state = load(root, required=False, claim=False)
4787
+ return state if state and state.get("current_stage") != "done" and state.get("task_id") else None
4788
+
4789
+
4790
+ def cmd_recall(args, root):
4791
+ """R10: the open risks, residual findings, decisions, scope changes and task
4792
+ one-liners that touch these files, within the budget. Read-only."""
4793
+ sync, store = memory_modules()
4794
+ cfg = memory_config(sync, root)
4795
+ files = _split_files(args.files)
4796
+ if not files:
4797
+ die("recall needs --files \"a,b,dir/**\"", 2)
4798
+ kinds = _split_files(args.kinds) or None
4799
+ history = store.open_history(root, cfg)
4800
+ budget = args.budget if args.budget is not None else cfg.recall_budget
4801
+ text, result = history.recall(files, budget, cfg.recall_max_items, kinds, args.all_risks, args.offset)
4802
+ if args.json:
4803
+ print(json.dumps({"schema": 1, "files": files, "budget": budget, "used": result["used"],
4804
+ "items": [{k: i[k] for k in ("rid", "kind", "task", "ts", "line", "src")}
4805
+ for i in result["items"]],
4806
+ "more": result["more"], "next": result["next"], "source": history.source()},
4807
+ ensure_ascii=False, sort_keys=True))
4808
+ return
4809
+ sys.stdout.write(text)
4810
+
4811
+
4812
+ def cmd_memory(args, root):
4813
+ sync, store = memory_modules()
4814
+ action = args.memory_action
4815
+ try:
4816
+ MEMORY_ACTIONS[action](args, root, sync, store)
4817
+ except sync.MemoryError_ as exc:
4818
+ die(str(exc), exc.code)
4819
+
4820
+
4821
+ def memory_status(args, root, sync, _store):
4822
+ lines = sync.status_lines(root)
4823
+ if args.json:
4824
+ print(json.dumps(dict(line.split(": ", 1) for line in lines), ensure_ascii=False, sort_keys=True))
4825
+ return
4826
+ print("\n".join(lines))
4827
+
4828
+
4829
+ def memory_enable(args, root, sync, _store):
4830
+ memory_human(root, "enable")
4831
+ hook = sync.enable(root, args.author, args.period, args.index)
4832
+ print("memory: packages mode on (%s); every teammate's next close exports" % sync.POLICY_REL)
4833
+ print("pre-push: %s" % hook)
4834
+ if hook in ("foreign", "hookspath"):
4835
+ print("not written — %s. Add these lines to your own pre-push hook:\n%s" % (
4836
+ "core.hooksPath is set" if hook == "hookspath" else "a pre-push hook exists", sync.snippet_text()))
4837
+ print("\n".join(sync.status_lines(root)))
4838
+
4839
+
4840
+ def memory_disable(_args, root, sync, _store):
4841
+ memory_human(root, "disable")
4842
+ print("memory: files mode; packages and local data kept; pre-push: %s" % sync.disable(root))
4843
+
4844
+
4845
+ def memory_export(args, root, sync, _store):
4846
+ cfg = memory_config(sync, root)
4847
+ ids = "pending" if args.pending or not args.ids else [valid_task_id(t, "ID") for t in args.ids]
4848
+ result = sync.export(root, ids, cfg)
4849
+ for line in sync.record_lines(result):
4850
+ print(line)
4851
+ print("exported: +%d entries%s" % (result.added, "" if result.added else " — nothing pending"))
4852
+ for task_id, why in result.failures:
4853
+ print("memory: export of %s failed (%s)" % (task_id, why))
4854
+ if result.failures:
4855
+ sys.exit(1)
4856
+
4857
+
4858
+ def memory_verify(args, root, sync, _store):
4859
+ stats = None
4860
+ if args.range:
4861
+ if ".." not in args.range:
4862
+ die("--range takes OLD..NEW", 2)
4863
+ old, new = args.range.split("..", 1)
4864
+ findings, stats = sync.validate_range(root, sync.rev(root, old), sync.rev(root, new) or new, local=False)
4865
+ findings = findings or []
4866
+ elif args.fresh_clone:
4867
+ findings = sync.verify_fresh_clone(root)
4868
+ else:
4869
+ findings = sync.verify(root, "all", [valid_task_id(t, "ID") for t in args.ids] or None)
4870
+ failing = [f for f in findings if not f.check.endswith("-warn")]
4871
+ if args.json:
4872
+ print(json.dumps({"findings": [f._asdict() for f in findings], "ok": not failing, "stats": stats},
4873
+ ensure_ascii=False, sort_keys=True))
4874
+ else:
4875
+ for f in findings:
4876
+ print(sync.format_finding(f))
4877
+ print("memory verify: %s" % ("ok" if not failing else "%d finding(s)" % len(failing)))
4878
+ if failing:
4879
+ sys.exit(1)
4880
+
4881
+
4882
+ def memory_show(args, root, sync, _store):
4883
+ print("\n".join(sync.show(root, valid_task_id(args.id, "ID"), "json" if args.json else "md", args.entry)))
4884
+
4885
+
4886
+ def memory_search(args, root, sync, store):
4887
+ cfg = memory_config(sync, root)
4888
+ filters = {"kind": args.kind, "by": args.by, "task": args.task, "since": args.since, "until": args.until,
4889
+ "files": _split_files(args.file) or None}
4890
+ history = store.open_history(root, cfg)
4891
+ hits, total = history.search(args.query or "", filters, args.limit, args.offset)
4892
+ if args.json:
4893
+ print(json.dumps({"schema": 1, "query": args.query or "", "total": total, "offset": args.offset,
4894
+ "items": [{"rid": e["rid"], "kind": e["kind"], "task": e.get("task"), "ts": e.get("ts"),
4895
+ "line": store.mf.search_line(e)} for e in hits],
4896
+ "source": history.source()}, ensure_ascii=False, sort_keys=True))
4897
+ return
4898
+ print("# Search — \"%s\" · %d of %d matches · offset %d · index %s" % (
4899
+ args.query or "", len(hits), total, args.offset, history.source()["index"]))
4900
+ for e in hits:
4901
+ print("- %s" % store.mf.search_line(e))
4902
+ if args.offset + len(hits) < total:
4903
+ print("… %d more: --offset %d" % (total - args.offset - len(hits), args.offset + len(hits)))
4904
+
4905
+
4906
+ def risk_scope(state):
4907
+ """C4 (§D9): with a task in flight, files mode edits known-risks.md only
4908
+ when the task names it."""
4909
+ rel = ".ai/project/known-risks.md"
4910
+ if state and not sensors.scope_any(rel, task_files(state)):
4911
+ die("SCOPE_CHANGE_REQUIRED — 'memory risk' writes %s, which task %s does not name:\n"
4912
+ " state.py start --amend --files \"+%s\" --why \"<the risk you record>\"" % (rel, state["task_id"], rel),
4913
+ 6)
4914
+
4915
+
4916
+ def memory_risk(args, root, sync, store):
4917
+ cfg = memory_config(sync, root)
4918
+ state = in_flight(root)
4919
+ if args.risk_action == "add":
4920
+ where = _split_files(args.where)
4921
+ if args.label not in store.mf.LABELS:
4922
+ die("--label takes one of: %s" % ", ".join(store.mf.LABELS), 2)
4923
+ if args.status not in store.mf.RISK_STATUS:
4924
+ die("--status takes one of: %s" % ", ".join(store.mf.RISK_STATUS), 2)
4925
+ if not cfg.packages:
4926
+ risk_scope(state)
4927
+ found = "%s, by %s" % (now()[:10], state["task_id"] if state else "no task")
4928
+ rkey = sync.risk_add_files(root, args.title, where, args.label, args.what, args.likely, args.fix,
4929
+ args.status, found)
4930
+ print("risk: %s added to .ai/project/known-risks.md" % rkey)
4931
+ return
4932
+ data = sync.risk_data(args.title, where, args.label, args.what, args.likely, args.fix, args.status)
4933
+ else:
4934
+ if args.status not in ("fixed", "accepted", "ticketed"):
4935
+ die("--status takes fixed, accepted or ticketed", 2)
4936
+ if not cfg.packages:
4937
+ risk_scope(state)
4938
+ sync.risk_close_files(root, args.rkey, args.status, args.why)
4939
+ print("risk: %s is %s" % (args.rkey, args.status))
4940
+ return
4941
+ history = store.FileHistory(root, cfg)
4942
+ found = [e for e in history.risks("all") if e.get("rkey") == args.rkey]
4943
+ if not found:
4944
+ die("no risk %s ('state.py memory risks --status all')" % args.rkey, 1)
4945
+ last = found[0]
4946
+ data = sync.risk_data(last["title"], last.get("where"), last.get("label"), last.get("what"),
4947
+ last.get("likelihood"), last.get("fix"), args.status, args.why, last["rid"])
4948
+ if state:
4949
+ state = load(root)
4950
+ emit(root, state, "risk_recorded", "%s: %s" % (data["rkey"], one_line(data["title"], 200)), data)
4951
+ save(root, state)
4952
+ print("risk: %s recorded in %s's journal (exported at close)" % (data["rkey"], state["task_id"]))
4953
+ return
4954
+ entry = sync.standalone_risk(root, cfg, data, RUNTIME)
4955
+ print("risk: %s recorded (%s)" % (data["rkey"], entry["rid"]))
4956
+
4957
+
4958
+ def memory_risks(args, root, sync, store):
4959
+ cfg = memory_config(sync, root)
4960
+ risks = store.open_history(root, cfg).risks(args.status)
4961
+ if args.json:
4962
+ for e in risks:
4963
+ print(store.mf.canonical(e).decode("utf-8"))
4964
+ return
4965
+ if args.md:
4966
+ out = ["# Known risks", ""]
4967
+ for e in risks:
4968
+ out += (e["text"].split("\n") if e.get("text") else store.mc.risk_section(
4969
+ e.get("title"), store.mf.where_list(e.get("where")), e.get("label"), e.get("what"),
4970
+ e.get("likelihood"), e.get("fix"), "%s%s" % (e.get("status"), " — %s" % e["status_why"]
4971
+ if e.get("status_why") else ""),
4972
+ e.get("found_by") or "-")) + [""]
4973
+ print("\n".join(out).rstrip("\n"))
4974
+ return
4975
+ print("# Risks — %s · %d" % (args.status, len(risks)))
4976
+ for e in risks:
4977
+ where = store.mf.where_list(e.get("where"))
4978
+ print("- [%s %s] %s — %s (%s)" % (e.get("status"), e.get("label"), where[0] if where else "-",
4979
+ e.get("title"), e.get("rkey")))
4980
+
4981
+
4982
+ def memory_migrate(args, root, sync, _store):
4983
+ cfg = memory_config(sync, root)
4984
+ if args.move_logs:
4985
+ memory_human(root, "migrate --move-logs")
4986
+ memory_confirm(cfg, args.confirm_delete, "migrate --move-logs")
4987
+ moved, command = sync.move_logs(root)
4988
+ print("moved %d committed log(s) into .ai/state/logs/<id>/legacy/; now, as its own commit (C3):\n %s"
4989
+ % (len(moved), command))
4990
+ return
4991
+ if args.apply:
4992
+ print("\n".join(sync.migrate_apply(root, risks=args.risks)))
4993
+ return
4994
+ report = sync.migrate_report(root)
4995
+ print("report: %s (%d lines) — read it, then 'state.py memory migrate --apply'"
4996
+ % (os.path.join(sync.LOCAL_REL, "migration-report.md"), report.count("\n")))
4997
+
4998
+
4999
+ def memory_rebuild(_args, root, sync, store):
5000
+ cfg = memory_config(sync, root)
5001
+ print("index: rebuilt from %d sources (%s)" % (store.SqliteHistory(root, cfg).rebuild(), store.INDEX_REL))
5002
+
5003
+
5004
+ def memory_cleanup(args, root, sync, _store):
5005
+ cfg = memory_config(sync, root)
5006
+ memory_human(root, "cleanup")
5007
+ memory_confirm(cfg, args.confirm_delete, "cleanup")
5008
+ if not args.all and not args.ids:
5009
+ die("memory cleanup takes IDs or --all", 2)
5010
+ print("\n".join(sync.cleanup(root, "all" if args.all else [valid_task_id(t, "ID") for t in args.ids])))
5011
+
5012
+
5013
+ def memory_restore(args, root, sync, _store):
5014
+ if not args.all and not args.ids:
5015
+ die("memory restore takes IDs or --all", 2)
5016
+ print("\n".join(sync.restore(root, "all" if args.all else [valid_task_id(t, "ID") for t in args.ids])))
5017
+
5018
+
5019
+ def memory_pack(args, root, sync, _store):
5020
+ cfg = memory_config(sync, root)
5021
+ memory_human(root, "pack")
5022
+ memory_confirm(cfg, args.confirm_delete, "pack")
5023
+ target, removed, count = sync.pack(root, args.period)
5024
+ print("pack: %s (%d records; %d batch(es) removed)" % (os.path.join(sync.MEMORY_REL, target), count,
5025
+ len(removed)))
5026
+
5027
+
5028
+ def memory_redact(args, root, sync, _store):
5029
+ via = memory_human(root, "redact")
5030
+ rid, name = sync.redact(root, args.rid, args.field, args.why, by="%s via %s" % (os.environ.get("USER") or
5031
+ "unknown", via))
5032
+ print("redacted: %s in %s (new rid %s)" % (args.field, os.path.join(sync.MEMORY_REL, name), rid))
5033
+
5034
+
5035
+ MEMORY_ACTIONS = {"status": memory_status, "enable": memory_enable, "disable": memory_disable,
5036
+ "export": memory_export, "verify": memory_verify, "show": memory_show, "search": memory_search,
5037
+ "risk": memory_risk, "risks": memory_risks, "migrate": memory_migrate, "rebuild": memory_rebuild,
5038
+ "cleanup": memory_cleanup, "restore": memory_restore, "pack": memory_pack,
5039
+ "redact": memory_redact}
5040
+
5041
+
5042
+ def memory_parsers(sub):
5043
+ p = sub.add_parser("recall", help="what the record holds about these files, within a token budget")
5044
+ p.add_argument("--files", default=""); p.add_argument("--budget", type=int)
5045
+ p.add_argument("--kinds", default=""); p.add_argument("--all-risks", dest="all_risks", action="store_true")
5046
+ p.add_argument("--offset", type=int, default=0); p.add_argument("--json", action="store_true")
5047
+ p.set_defaults(func=cmd_recall)
5048
+ p = sub.add_parser("memory", help="the compact project record (docs/local-memory.md)")
5049
+ p.set_defaults(func=cmd_memory)
5050
+ msub = p.add_subparsers(dest="memory_action", required=True)
5051
+ m = msub.add_parser("status"); m.add_argument("--json", action="store_true")
5052
+ m = msub.add_parser("enable"); m.add_argument("--author"); m.add_argument("--period", choices=["month", "quarter", "year"])
5053
+ m.add_argument("--index", choices=["none", "sqlite"])
5054
+ msub.add_parser("disable")
5055
+ m = msub.add_parser("export"); m.add_argument("ids", nargs="*"); m.add_argument("--pending", action="store_true")
5056
+ m = msub.add_parser("verify"); m.add_argument("ids", nargs="*"); m.add_argument("--all", action="store_true")
5057
+ m.add_argument("--range"); m.add_argument("--fresh-clone", dest="fresh_clone", action="store_true")
5058
+ m.add_argument("--json", action="store_true")
5059
+ m = msub.add_parser("show"); m.add_argument("id"); m.add_argument("--md", action="store_true")
5060
+ m.add_argument("--json", action="store_true"); m.add_argument("--entry")
5061
+ m = msub.add_parser("search"); m.add_argument("query", nargs="?", default="")
5062
+ for flag in ("--kind", "--by", "--file", "--task", "--since", "--until"):
5063
+ m.add_argument(flag)
5064
+ m.add_argument("--limit", type=int, default=20); m.add_argument("--offset", type=int, default=0)
5065
+ m.add_argument("--json", action="store_true")
5066
+ m = msub.add_parser("risk"); rsub = m.add_subparsers(dest="risk_action", required=True)
5067
+ r = rsub.add_parser("add"); r.add_argument("--title", required=True); r.add_argument("--where", default="")
5068
+ r.add_argument("--label", required=True); r.add_argument("--what", required=True)
5069
+ r.add_argument("--likely"); r.add_argument("--fix"); r.add_argument("--status", default="open")
5070
+ r = rsub.add_parser("close"); r.add_argument("rkey"); r.add_argument("--status", required=True)
5071
+ r.add_argument("--why", required=True)
5072
+ m = msub.add_parser("risks"); m.add_argument("--status", choices=["open", "ticketed", "accepted", "fixed", "all"],
5073
+ default="open")
5074
+ m.add_argument("--md", action="store_true"); m.add_argument("--json", action="store_true")
5075
+ m = msub.add_parser("migrate"); g = m.add_mutually_exclusive_group(required=True)
5076
+ g.add_argument("--report", action="store_true"); g.add_argument("--apply", action="store_true")
5077
+ g.add_argument("--move-logs", dest="move_logs", action="store_true")
5078
+ m.add_argument("--risks", action="store_true"); m.add_argument("--confirm-delete", dest="confirm_delete")
5079
+ msub.add_parser("rebuild")
5080
+ m = msub.add_parser("cleanup"); m.add_argument("ids", nargs="*"); m.add_argument("--all", action="store_true")
5081
+ m.add_argument("--confirm-delete", dest="confirm_delete")
5082
+ m = msub.add_parser("restore"); m.add_argument("ids", nargs="*"); m.add_argument("--all", action="store_true")
5083
+ m = msub.add_parser("pack"); m.add_argument("--period", required=True)
5084
+ m.add_argument("--confirm-delete", dest="confirm_delete")
5085
+ m = msub.add_parser("redact"); m.add_argument("rid"); m.add_argument("--field", required=True)
5086
+ m.add_argument("--why", required=True)
5087
+
5088
+
4183
5089
  def main():
4184
5090
  parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
4185
5091
  parser.add_argument("--root", default=".", help="project directory (default: search upwards for .ai/)")
@@ -4256,9 +5162,21 @@ def main():
4256
5162
  p.add_argument("--tests", default=""); p.add_argument("--declare", action="append", default=[])
4257
5163
  p.add_argument("--mode"); p.add_argument("--by", default=""); p.add_argument("--why", default="")
4258
5164
  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")
5165
+ p.add_argument("--task-id", help="an id of your own; a legacy T-YYYY-MM-DD-NNN id is still accepted")
5166
+ p.add_argument("--slug", help="3-24 characters of [a-z0-9] words joined by '-': the id becomes "
5167
+ "T-YYYY-MM-DD-HHMM-<slug> (UTC). Default: derived from --goal")
5168
+ p.add_argument("--amend", action="store_true")
5169
+ p.add_argument("--batch", action="store_true", help="the batch loop of /rr-build-all (needs --by)")
5170
+ p.add_argument("--no-batch", dest="no_batch", action="store_true", help="opt out of weight.json batch.default")
4260
5171
  p.set_defaults(func=cmd_start)
4261
5172
 
5173
+ p = sub.add_parser("steps"); p.add_argument("--ready", action="store_true")
5174
+ p.add_argument("--json", action="store_true"); p.set_defaults(func=cmd_steps)
5175
+
5176
+ p = sub.add_parser("batch"); p.add_argument("action", choices=["on", "off"])
5177
+ p.add_argument("--by", default=""); p.add_argument("--why", default="")
5178
+ p.set_defaults(func=cmd_batch)
5179
+
4262
5180
  p = sub.add_parser("verify")
4263
5181
  p.add_argument("--scope", required=True, choices=["step", "suite", "e2e", "all"])
4264
5182
  p.add_argument("--files", default=""); p.add_argument("--expect", choices=["fail"])
@@ -4282,6 +5200,8 @@ def main():
4282
5200
  p = sub.add_parser("consent"); p.add_argument("action", nargs="?", choices=["audit"])
4283
5201
  p.add_argument("--json", action="store_true"); p.set_defaults(func=cmd_consent)
4284
5202
 
5203
+ memory_parsers(sub)
5204
+
4285
5205
  global RUNTIME, MUTATING # pylint: disable=global-statement
4286
5206
  args = parser.parse_args()
4287
5207
  if getattr(args, "topic", None):