riskrails 3.0.1 → 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 +1 -1
- package/README.md +28 -15
- 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 +27 -30
- package/bin/riskrails.js +5 -5
- package/hooks/context-guard.py +1 -1
- package/hooks/guard/common.py +2 -0
- package/hooks/guard/paths.py +41 -30
- package/hooks/project-scaffold.py +1 -5
- package/hooks/runtime-gate.py +6 -6
- package/hooks/update_notice.py +20 -14
- package/instructions/routing.md +1 -1
- package/instructions/stub.md +3 -3
- package/package.json +1 -1
- package/skills/AGENTS.md +1 -1
- package/skills/CLAUDE.md +1 -1
- package/skills/ai-init/templates/.ai/AGENTS.md +1 -1
- package/skills/ai-init/templates/.ai/VERSION +1 -1
- 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/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/sensors.py +24 -4
- package/skills/ai-task/state.py +37 -14
- 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/18c7a2753ce9cb6f6d3d1e9d5d1125d75950b879d56b7325225b200869631b29 +15 -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/5a93f78b4ed4d416c171139b2ce610a117f9c1dd46fbb56ca86729cb4c53d10c +6 -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/7f063e79195a5c85c37b7260dd20c184d3b9bea85e37202e0bbca73ad84bba49 +13 -0
- package/skills/project-update/history/blobs/c8573ca1f810ce6c4ac49456a4c1725b93e0d3b74c54ce95d46d8747777895f0 +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/index.json +36 -15
- package/skills/project-update/migrations/0011_retire_risk_tiers.py +37 -0
- package/skills/project-update/migrations/__init__.py +6 -1
- package/skills/project-update/render_instructions.py +1 -1
- package/skills/project-update/update.py +374 -87
- package/skills/rr-build-all/SKILL.md +2 -2
- package/skills/rr-plan/SKILL.md +3 -1
- package/skills/rr-review/SKILL.md +13 -8
- package/skills/rr-setup/SKILL.md +15 -2
- package/skills/rr-setup/setup.py +7 -1
- package/skills/rr-spec/SKILL.md +4 -2
- package/skills/usage-report/usage-report.py +4 -2
- package/skills/ai-init/templates/.ai/policies/risk-tiers.json +0 -628
- package/skills/ai-init/templates/.ai/policies/risk-tiers.md +0 -180
|
@@ -20,12 +20,17 @@ Exit codes:
|
|
|
20
20
|
5 refused: --confirm-delete without a human (ADOPT_REFUSED on stdout line 1)
|
|
21
21
|
|
|
22
22
|
What it manages, and how:
|
|
23
|
-
.ai/** the project owns (
|
|
23
|
+
.ai/** the project owns (weight, path-guard, three-way update
|
|
24
24
|
testing, coding, safety, security, AGENTS.md)
|
|
25
25
|
.ai/** the plugin shares (the other policies, read from the plugin's shared tree; a project
|
|
26
26
|
agents, workflows, templates) copy is an override and is never merged — an
|
|
27
27
|
unedited one is offered for deletion
|
|
28
|
-
docs/sdlc/*/TEMPLATE.md
|
|
28
|
+
docs/sdlc/*/TEMPLATE.md the plugin supplies them (read in place); an
|
|
29
|
+
unedited copy is offered for deletion, an
|
|
30
|
+
edited one is a project override and stays
|
|
31
|
+
.ai/reports/<task>/, .ai/state/logs/<task>/ proposals only, never run on their own: the
|
|
32
|
+
of closed, abandoned or long-idle tasks logs of a closed task, every file of an
|
|
33
|
+
abandoned or STALE_DAYS-idle unclosed one
|
|
29
34
|
.claude/memory/README.md, .codex/memory/README.md three-way update, per runtime the project uses
|
|
30
35
|
the managed block in CLAUDE.md and/or AGENTS.md three-way update of the block only
|
|
31
36
|
.gitignore missing entries appended
|
|
@@ -41,12 +46,12 @@ Three-way update: a file whose content is a version this plugin once shipped was
|
|
|
41
46
|
never edited, so it is replaced. An edited file is merged against the shipped
|
|
42
47
|
version closest to it (git merge-file for text, per key for JSON), keeping every
|
|
43
48
|
edit. A real conflict leaves the project file alone and puts the plugin's version
|
|
44
|
-
in a git-ignored local directory for a human or /
|
|
49
|
+
in a git-ignored local directory for a human or /rr-setup --update to merge.
|
|
45
50
|
"""
|
|
46
51
|
import argparse, contextlib, copy, difflib, hashlib, importlib.util, io, json, os, re, subprocess, sys, tempfile
|
|
47
52
|
from collections import OrderedDict
|
|
48
53
|
from types import SimpleNamespace
|
|
49
|
-
from datetime import datetime, timezone
|
|
54
|
+
from datetime import datetime, timedelta, timezone
|
|
50
55
|
|
|
51
56
|
sys.dont_write_bytecode = True # never leave __pycache__ in an installed plugin
|
|
52
57
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
@@ -80,6 +85,7 @@ ROUTER_FILE = ".ai/AGENTS.md"
|
|
|
80
85
|
# section, or doubles it, is a conflict for a human.
|
|
81
86
|
SHARED_SECTION_RE = re.compile(rb"(?m)^## Shared files[ \t]*\r?$")
|
|
82
87
|
SHARED_SCHEMA = 6 # the schema from which shared copies are overrides (0006)
|
|
88
|
+
STALE_DAYS = 7 # an unclosed task whose last event is older is proposed for cleanup
|
|
83
89
|
STATE_FILE = ".ai/state/state.json"
|
|
84
90
|
LEGACY_STATE_FILE = ".ai/state/current.json" # R34: the name until migration 0009 moves it
|
|
85
91
|
|
|
@@ -119,14 +125,14 @@ def renamed_markers(block):
|
|
|
119
125
|
find_block = render_instructions.find_block
|
|
120
126
|
current_markers = render_instructions.current_markers
|
|
121
127
|
MISSING = object()
|
|
122
|
-
HASH_RE = re.compile(rb"sha256:[0-9a-f]{64}")
|
|
123
128
|
|
|
124
129
|
PROJECT_INIT_MAP = [ # template -> target, kind
|
|
125
130
|
("sdlc-README.md", "docs/sdlc/README.md", "update"),
|
|
126
|
-
|
|
127
|
-
("
|
|
128
|
-
("
|
|
129
|
-
("
|
|
131
|
+
# read in place by /rr-spec and /rr-plan; a project copy is an override: never created
|
|
132
|
+
("intent.md", "docs/sdlc/intent/TEMPLATE.md", "retire"),
|
|
133
|
+
("spec.md", "docs/sdlc/specs/TEMPLATE.md", "retire"),
|
|
134
|
+
("plan.md", "docs/sdlc/plans/TEMPLATE.md", "retire"),
|
|
135
|
+
("adr.md", "docs/sdlc/adr/TEMPLATE.md", "retire"),
|
|
130
136
|
# the project's own principles: created once, then it is the project's file
|
|
131
137
|
("constitution.md", "docs/sdlc/constitution.md", "create"),
|
|
132
138
|
]
|
|
@@ -302,6 +308,53 @@ def git_dirty_ai(root):
|
|
|
302
308
|
return res.returncode == 0 and bool(res.stdout.strip())
|
|
303
309
|
|
|
304
310
|
|
|
311
|
+
def git_states(root, rel_dir):
|
|
312
|
+
"""A function path -> how git holds a file under `rel_dir` (project-relative):
|
|
313
|
+
"tracked" (committed and unchanged), "uncommitted" (changed, staged or not yet
|
|
314
|
+
tracked, so git has no copy to restore) or "ignored"; None outside a work tree
|
|
315
|
+
or for a path git does not know."""
|
|
316
|
+
def run(*args):
|
|
317
|
+
res = subprocess.run(["git", "-C", root] + list(args), stdout=subprocess.PIPE,
|
|
318
|
+
stderr=subprocess.DEVNULL, timeout=30)
|
|
319
|
+
return res.stdout.decode("utf-8", "replace") if res.returncode == 0 else None
|
|
320
|
+
try:
|
|
321
|
+
prefix = run("rev-parse", "--show-prefix")
|
|
322
|
+
tracked = run("ls-files", "-z", "--", rel_dir)
|
|
323
|
+
status = run("status", "--porcelain=v1", "-z", "--ignored", "-uall", "--", rel_dir)
|
|
324
|
+
except (OSError, subprocess.SubprocessError):
|
|
325
|
+
return lambda path: None
|
|
326
|
+
if prefix is None or tracked is None or status is None:
|
|
327
|
+
return lambda path: None
|
|
328
|
+
prefix = prefix.strip()
|
|
329
|
+
tracked = set(tracked.split("\0"))
|
|
330
|
+
dirty, ignored = set(), []
|
|
331
|
+
entries = status.split("\0")
|
|
332
|
+
skip = False
|
|
333
|
+
for entry in entries:
|
|
334
|
+
if skip: # the origin path of a rename
|
|
335
|
+
skip = False
|
|
336
|
+
continue
|
|
337
|
+
if len(entry) < 4:
|
|
338
|
+
continue
|
|
339
|
+
xy, path = entry[:2], entry[3:]
|
|
340
|
+
if prefix and path.startswith(prefix):
|
|
341
|
+
path = path[len(prefix):]
|
|
342
|
+
if "R" in xy or "C" in xy:
|
|
343
|
+
skip = True
|
|
344
|
+
if xy == "!!":
|
|
345
|
+
ignored.append(path)
|
|
346
|
+
else:
|
|
347
|
+
dirty.add(path)
|
|
348
|
+
|
|
349
|
+
def classify(path):
|
|
350
|
+
if path in dirty:
|
|
351
|
+
return "uncommitted"
|
|
352
|
+
if any(path == i or (i.endswith("/") and path.startswith(i)) for i in ignored):
|
|
353
|
+
return "ignored"
|
|
354
|
+
return "tracked" if path in tracked else None
|
|
355
|
+
return classify
|
|
356
|
+
|
|
357
|
+
|
|
305
358
|
# ---------------------------------------------------------------- merging
|
|
306
359
|
def closest(candidates, ours, score):
|
|
307
360
|
best, best_score = None, -1.0
|
|
@@ -407,6 +460,22 @@ def detect_version(plan):
|
|
|
407
460
|
return int(text)
|
|
408
461
|
|
|
409
462
|
|
|
463
|
+
def unsafe_path(root, path):
|
|
464
|
+
"""Why a project-relative path must not be deleted: a symlink on the way to it,
|
|
465
|
+
or a real path outside the project; None when it is safe. Run at plan time and
|
|
466
|
+
again right before the delete."""
|
|
467
|
+
real_root = os.path.realpath(root)
|
|
468
|
+
here = root
|
|
469
|
+
for part in path.split("/"):
|
|
470
|
+
here = os.path.join(here, part)
|
|
471
|
+
if os.path.islink(here):
|
|
472
|
+
return "%s is a symlink" % os.path.relpath(here, root).replace(os.sep, "/")
|
|
473
|
+
real = os.path.realpath(os.path.join(root, path))
|
|
474
|
+
if real != real_root and not real.startswith(real_root + os.sep):
|
|
475
|
+
return "%s resolves outside the project (%s)" % (path, real)
|
|
476
|
+
return None
|
|
477
|
+
|
|
478
|
+
|
|
410
479
|
class MigrationContext:
|
|
411
480
|
"""What a migration sees: the project as this run leaves it, not the disk."""
|
|
412
481
|
|
|
@@ -523,16 +592,7 @@ class MigrationContext:
|
|
|
523
592
|
"""Why a file must not be deleted through this path, or None: it is a
|
|
524
593
|
symlink, it is reached through one, or it resolves outside the project."""
|
|
525
594
|
self._check_path(path)
|
|
526
|
-
|
|
527
|
-
here = self.root
|
|
528
|
-
for part in path.split("/"):
|
|
529
|
-
here = os.path.join(here, part)
|
|
530
|
-
if os.path.islink(here):
|
|
531
|
-
return "%s is a symlink" % os.path.relpath(here, self.root).replace(os.sep, "/")
|
|
532
|
-
real = os.path.realpath(os.path.join(self.root, path))
|
|
533
|
-
if real != root and not real.startswith(root + os.sep):
|
|
534
|
-
return "%s resolves outside the project (%s)" % (path, real)
|
|
535
|
-
return None
|
|
595
|
+
return unsafe_path(self.root, path)
|
|
536
596
|
|
|
537
597
|
def override_note(self, path):
|
|
538
598
|
"""What an edited copy of a shared file now means."""
|
|
@@ -710,11 +770,14 @@ class Plan:
|
|
|
710
770
|
self.schema = None # (from, to, [migration modules]) when migrations are pending
|
|
711
771
|
self.confirm_delete = None # the human who confirmed this run's deletions
|
|
712
772
|
self.confirm_via = None # why that gate passed: "terminal" or "unattended"
|
|
773
|
+
self.confirm_cleanup = None # the human who confirmed this run's [cleanup] deletions
|
|
774
|
+
self.cleanup_via = None
|
|
713
775
|
self.held = None # the schema version kept until a migration item is settled
|
|
714
776
|
self.final = {} # target -> bytes after this run (for cross-file fixes)
|
|
715
777
|
self.removed = set() # targets this run takes away
|
|
716
778
|
self.seen = {} # target -> sha of what was on disk when it was first read,
|
|
717
779
|
# or None when nothing was there: apply's precondition
|
|
780
|
+
self.git_kept = set() # [cleanup] targets git holds or should hold: a hint and an original/ copy
|
|
718
781
|
self.report_dir = ".ai/reports/project-update-%s" % datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
719
782
|
|
|
720
783
|
# The project as this run leaves it: planned content first, then the disk.
|
|
@@ -765,17 +828,60 @@ class Plan:
|
|
|
765
828
|
return base
|
|
766
829
|
|
|
767
830
|
|
|
831
|
+
WEIGHT_JSON, WEIGHT_MD = ".ai/policies/weight.json", ".ai/policies/weight.md"
|
|
832
|
+
_WEIGHT = None
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def weight_module():
|
|
836
|
+
"""skills/ai-task/weight.py, the renderer behind `state.py weight --mirror`."""
|
|
837
|
+
global _WEIGHT # pylint: disable=global-statement # loaded once per run
|
|
838
|
+
if _WEIGHT is None:
|
|
839
|
+
spec = importlib.util.spec_from_file_location("riskrails_weight", os.path.join(SKILLS, "ai-task", "weight.py"))
|
|
840
|
+
_WEIGHT = importlib.util.module_from_spec(spec)
|
|
841
|
+
spec.loader.exec_module(_WEIGHT)
|
|
842
|
+
return _WEIGHT
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def render_weight(data):
|
|
846
|
+
"""weight.md for the weight.json bytes `data`; the renderer reads a path."""
|
|
847
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
848
|
+
path = os.path.join(tmp, "weight.json")
|
|
849
|
+
with open(path, "wb") as fh:
|
|
850
|
+
fh.write(data)
|
|
851
|
+
return weight_module().mirror(path).encode("utf-8")
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def weight_mirror(plan, history, theirs):
|
|
855
|
+
"""weight.md is generated from weight.json. A mirror that is exactly the render of
|
|
856
|
+
the project's JSON on disk is rendered again from the JSON this run leaves behind:
|
|
857
|
+
merged as text with the template's, whose sha256 line names the template's JSON, it
|
|
858
|
+
was a conflict on every update. A mirror edited by hand, or stale, merges as before."""
|
|
859
|
+
ours = plan.read(WEIGHT_MD)
|
|
860
|
+
path = os.path.join(plan.root, WEIGHT_JSON)
|
|
861
|
+
disk = read(path) if os.path.isfile(path) and not os.path.islink(path) else None
|
|
862
|
+
after = plan.read(WEIGHT_JSON)
|
|
863
|
+
fresh = None
|
|
864
|
+
if ours is not None and disk is not None and after is not None:
|
|
865
|
+
try:
|
|
866
|
+
if ours == render_weight(disk):
|
|
867
|
+
fresh = render_weight(after)
|
|
868
|
+
except (ValueError, TypeError, AttributeError, KeyError):
|
|
869
|
+
fresh = None # not a table the renderer reads: the text merge decides
|
|
870
|
+
if fresh is None:
|
|
871
|
+
three_way(plan, history, "ai-init/" + WEIGHT_MD, WEIGHT_MD, theirs)
|
|
872
|
+
elif fresh != ours:
|
|
873
|
+
plan.add("update", WEIGHT_MD, "rendered from weight.json", content=fresh)
|
|
874
|
+
|
|
875
|
+
|
|
768
876
|
def three_way(plan, history, key, target, theirs):
|
|
769
877
|
ours = plan.read(target)
|
|
770
878
|
if ours is None:
|
|
771
879
|
plan.add("create", target, content=theirs)
|
|
772
880
|
return
|
|
773
|
-
|
|
774
|
-
norm = (lambda b: HASH_RE.sub(b"sha256:" + b"0" * 64, b)) if is_mirror else (lambda b: b)
|
|
775
|
-
if norm(ours) == norm(theirs):
|
|
881
|
+
if ours == theirs:
|
|
776
882
|
return
|
|
777
|
-
versions = [v for v in history.versions(key) if
|
|
778
|
-
if any(
|
|
883
|
+
versions = [v for v in history.versions(key) if v != theirs]
|
|
884
|
+
if any(v == ours for v in versions):
|
|
779
885
|
policy = None
|
|
780
886
|
if target.endswith(".json") and "/policies/" in "/" + target:
|
|
781
887
|
try:
|
|
@@ -784,7 +890,7 @@ def three_way(plan, history, key, target, theirs):
|
|
|
784
890
|
except ValueError:
|
|
785
891
|
policy = None
|
|
786
892
|
plan.add("update", target, "unchanged since it was installed",
|
|
787
|
-
content=
|
|
893
|
+
content=theirs, policy=policy)
|
|
788
894
|
return
|
|
789
895
|
|
|
790
896
|
if target.endswith(".json"):
|
|
@@ -805,28 +911,21 @@ def three_way(plan, history, key, target, theirs):
|
|
|
805
911
|
plan.add("merge", target, "your edits kept", content=dump_json(merged), policy=policy)
|
|
806
912
|
if conflicts:
|
|
807
913
|
plan.add("conflict", target, "kept your value for: " + ", ".join(conflicts), conflict_copy=theirs)
|
|
808
|
-
model_keys = [c for c in conflicts if c.split(".")[0] == "model_tiers" or ".review_model" in "." + c]
|
|
809
|
-
if target.endswith("policies/risk-tiers.json") and model_keys:
|
|
810
|
-
hint = ("%s: %s — the plugin now names tiers (FAST/BALANCED/STRONG/EXPERT), not models; "
|
|
811
|
-
"the model of a tier comes from the installed plan: state.py profile --tier <TIER>"
|
|
812
|
-
% (target, ", ".join(model_keys)))
|
|
813
|
-
if hint not in plan.hints:
|
|
814
|
-
plan.hints.append(hint)
|
|
815
914
|
return
|
|
816
915
|
|
|
817
916
|
if target == ROUTER_FILE and len(SHARED_SECTION_RE.findall(ours)) == 1:
|
|
818
917
|
router_merge(plan, ours, versions, theirs)
|
|
819
918
|
return
|
|
820
|
-
base = closest(versions,
|
|
821
|
-
merged = merge_text(
|
|
919
|
+
base = closest(versions, ours, text_ratio) if versions else None
|
|
920
|
+
merged = merge_text(ours, base, theirs) if base is not None else None
|
|
822
921
|
if merged is not None and target == ROUTER_FILE and len(SHARED_SECTION_RE.findall(merged)) != 1:
|
|
823
922
|
plan.add("conflict", target, "the merge would not name the shared files exactly once "
|
|
824
923
|
"(## Shared files)", conflict_copy=theirs)
|
|
825
924
|
return
|
|
826
925
|
if merged is None:
|
|
827
926
|
plan.add("conflict", target, "edited here and changed in the plugin", conflict_copy=theirs)
|
|
828
|
-
elif merged !=
|
|
829
|
-
plan.add("merge", target, "your edits kept", content=
|
|
927
|
+
elif merged != ours:
|
|
928
|
+
plan.add("merge", target, "your edits kept", content=merged)
|
|
830
929
|
|
|
831
930
|
|
|
832
931
|
def split_shared_section(text):
|
|
@@ -838,8 +937,9 @@ def split_shared_section(text):
|
|
|
838
937
|
return None
|
|
839
938
|
start = found[0].start()
|
|
840
939
|
nxt = re.compile(rb"(?m)^## ").search(text, found[0].end())
|
|
841
|
-
if nxt is None:
|
|
842
|
-
|
|
940
|
+
if nxt is None: # at the end: the blank line before it belongs to the section
|
|
941
|
+
head = text[:start]
|
|
942
|
+
return (head[:-1] if head.endswith(b"\n\n") else head), text[start:], b""
|
|
843
943
|
end = nxt.start()
|
|
844
944
|
eol = text.find(b"\n", end)
|
|
845
945
|
follow = text[end:eol + 1] if eol >= 0 else text[end:]
|
|
@@ -863,7 +963,9 @@ def router_merge(plan, ours, versions, theirs):
|
|
|
863
963
|
if o_rest == t_rest or (base is not None and base == t_rest):
|
|
864
964
|
return
|
|
865
965
|
merged = merge_text(o_rest, base, t_rest) if base is not None else None
|
|
866
|
-
if merged is not None:
|
|
966
|
+
if merged is not None and not follow: # the project had it at the end: it stays there
|
|
967
|
+
merged = merged + (b"" if merged.endswith(b"\n") else b"\n") + b"\n" + o_sec
|
|
968
|
+
elif merged is not None:
|
|
867
969
|
merged = put_section(merged, o_sec, [follow, t_split[2] if t_split else b""])
|
|
868
970
|
if merged is None or len(SHARED_SECTION_RE.findall(merged)) != 1:
|
|
869
971
|
plan.add("conflict", ROUTER_FILE, "edited here and changed in the plugin", conflict_copy=theirs)
|
|
@@ -886,27 +988,6 @@ def put_section(text, section, follows):
|
|
|
886
988
|
return text + (b"" if text.endswith(b"\n") else b"\n") + section
|
|
887
989
|
|
|
888
990
|
|
|
889
|
-
def fix_mirror(plan, target, content, ours):
|
|
890
|
-
"""risk-tiers.md carries the sha256 of risk-tiers.json. Carry the project's sync
|
|
891
|
-
state forward: a mirror that matched its JSON before this run points at the JSON
|
|
892
|
-
this run leaves behind; a mirror that was already stale, or a JSON left with a
|
|
893
|
-
conflict, keeps its old hash so /ai-status keeps warning."""
|
|
894
|
-
if not target.endswith(".ai/policies/risk-tiers.md"):
|
|
895
|
-
return content
|
|
896
|
-
old = HASH_RE.search(ours)
|
|
897
|
-
# The disk, not the plan: the question is whether the mirror matched the JSON
|
|
898
|
-
# the project had before this run.
|
|
899
|
-
json_target = target[:-3] + ".json"
|
|
900
|
-
json_path = os.path.join(plan.root, json_target)
|
|
901
|
-
before = read(json_path) if os.path.exists(json_path) else None
|
|
902
|
-
in_sync = old is not None and before is not None and old.group(0) == b"sha256:" + sha(before).encode()
|
|
903
|
-
conflicted = any(i["action"] == "conflict" and i["target"] == json_target for i in plan.items)
|
|
904
|
-
after = plan.final.get(json_target, None if json_target in plan.removed else before)
|
|
905
|
-
if in_sync and not conflicted and after is not None:
|
|
906
|
-
return HASH_RE.sub(b"sha256:" + sha(after).encode(), content, count=1)
|
|
907
|
-
return HASH_RE.sub(old.group(0), content, count=1) if old else content
|
|
908
|
-
|
|
909
|
-
|
|
910
991
|
def block_update(plan, history, runtime):
|
|
911
992
|
name, block_tpl, minimal_tpl = INSTRUCTION_FILE[runtime]
|
|
912
993
|
if name in plan.removed: # a migration moved this instruction file away
|
|
@@ -985,7 +1066,7 @@ def shared_update(plan, history, version):
|
|
|
985
1066
|
edited.append(path)
|
|
986
1067
|
elif not named:
|
|
987
1068
|
ctx.hint("%s: unedited, but %s does not name the shared files (## Shared files), so the "
|
|
988
|
-
"copy stays — restore that section, then run /
|
|
1069
|
+
"copy stays — restore that section, then run /rr-setup --update again"
|
|
989
1070
|
% (path, ROUTER_FILE))
|
|
990
1071
|
else:
|
|
991
1072
|
candidates.append(path)
|
|
@@ -1008,9 +1089,145 @@ def shared_update(plan, history, version):
|
|
|
1008
1089
|
"adds nothing" % (len(edited), ", ".join(edited), SHARED))
|
|
1009
1090
|
|
|
1010
1091
|
|
|
1092
|
+
def retire_templates(plan, history, retired):
|
|
1093
|
+
"""[(history key, target)] the plugin now supplies from its own tree. A
|
|
1094
|
+
missing copy stays missing; an unedited one is offered for deletion, same
|
|
1095
|
+
gate as a shared override; an edited one is a project override, named once."""
|
|
1096
|
+
edited = []
|
|
1097
|
+
for key, target in retired:
|
|
1098
|
+
data = plan.read(target)
|
|
1099
|
+
if data is None:
|
|
1100
|
+
continue
|
|
1101
|
+
known = history.hashes(key)
|
|
1102
|
+
if sha(data) not in known and sha(data.replace(b"\r\n", b"\n")) not in known:
|
|
1103
|
+
edited.append(target)
|
|
1104
|
+
continue
|
|
1105
|
+
why = "[sdlc] the plugin supplies it: skills/project-init/templates/" + key.split("/", 1)[1]
|
|
1106
|
+
if plan.confirm_delete:
|
|
1107
|
+
plan.add("delete", target, why, reason=why)
|
|
1108
|
+
plan.remove(target)
|
|
1109
|
+
else:
|
|
1110
|
+
plan.add("delete?", target, why, reason=why)
|
|
1111
|
+
if edited:
|
|
1112
|
+
plan.hints.append("%d file(s) override the plugin's SDLC template and no longer receive its "
|
|
1113
|
+
"changes: %s" % (len(edited), ", ".join(edited)))
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def task_journal(root, task_id):
|
|
1117
|
+
"""(finished, abandoned, last event as a datetime) from the task's journal,
|
|
1118
|
+
or None when it holds no readable event. A task is finished when its journal
|
|
1119
|
+
has task_closed or gate_approved, or when logs/<id>/state.json records a
|
|
1120
|
+
result (so a torn task_closed line does not make a closed task look unclosed)."""
|
|
1121
|
+
closed = abandoned = False
|
|
1122
|
+
last = None
|
|
1123
|
+
try:
|
|
1124
|
+
with open(os.path.join(root, ".ai", "reports", task_id, "events.jsonl"), encoding="utf-8") as fh:
|
|
1125
|
+
lines = fh.read().splitlines()
|
|
1126
|
+
except (OSError, UnicodeDecodeError):
|
|
1127
|
+
return None
|
|
1128
|
+
for line in lines:
|
|
1129
|
+
try:
|
|
1130
|
+
ev = json.loads(line)
|
|
1131
|
+
when = datetime.strptime(ev["ts"][:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
|
|
1132
|
+
except (ValueError, KeyError, TypeError):
|
|
1133
|
+
continue
|
|
1134
|
+
if last is None or when > last:
|
|
1135
|
+
last = when
|
|
1136
|
+
if ev.get("event") == "task_closed":
|
|
1137
|
+
closed = True
|
|
1138
|
+
data = ev.get("data")
|
|
1139
|
+
abandoned = (isinstance(data, dict) and data.get("abandoned") is True) or ev.get("detail") == "abandoned"
|
|
1140
|
+
elif ev.get("event") == "gate_approved":
|
|
1141
|
+
closed = True
|
|
1142
|
+
try:
|
|
1143
|
+
with open(os.path.join(root, ".ai", "state", "logs", task_id, "state.json"), encoding="utf-8") as fh:
|
|
1144
|
+
result = json.load(fh).get("result")
|
|
1145
|
+
except (OSError, ValueError, AttributeError, UnicodeDecodeError):
|
|
1146
|
+
result = None
|
|
1147
|
+
if isinstance(result, str) and result:
|
|
1148
|
+
closed = True
|
|
1149
|
+
abandoned = abandoned or result == "abandoned"
|
|
1150
|
+
return None if last is None else (closed, abandoned, last)
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
def cleanup_update(plan, now=None):
|
|
1154
|
+
"""Old run logs and stale unclosed tasks, proposed for deletion and never
|
|
1155
|
+
deleted unasked (only with --confirm-cleanup). A closed task's `*.log` files
|
|
1156
|
+
go (its state.json stays); an abandoned task loses every file under
|
|
1157
|
+
.ai/reports/<id>/ and .ai/state/logs/<id>/ except that state.json; an unclosed
|
|
1158
|
+
one idle for STALE_DAYS loses every file in both. The task in flight is
|
|
1159
|
+
never touched. `now` is a datetime; RISKRAILS_NOW (ISO, Z) overrides it."""
|
|
1160
|
+
if now is None:
|
|
1161
|
+
try:
|
|
1162
|
+
now = datetime.strptime(env("NOW") or utc_now(), "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
|
1163
|
+
except ValueError:
|
|
1164
|
+
print("project-update: RISKRAILS_NOW %r is not a UTC time like 2026-01-31T12:00:00Z" % env("NOW"),
|
|
1165
|
+
file=sys.stderr)
|
|
1166
|
+
raise SystemExit(2)
|
|
1167
|
+
flight = None
|
|
1168
|
+
for name in (STATE_FILE, LEGACY_STATE_FILE):
|
|
1169
|
+
raw = plan.read(name)
|
|
1170
|
+
if raw is not None:
|
|
1171
|
+
try:
|
|
1172
|
+
flight = json.loads(raw).get("task_id")
|
|
1173
|
+
except (ValueError, AttributeError):
|
|
1174
|
+
return # unreadable: a task may be in flight, so no task is proposed as stale
|
|
1175
|
+
break
|
|
1176
|
+
reports =os.path.join(plan.root, ".ai", "reports")
|
|
1177
|
+
logs = os.path.join(plan.root, ".ai", "state", "logs")
|
|
1178
|
+
|
|
1179
|
+
def files(base, rel, only_logs=False, keep=()):
|
|
1180
|
+
out = []
|
|
1181
|
+
for here, _dirs, names in os.walk(os.path.join(base, rel)):
|
|
1182
|
+
for n in names:
|
|
1183
|
+
full = os.path.join(here, n)
|
|
1184
|
+
if os.path.isfile(full) and not os.path.islink(full) and (not only_logs or n.endswith(".log")) \
|
|
1185
|
+
and n not in keep:
|
|
1186
|
+
out.append(os.path.relpath(full, plan.root).replace(os.sep, "/"))
|
|
1187
|
+
return out
|
|
1188
|
+
|
|
1189
|
+
for task_id in sorted(os.listdir(reports)) if os.path.isdir(reports) else []:
|
|
1190
|
+
journal = task_journal(plan.root, task_id)
|
|
1191
|
+
if journal is None or task_id == flight:
|
|
1192
|
+
continue
|
|
1193
|
+
closed, abandoned, last = journal
|
|
1194
|
+
if abandoned:
|
|
1195
|
+
why, targets = "[cleanup] abandoned task %s" % task_id, \
|
|
1196
|
+
files(reports, task_id) + files(logs, task_id, keep=("state.json",))
|
|
1197
|
+
elif not closed and now - last > timedelta(days=STALE_DAYS):
|
|
1198
|
+
why = "[cleanup] unclosed task %s, last event %s" % (task_id, last.strftime("%Y-%m-%d"))
|
|
1199
|
+
targets = files(reports, task_id) + files(logs, task_id)
|
|
1200
|
+
elif closed:
|
|
1201
|
+
why, targets = "[cleanup] log of closed task %s" % task_id, files(logs, task_id, only_logs=True)
|
|
1202
|
+
else:
|
|
1203
|
+
continue
|
|
1204
|
+
# .ai/reports/ is usually git-tracked; only .ai/state/ is git-ignored run state
|
|
1205
|
+
held = git_states(plan.root, ".ai/reports/" + task_id)
|
|
1206
|
+
for target in sorted(targets):
|
|
1207
|
+
note = why
|
|
1208
|
+
state = held(target) if target.startswith(".ai/reports/") else None
|
|
1209
|
+
if state == "tracked":
|
|
1210
|
+
note += "; tracked: git can restore a committed copy"
|
|
1211
|
+
elif state == "uncommitted":
|
|
1212
|
+
note += "; uncommitted: git holds no copy of it"
|
|
1213
|
+
elif state == "ignored":
|
|
1214
|
+
note += "; git-ignored: not recoverable through git"
|
|
1215
|
+
if state in ("tracked", "uncommitted"):
|
|
1216
|
+
plan.git_kept.add(target)
|
|
1217
|
+
if plan.confirm_cleanup:
|
|
1218
|
+
plan.add("delete", target, note, reason=why)
|
|
1219
|
+
plan.remove(target)
|
|
1220
|
+
else:
|
|
1221
|
+
plan.add("delete?", target, note, reason=why)
|
|
1222
|
+
|
|
1223
|
+
|
|
1011
1224
|
def deletion_hint(plan):
|
|
1012
|
-
"""M4: git is the rollback of a deletion; it must hold .ai/ first.
|
|
1013
|
-
|
|
1225
|
+
"""M4: git is the rollback of a migration's deletion; it must hold .ai/ first.
|
|
1226
|
+
A [cleanup] deletion of git-ignored run state is not counted: git does not roll it back;
|
|
1227
|
+
one under .ai/reports/ that git tracks, or should, is."""
|
|
1228
|
+
if any(i["action"] in ("delete?", "delete") and i["target"].startswith(".ai/")
|
|
1229
|
+
and (not (i.get("reason") or "").startswith("[cleanup]") or i["target"] in plan.git_kept)
|
|
1230
|
+
for i in plan.items) \
|
|
1014
1231
|
and git_dirty_ai(plan.root):
|
|
1015
1232
|
plan.hints.append("commit .ai/ first: git is this migration's rollback "
|
|
1016
1233
|
"(`git status -- .ai` lists what it does not hold yet)")
|
|
@@ -1198,8 +1415,10 @@ def constitution_hint(plan):
|
|
|
1198
1415
|
plan.hints.append(line.replace(path, rel))
|
|
1199
1416
|
|
|
1200
1417
|
|
|
1201
|
-
def build_plan(root, confirm_delete=None, confirm_via=None):
|
|
1418
|
+
def build_plan(root, confirm_delete=None, confirm_via=None, confirm_cleanup=None, cleanup_via=None):
|
|
1202
1419
|
plan = Plan(root)
|
|
1420
|
+
plan.confirm_cleanup = confirm_cleanup
|
|
1421
|
+
plan.cleanup_via = cleanup_via
|
|
1203
1422
|
plan.confirm_delete = confirm_delete
|
|
1204
1423
|
plan.confirm_via = confirm_via
|
|
1205
1424
|
has_ai = os.path.isdir(os.path.join(root, ".ai"))
|
|
@@ -1231,7 +1450,7 @@ def build_plan(root, confirm_delete=None, confirm_via=None):
|
|
|
1231
1450
|
if stuck:
|
|
1232
1451
|
plan.held = version
|
|
1233
1452
|
plan.hints.append("the schema stays at %d until %s — resolve a conflict by hand, confirm a "
|
|
1234
|
-
"deletion with --apply --confirm-delete NAME, then run /
|
|
1453
|
+
"deletion with --apply --confirm-delete NAME, then run /rr-setup --update again"
|
|
1235
1454
|
% (version, ", ".join("%s (%s)" % (i["target"], i["action"]) for i in stuck)))
|
|
1236
1455
|
else:
|
|
1237
1456
|
# Last of the migration items: an apply that stops short leaves the
|
|
@@ -1243,18 +1462,22 @@ def build_plan(root, confirm_delete=None, confirm_via=None):
|
|
|
1243
1462
|
if has_sdlc:
|
|
1244
1463
|
tpl = TEMPLATES["project-init"]
|
|
1245
1464
|
entries = list(PROJECT_INIT_MAP)
|
|
1465
|
+
retired = []
|
|
1246
1466
|
for rt in runtimes:
|
|
1247
1467
|
entries += RUNTIME_MAP[rt]
|
|
1248
1468
|
for src, target, kind in entries:
|
|
1249
1469
|
content = read(os.path.join(tpl, src))
|
|
1250
1470
|
if target in plan.removed: # a migration moved it away in this run
|
|
1251
1471
|
continue
|
|
1252
|
-
if kind == "
|
|
1472
|
+
if kind == "retire":
|
|
1473
|
+
retired.append(("project-init/" + src, target))
|
|
1474
|
+
elif kind == "create":
|
|
1253
1475
|
if not plan.exists(target):
|
|
1254
1476
|
plan.add("create", target, content=content.replace(
|
|
1255
1477
|
b"{{PROJECT}}", os.path.basename(root).encode()))
|
|
1256
1478
|
else:
|
|
1257
1479
|
three_way(plan, history, "project-init/" + src, target, content)
|
|
1480
|
+
retire_templates(plan, history, retired)
|
|
1258
1481
|
gitignore_update(plan, os.path.join(tpl, "gitignore.snippet"))
|
|
1259
1482
|
|
|
1260
1483
|
if has_ai:
|
|
@@ -1263,7 +1486,7 @@ def build_plan(root, confirm_delete=None, confirm_via=None):
|
|
|
1263
1486
|
for dirpath, _, names in os.walk(os.path.join(tpl, ".ai")):
|
|
1264
1487
|
for n in names:
|
|
1265
1488
|
files.append(os.path.relpath(os.path.join(dirpath, n), tpl))
|
|
1266
|
-
# JSON before markdown, so the
|
|
1489
|
+
# JSON before markdown, so the weight mirror is rendered from the merged JSON.
|
|
1267
1490
|
for rel in sorted(files, key=lambda r: (not r.endswith(".json"), r)):
|
|
1268
1491
|
if rel == VERSION_FILE: # owned by the migration step, never merged
|
|
1269
1492
|
continue
|
|
@@ -1276,8 +1499,12 @@ def build_plan(root, confirm_delete=None, confirm_via=None):
|
|
|
1276
1499
|
if not plan.exists(rel):
|
|
1277
1500
|
plan.add("create", rel, content=content)
|
|
1278
1501
|
continue
|
|
1502
|
+
if rel == WEIGHT_MD:
|
|
1503
|
+
weight_mirror(plan, history, content)
|
|
1504
|
+
continue
|
|
1279
1505
|
three_way(plan, history, "ai-init/" + rel, rel, content)
|
|
1280
1506
|
shared_update(plan, history, version)
|
|
1507
|
+
cleanup_update(plan)
|
|
1281
1508
|
deletion_hint(plan)
|
|
1282
1509
|
for rt in runtimes:
|
|
1283
1510
|
block_update(plan, history, rt)
|
|
@@ -1290,8 +1517,8 @@ def build_plan(root, confirm_delete=None, confirm_via=None):
|
|
|
1290
1517
|
("verify_command", "the feedback loop needs it"),
|
|
1291
1518
|
("step_test_command", "a step runs only its own tests"),
|
|
1292
1519
|
("e2e_command", "e2e runs once at the end of a task; write 'none' if there is no suite"),
|
|
1293
|
-
("lint_command", "a sensor reads it;
|
|
1294
|
-
("typecheck_command", "a sensor reads it;
|
|
1520
|
+
("lint_command", "a sensor reads it; read the project's config files for it, and write 'none' if there is none"),
|
|
1521
|
+
("typecheck_command", "a sensor reads it; read the project's config files for it, and write 'none' if there is none"),
|
|
1295
1522
|
):
|
|
1296
1523
|
if re.search(rb"(?m)^" + field.encode() + rb":[ \t]*(#.*)?$", testing):
|
|
1297
1524
|
plan.hints.append(".ai/policies/testing.md: %s is empty — %s" % (field, why))
|
|
@@ -1432,16 +1659,25 @@ def apply(plan):
|
|
|
1432
1659
|
|
|
1433
1660
|
def apply_item(plan, item, written, recorded):
|
|
1434
1661
|
target, action = item["target"], item["action"]
|
|
1662
|
+
cleanup = (item.get("reason") or "").startswith("[cleanup]")
|
|
1435
1663
|
if action == "delete?": # listed only, until a human confirms it
|
|
1436
1664
|
return
|
|
1437
1665
|
mode = None
|
|
1438
1666
|
if action in ("move", "delete"):
|
|
1439
1667
|
source = item["src"] if action == "move" else target
|
|
1668
|
+
# the plan-time symlink check is repeated: a path swapped since is refused
|
|
1669
|
+
why = unsafe_path(plan.root, source)
|
|
1670
|
+
if why:
|
|
1671
|
+
raise Abort(item, why)
|
|
1440
1672
|
check(plan, item, source, item["src_expect"] if action == "move" else item["expect"], written)
|
|
1441
1673
|
if action == "move":
|
|
1442
1674
|
check(plan, item, target, item["expect"], written)
|
|
1443
1675
|
mode = os.stat(os.path.join(plan.root, source)).st_mode & 0o7777
|
|
1444
|
-
|
|
1676
|
+
# git-ignored task state: a move is byte-identical; a [cleanup] deletion of it is NOT
|
|
1677
|
+
# recoverable through git (the logs are git-ignored), and copying what it removes
|
|
1678
|
+
# would defeat the cleanup, so no original/ copy is kept. A report file git tracks,
|
|
1679
|
+
# or should, is kept like any other deletion.
|
|
1680
|
+
if not source.startswith(".ai/state/") and (not cleanup or source in plan.git_kept):
|
|
1445
1681
|
keep_original(plan, source)
|
|
1446
1682
|
recorded.append(target)
|
|
1447
1683
|
# Recorded before the file is gone: an abort later must not lose the note
|
|
@@ -1449,7 +1685,8 @@ def apply_item(plan, item, written, recorded):
|
|
|
1449
1685
|
record_migration(plan,
|
|
1450
1686
|
[dict(action=action, migration=item["migration"],
|
|
1451
1687
|
**(dict(src=item["src"], dst=target) if action == "move" else dict(path=target)))],
|
|
1452
|
-
[dict(path=target, confirmed_by=plan.
|
|
1688
|
+
[dict(path=target, confirmed_by=plan.confirm_cleanup if cleanup else plan.confirm_delete,
|
|
1689
|
+
via=plan.cleanup_via if cleanup else plan.confirm_via, at=utc_now())]
|
|
1453
1690
|
if action == "delete" else [])
|
|
1454
1691
|
elif item["content"] is not None:
|
|
1455
1692
|
check(plan, item, target, item["expect"], written)
|
|
@@ -1469,10 +1706,25 @@ def apply_item(plan, item, written, recorded):
|
|
|
1469
1706
|
elif action == "delete":
|
|
1470
1707
|
os.remove(os.path.join(plan.root, target))
|
|
1471
1708
|
written[target] = None
|
|
1709
|
+
if (item["reason"] or "").startswith("[cleanup]"):
|
|
1710
|
+
prune_empty(plan.root, target)
|
|
1472
1711
|
if item["conflict_copy"] is not None:
|
|
1473
1712
|
write_file(plan.root, os.path.join(plan.local_dir, target), item["conflict_copy"])
|
|
1474
1713
|
|
|
1475
1714
|
|
|
1715
|
+
def prune_empty(root, target):
|
|
1716
|
+
"""The directories a cleanup deletion left empty, up to (not including)
|
|
1717
|
+
.ai/reports and .ai/state/logs; a directory with anything in it stays."""
|
|
1718
|
+
top = ".ai/state/logs" if target.startswith(".ai/state/logs/") else ".ai/reports"
|
|
1719
|
+
here = os.path.dirname(target)
|
|
1720
|
+
while here.startswith(top + "/"):
|
|
1721
|
+
try:
|
|
1722
|
+
os.rmdir(os.path.join(root, here))
|
|
1723
|
+
except OSError:
|
|
1724
|
+
return
|
|
1725
|
+
here = os.path.dirname(here)
|
|
1726
|
+
|
|
1727
|
+
|
|
1476
1728
|
def plain_pending(root, ignore=()):
|
|
1477
1729
|
"""True when the plain --check would exit 1: an automatic item or a schema
|
|
1478
1730
|
step is pending. Targets in `ignore` (an adopt resuming its own writes) do
|
|
@@ -1523,20 +1775,42 @@ def report(plan, applied):
|
|
|
1523
1775
|
print(" up to date with the installed plugin")
|
|
1524
1776
|
def shown(i):
|
|
1525
1777
|
return "%s -> %s" % (i["src"], i["target"]) if i["src"] else i["target"]
|
|
1526
|
-
|
|
1778
|
+
# a cleanup is one line per directory, however many files it holds
|
|
1779
|
+
rows, groups = [], {}
|
|
1780
|
+
for i in plan.items:
|
|
1781
|
+
if (i["reason"] or "").startswith("[cleanup]"):
|
|
1782
|
+
parts = i["target"].split("/")
|
|
1783
|
+
where = "/".join(parts[:4] if i["target"].startswith(".ai/state/logs/") else parts[:3])
|
|
1784
|
+
key = (where, i["action"], i["note"])
|
|
1785
|
+
if key in groups:
|
|
1786
|
+
groups[key]["count"] += 1
|
|
1787
|
+
continue
|
|
1788
|
+
row = dict(i, migration=None, src=None, count=1)
|
|
1789
|
+
groups[key] = row
|
|
1790
|
+
row["target"] = where
|
|
1791
|
+
i = row
|
|
1792
|
+
rows.append(i)
|
|
1793
|
+
for (where, _action, _note), row in groups.items():
|
|
1794
|
+
row["target"] = "%s/ (%d file%s)" % (where, row["count"], "" if row["count"] == 1 else "s")
|
|
1795
|
+
width = max([len(shown(i)) for i in rows] + [10])
|
|
1527
1796
|
if plan.schema:
|
|
1528
1797
|
frm, to, mods = plan.schema
|
|
1529
1798
|
print(" %-9s %-*s (%d migration%s: %s)" % (
|
|
1530
1799
|
"schema", width, "%d -> %d" % (frm, to), len(mods), "" if len(mods) == 1 else "s",
|
|
1531
1800
|
", ".join("%04d %s" % (m.VERSION, m.TITLE) for m in mods)))
|
|
1532
|
-
for i in
|
|
1801
|
+
for i in rows:
|
|
1533
1802
|
note = i["note"]
|
|
1534
1803
|
if i["migration"] is not None:
|
|
1535
1804
|
note = "[%04d]%s" % (i["migration"], " " + note if note else "")
|
|
1536
1805
|
if i.get("tool") and not note.startswith("[%s]" % i["tool"]):
|
|
1537
1806
|
note = "[%s] %s" % (i["tool"], note)
|
|
1538
1807
|
if i["action"] == "delete?":
|
|
1539
|
-
|
|
1808
|
+
if (i["reason"] or "").startswith("[cleanup]"):
|
|
1809
|
+
note += "; needs --apply --confirm-cleanup NAME"
|
|
1810
|
+
if i["target"].startswith(".ai/state/"):
|
|
1811
|
+
note += " (git-ignored: not recoverable through git)"
|
|
1812
|
+
else:
|
|
1813
|
+
note += "; needs --apply --confirm-delete NAME"
|
|
1540
1814
|
if i["action"] == "conflict" and i["conflict_copy"] is not None:
|
|
1541
1815
|
note += "; plugin version %s %s/%s" % ("at" if applied else "will be at", plan.local_dir, i["target"])
|
|
1542
1816
|
print(" %-9s %-*s %s" % (i["action"], width, shown(i), note))
|
|
@@ -1576,30 +1850,42 @@ def main():
|
|
|
1576
1850
|
ap.add_argument("--confirm-delete", metavar="NAME",
|
|
1577
1851
|
help="a human confirms this run's proposed deletions, and is recorded in "
|
|
1578
1852
|
"the migration report; only with --apply")
|
|
1853
|
+
ap.add_argument("--confirm-cleanup", metavar="NAME",
|
|
1854
|
+
help="a human confirms this run's [cleanup] deletions (run logs and stale task files, "
|
|
1855
|
+
"git-ignored and not recoverable through git); only with --apply. "
|
|
1856
|
+
"--confirm-delete does not settle them")
|
|
1579
1857
|
args = ap.parse_args()
|
|
1580
|
-
confirm_via = None
|
|
1581
|
-
|
|
1858
|
+
confirm_via = cleanup_via = None
|
|
1859
|
+
for flag, name in (("--confirm-delete", args.confirm_delete), ("--confirm-cleanup", args.confirm_cleanup)):
|
|
1860
|
+
if name is None:
|
|
1861
|
+
continue
|
|
1582
1862
|
if not args.apply:
|
|
1583
|
-
ap.error("
|
|
1584
|
-
if not
|
|
1585
|
-
ap.error("
|
|
1586
|
-
if adopt.placeholder_name(
|
|
1587
|
-
ap.error("
|
|
1588
|
-
%
|
|
1589
|
-
passed,
|
|
1863
|
+
ap.error("%s only makes sense with --apply" % flag)
|
|
1864
|
+
if not name.strip():
|
|
1865
|
+
ap.error("%s needs the name of the human who confirmed the deletion" % flag)
|
|
1866
|
+
if adopt.placeholder_name(name):
|
|
1867
|
+
ap.error("%s got the placeholder %r; type your own name, it is the audit trail"
|
|
1868
|
+
% (flag, name.strip()))
|
|
1869
|
+
passed, via, why = adopt.human_gate()
|
|
1590
1870
|
if not passed:
|
|
1591
1871
|
# Under an agent-session marker the AI_UNATTENDED route is the launcher's
|
|
1592
1872
|
# to declare, not the agent's to type: the hint names it only otherwise.
|
|
1593
1873
|
hint = "run the same command yourself in a terminal outside Claude Code or Codex"
|
|
1594
1874
|
if "agent session" not in why:
|
|
1595
1875
|
hint += ", or set AI_UNATTENDED=1 if a launcher runs it unattended on your behalf"
|
|
1596
|
-
return adopt.refuse("
|
|
1876
|
+
return adopt.refuse("%s is typed by a human, and %s" % (flag, why), hint)
|
|
1877
|
+
if flag == "--confirm-delete":
|
|
1878
|
+
confirm_via = via
|
|
1879
|
+
else:
|
|
1880
|
+
cleanup_via = via
|
|
1597
1881
|
if args.budget and not args.check:
|
|
1598
1882
|
ap.error("--budget only makes sense with --check")
|
|
1599
1883
|
if not args.adopt and (args.tool or args.mode != "migrate" or args.split_request or args.split or args.diff
|
|
1600
1884
|
or args.cleanup):
|
|
1601
1885
|
ap.error("--mode, --tool, --split, --split-request, --diff and --cleanup only make sense with --adopt")
|
|
1602
1886
|
root = os.path.abspath(args.root)
|
|
1887
|
+
if args.adopt and args.confirm_cleanup is not None:
|
|
1888
|
+
ap.error("--confirm-cleanup does not apply to --adopt")
|
|
1603
1889
|
if args.adopt:
|
|
1604
1890
|
if args.confirm_delete is not None and not args.cleanup:
|
|
1605
1891
|
ap.error("--adopt --confirm-delete belongs to --cleanup")
|
|
@@ -1632,13 +1918,14 @@ def main():
|
|
|
1632
1918
|
print("project-update: %s templates not found at %s (run riskrails/install.sh)" % (name, tpl), file=sys.stderr)
|
|
1633
1919
|
return 2
|
|
1634
1920
|
try:
|
|
1635
|
-
plan = build_plan(root, confirm_delete=getattr(args, "confirm_delete", None), confirm_via=confirm_via
|
|
1921
|
+
plan = build_plan(root, confirm_delete=getattr(args, "confirm_delete", None), confirm_via=confirm_via,
|
|
1922
|
+
confirm_cleanup=args.confirm_cleanup, cleanup_via=cleanup_via)
|
|
1636
1923
|
except SchemaError as exc:
|
|
1637
1924
|
# --check is read by /ai-status, which reads stdout: it must see the reason.
|
|
1638
1925
|
print("project-update: %s" % exc, file=sys.stdout if args.check else sys.stderr)
|
|
1639
1926
|
return 2
|
|
1640
1927
|
if plan is None:
|
|
1641
|
-
print("project-update: %s has neither .ai/ nor docs/sdlc/ — run /
|
|
1928
|
+
print("project-update: %s has neither .ai/ nor docs/sdlc/ — run /rr-setup first" % root,
|
|
1642
1929
|
file=sys.stderr)
|
|
1643
1930
|
return 2
|
|
1644
1931
|
if not args.check:
|
|
@@ -1665,7 +1952,7 @@ def main():
|
|
|
1665
1952
|
bits.append("%d deletion(s) a human must confirm" % deletions)
|
|
1666
1953
|
if plan.held is not None:
|
|
1667
1954
|
bits.append("the schema stays at %d until they are settled" % plan.held)
|
|
1668
|
-
print("project is behind the installed plugin: %s — run /
|
|
1955
|
+
print("project is behind the installed plugin: %s — run /rr-setup --update" % ", ".join(bits))
|
|
1669
1956
|
return 1
|
|
1670
1957
|
print("project matches the installed plugin" + (" (%d file(s) differ by hand-merge choice)" % conflicts if conflicts else ""))
|
|
1671
1958
|
return 0
|