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