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