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
|
@@ -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,13 +85,20 @@ 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
|
-
LEGACY_STATE_FILE = ".ai/state/current.json" # R34: the name until migration
|
|
90
|
+
LEGACY_STATE_FILE = ".ai/state/current.json" # R34: the name until migration 0009 moves it
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# Templates a migration creates exactly once, on the step to the given schema.
|
|
94
|
+
# From that schema on, a missing file is the human's choice (dialog.json missing
|
|
95
|
+
# means the dialog is off), so the template walk must not re-create it.
|
|
96
|
+
CREATE_ONCE = {".ai/policies/dialog.json": 10}
|
|
85
97
|
|
|
86
98
|
|
|
87
99
|
def state_file(plan):
|
|
88
100
|
"""The task state's path as this run leaves the project: state.json once it
|
|
89
|
-
exists there (migration
|
|
101
|
+
exists there (migration 0009 moved it, or the task started under it), else
|
|
90
102
|
the legacy current.json."""
|
|
91
103
|
if not plan.exists(STATE_FILE) and plan.exists(LEGACY_STATE_FILE):
|
|
92
104
|
return LEGACY_STATE_FILE
|
|
@@ -113,14 +125,14 @@ def renamed_markers(block):
|
|
|
113
125
|
find_block = render_instructions.find_block
|
|
114
126
|
current_markers = render_instructions.current_markers
|
|
115
127
|
MISSING = object()
|
|
116
|
-
HASH_RE = re.compile(rb"sha256:[0-9a-f]{64}")
|
|
117
128
|
|
|
118
129
|
PROJECT_INIT_MAP = [ # template -> target, kind
|
|
119
130
|
("sdlc-README.md", "docs/sdlc/README.md", "update"),
|
|
120
|
-
|
|
121
|
-
("
|
|
122
|
-
("
|
|
123
|
-
("
|
|
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"),
|
|
124
136
|
# the project's own principles: created once, then it is the project's file
|
|
125
137
|
("constitution.md", "docs/sdlc/constitution.md", "create"),
|
|
126
138
|
]
|
|
@@ -296,6 +308,53 @@ def git_dirty_ai(root):
|
|
|
296
308
|
return res.returncode == 0 and bool(res.stdout.strip())
|
|
297
309
|
|
|
298
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
|
+
|
|
299
358
|
# ---------------------------------------------------------------- merging
|
|
300
359
|
def closest(candidates, ours, score):
|
|
301
360
|
best, best_score = None, -1.0
|
|
@@ -401,6 +460,22 @@ def detect_version(plan):
|
|
|
401
460
|
return int(text)
|
|
402
461
|
|
|
403
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
|
+
|
|
404
479
|
class MigrationContext:
|
|
405
480
|
"""What a migration sees: the project as this run leaves it, not the disk."""
|
|
406
481
|
|
|
@@ -517,16 +592,7 @@ class MigrationContext:
|
|
|
517
592
|
"""Why a file must not be deleted through this path, or None: it is a
|
|
518
593
|
symlink, it is reached through one, or it resolves outside the project."""
|
|
519
594
|
self._check_path(path)
|
|
520
|
-
|
|
521
|
-
here = self.root
|
|
522
|
-
for part in path.split("/"):
|
|
523
|
-
here = os.path.join(here, part)
|
|
524
|
-
if os.path.islink(here):
|
|
525
|
-
return "%s is a symlink" % os.path.relpath(here, self.root).replace(os.sep, "/")
|
|
526
|
-
real = os.path.realpath(os.path.join(self.root, path))
|
|
527
|
-
if real != root and not real.startswith(root + os.sep):
|
|
528
|
-
return "%s resolves outside the project (%s)" % (path, real)
|
|
529
|
-
return None
|
|
595
|
+
return unsafe_path(self.root, path)
|
|
530
596
|
|
|
531
597
|
def override_note(self, path):
|
|
532
598
|
"""What an edited copy of a shared file now means."""
|
|
@@ -704,11 +770,14 @@ class Plan:
|
|
|
704
770
|
self.schema = None # (from, to, [migration modules]) when migrations are pending
|
|
705
771
|
self.confirm_delete = None # the human who confirmed this run's deletions
|
|
706
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
|
|
707
775
|
self.held = None # the schema version kept until a migration item is settled
|
|
708
776
|
self.final = {} # target -> bytes after this run (for cross-file fixes)
|
|
709
777
|
self.removed = set() # targets this run takes away
|
|
710
778
|
self.seen = {} # target -> sha of what was on disk when it was first read,
|
|
711
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
|
|
712
781
|
self.report_dir = ".ai/reports/project-update-%s" % datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
713
782
|
|
|
714
783
|
# The project as this run leaves it: planned content first, then the disk.
|
|
@@ -759,17 +828,60 @@ class Plan:
|
|
|
759
828
|
return base
|
|
760
829
|
|
|
761
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
|
+
|
|
762
876
|
def three_way(plan, history, key, target, theirs):
|
|
763
877
|
ours = plan.read(target)
|
|
764
878
|
if ours is None:
|
|
765
879
|
plan.add("create", target, content=theirs)
|
|
766
880
|
return
|
|
767
|
-
|
|
768
|
-
norm = (lambda b: HASH_RE.sub(b"sha256:" + b"0" * 64, b)) if is_mirror else (lambda b: b)
|
|
769
|
-
if norm(ours) == norm(theirs):
|
|
881
|
+
if ours == theirs:
|
|
770
882
|
return
|
|
771
|
-
versions = [v for v in history.versions(key) if
|
|
772
|
-
if any(
|
|
883
|
+
versions = [v for v in history.versions(key) if v != theirs]
|
|
884
|
+
if any(v == ours for v in versions):
|
|
773
885
|
policy = None
|
|
774
886
|
if target.endswith(".json") and "/policies/" in "/" + target:
|
|
775
887
|
try:
|
|
@@ -778,7 +890,7 @@ def three_way(plan, history, key, target, theirs):
|
|
|
778
890
|
except ValueError:
|
|
779
891
|
policy = None
|
|
780
892
|
plan.add("update", target, "unchanged since it was installed",
|
|
781
|
-
content=
|
|
893
|
+
content=theirs, policy=policy)
|
|
782
894
|
return
|
|
783
895
|
|
|
784
896
|
if target.endswith(".json"):
|
|
@@ -799,28 +911,21 @@ def three_way(plan, history, key, target, theirs):
|
|
|
799
911
|
plan.add("merge", target, "your edits kept", content=dump_json(merged), policy=policy)
|
|
800
912
|
if conflicts:
|
|
801
913
|
plan.add("conflict", target, "kept your value for: " + ", ".join(conflicts), conflict_copy=theirs)
|
|
802
|
-
model_keys = [c for c in conflicts if c.split(".")[0] == "model_tiers" or ".review_model" in "." + c]
|
|
803
|
-
if target.endswith("policies/risk-tiers.json") and model_keys:
|
|
804
|
-
hint = ("%s: %s — the plugin now names tiers (FAST/BALANCED/STRONG/EXPERT), not models; "
|
|
805
|
-
"the model of a tier comes from the installed plan: state.py profile --tier <TIER>"
|
|
806
|
-
% (target, ", ".join(model_keys)))
|
|
807
|
-
if hint not in plan.hints:
|
|
808
|
-
plan.hints.append(hint)
|
|
809
914
|
return
|
|
810
915
|
|
|
811
916
|
if target == ROUTER_FILE and len(SHARED_SECTION_RE.findall(ours)) == 1:
|
|
812
917
|
router_merge(plan, ours, versions, theirs)
|
|
813
918
|
return
|
|
814
|
-
base = closest(versions,
|
|
815
|
-
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
|
|
816
921
|
if merged is not None and target == ROUTER_FILE and len(SHARED_SECTION_RE.findall(merged)) != 1:
|
|
817
922
|
plan.add("conflict", target, "the merge would not name the shared files exactly once "
|
|
818
923
|
"(## Shared files)", conflict_copy=theirs)
|
|
819
924
|
return
|
|
820
925
|
if merged is None:
|
|
821
926
|
plan.add("conflict", target, "edited here and changed in the plugin", conflict_copy=theirs)
|
|
822
|
-
elif merged !=
|
|
823
|
-
plan.add("merge", target, "your edits kept", content=
|
|
927
|
+
elif merged != ours:
|
|
928
|
+
plan.add("merge", target, "your edits kept", content=merged)
|
|
824
929
|
|
|
825
930
|
|
|
826
931
|
def split_shared_section(text):
|
|
@@ -832,8 +937,9 @@ def split_shared_section(text):
|
|
|
832
937
|
return None
|
|
833
938
|
start = found[0].start()
|
|
834
939
|
nxt = re.compile(rb"(?m)^## ").search(text, found[0].end())
|
|
835
|
-
if nxt is None:
|
|
836
|
-
|
|
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""
|
|
837
943
|
end = nxt.start()
|
|
838
944
|
eol = text.find(b"\n", end)
|
|
839
945
|
follow = text[end:eol + 1] if eol >= 0 else text[end:]
|
|
@@ -857,7 +963,9 @@ def router_merge(plan, ours, versions, theirs):
|
|
|
857
963
|
if o_rest == t_rest or (base is not None and base == t_rest):
|
|
858
964
|
return
|
|
859
965
|
merged = merge_text(o_rest, base, t_rest) if base is not None else None
|
|
860
|
-
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:
|
|
861
969
|
merged = put_section(merged, o_sec, [follow, t_split[2] if t_split else b""])
|
|
862
970
|
if merged is None or len(SHARED_SECTION_RE.findall(merged)) != 1:
|
|
863
971
|
plan.add("conflict", ROUTER_FILE, "edited here and changed in the plugin", conflict_copy=theirs)
|
|
@@ -880,27 +988,6 @@ def put_section(text, section, follows):
|
|
|
880
988
|
return text + (b"" if text.endswith(b"\n") else b"\n") + section
|
|
881
989
|
|
|
882
990
|
|
|
883
|
-
def fix_mirror(plan, target, content, ours):
|
|
884
|
-
"""risk-tiers.md carries the sha256 of risk-tiers.json. Carry the project's sync
|
|
885
|
-
state forward: a mirror that matched its JSON before this run points at the JSON
|
|
886
|
-
this run leaves behind; a mirror that was already stale, or a JSON left with a
|
|
887
|
-
conflict, keeps its old hash so /ai-status keeps warning."""
|
|
888
|
-
if not target.endswith(".ai/policies/risk-tiers.md"):
|
|
889
|
-
return content
|
|
890
|
-
old = HASH_RE.search(ours)
|
|
891
|
-
# The disk, not the plan: the question is whether the mirror matched the JSON
|
|
892
|
-
# the project had before this run.
|
|
893
|
-
json_target = target[:-3] + ".json"
|
|
894
|
-
json_path = os.path.join(plan.root, json_target)
|
|
895
|
-
before = read(json_path) if os.path.exists(json_path) else None
|
|
896
|
-
in_sync = old is not None and before is not None and old.group(0) == b"sha256:" + sha(before).encode()
|
|
897
|
-
conflicted = any(i["action"] == "conflict" and i["target"] == json_target for i in plan.items)
|
|
898
|
-
after = plan.final.get(json_target, None if json_target in plan.removed else before)
|
|
899
|
-
if in_sync and not conflicted and after is not None:
|
|
900
|
-
return HASH_RE.sub(b"sha256:" + sha(after).encode(), content, count=1)
|
|
901
|
-
return HASH_RE.sub(old.group(0), content, count=1) if old else content
|
|
902
|
-
|
|
903
|
-
|
|
904
991
|
def block_update(plan, history, runtime):
|
|
905
992
|
name, block_tpl, minimal_tpl = INSTRUCTION_FILE[runtime]
|
|
906
993
|
if name in plan.removed: # a migration moved this instruction file away
|
|
@@ -979,7 +1066,7 @@ def shared_update(plan, history, version):
|
|
|
979
1066
|
edited.append(path)
|
|
980
1067
|
elif not named:
|
|
981
1068
|
ctx.hint("%s: unedited, but %s does not name the shared files (## Shared files), so the "
|
|
982
|
-
"copy stays — restore that section, then run /
|
|
1069
|
+
"copy stays — restore that section, then run /rr-setup --update again"
|
|
983
1070
|
% (path, ROUTER_FILE))
|
|
984
1071
|
else:
|
|
985
1072
|
candidates.append(path)
|
|
@@ -1002,9 +1089,145 @@ def shared_update(plan, history, version):
|
|
|
1002
1089
|
"adds nothing" % (len(edited), ", ".join(edited), SHARED))
|
|
1003
1090
|
|
|
1004
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
|
+
|
|
1005
1224
|
def deletion_hint(plan):
|
|
1006
|
-
"""M4: git is the rollback of a deletion; it must hold .ai/ first.
|
|
1007
|
-
|
|
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) \
|
|
1008
1231
|
and git_dirty_ai(plan.root):
|
|
1009
1232
|
plan.hints.append("commit .ai/ first: git is this migration's rollback "
|
|
1010
1233
|
"(`git status -- .ai` lists what it does not hold yet)")
|
|
@@ -1192,8 +1415,10 @@ def constitution_hint(plan):
|
|
|
1192
1415
|
plan.hints.append(line.replace(path, rel))
|
|
1193
1416
|
|
|
1194
1417
|
|
|
1195
|
-
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):
|
|
1196
1419
|
plan = Plan(root)
|
|
1420
|
+
plan.confirm_cleanup = confirm_cleanup
|
|
1421
|
+
plan.cleanup_via = cleanup_via
|
|
1197
1422
|
plan.confirm_delete = confirm_delete
|
|
1198
1423
|
plan.confirm_via = confirm_via
|
|
1199
1424
|
has_ai = os.path.isdir(os.path.join(root, ".ai"))
|
|
@@ -1225,7 +1450,7 @@ def build_plan(root, confirm_delete=None, confirm_via=None):
|
|
|
1225
1450
|
if stuck:
|
|
1226
1451
|
plan.held = version
|
|
1227
1452
|
plan.hints.append("the schema stays at %d until %s — resolve a conflict by hand, confirm a "
|
|
1228
|
-
"deletion with --apply --confirm-delete NAME, then run /
|
|
1453
|
+
"deletion with --apply --confirm-delete NAME, then run /rr-setup --update again"
|
|
1229
1454
|
% (version, ", ".join("%s (%s)" % (i["target"], i["action"]) for i in stuck)))
|
|
1230
1455
|
else:
|
|
1231
1456
|
# Last of the migration items: an apply that stops short leaves the
|
|
@@ -1237,18 +1462,22 @@ def build_plan(root, confirm_delete=None, confirm_via=None):
|
|
|
1237
1462
|
if has_sdlc:
|
|
1238
1463
|
tpl = TEMPLATES["project-init"]
|
|
1239
1464
|
entries = list(PROJECT_INIT_MAP)
|
|
1465
|
+
retired = []
|
|
1240
1466
|
for rt in runtimes:
|
|
1241
1467
|
entries += RUNTIME_MAP[rt]
|
|
1242
1468
|
for src, target, kind in entries:
|
|
1243
1469
|
content = read(os.path.join(tpl, src))
|
|
1244
1470
|
if target in plan.removed: # a migration moved it away in this run
|
|
1245
1471
|
continue
|
|
1246
|
-
if kind == "
|
|
1472
|
+
if kind == "retire":
|
|
1473
|
+
retired.append(("project-init/" + src, target))
|
|
1474
|
+
elif kind == "create":
|
|
1247
1475
|
if not plan.exists(target):
|
|
1248
1476
|
plan.add("create", target, content=content.replace(
|
|
1249
1477
|
b"{{PROJECT}}", os.path.basename(root).encode()))
|
|
1250
1478
|
else:
|
|
1251
1479
|
three_way(plan, history, "project-init/" + src, target, content)
|
|
1480
|
+
retire_templates(plan, history, retired)
|
|
1252
1481
|
gitignore_update(plan, os.path.join(tpl, "gitignore.snippet"))
|
|
1253
1482
|
|
|
1254
1483
|
if has_ai:
|
|
@@ -1257,19 +1486,25 @@ def build_plan(root, confirm_delete=None, confirm_via=None):
|
|
|
1257
1486
|
for dirpath, _, names in os.walk(os.path.join(tpl, ".ai")):
|
|
1258
1487
|
for n in names:
|
|
1259
1488
|
files.append(os.path.relpath(os.path.join(dirpath, n), tpl))
|
|
1260
|
-
# JSON before markdown, so the
|
|
1489
|
+
# JSON before markdown, so the weight mirror is rendered from the merged JSON.
|
|
1261
1490
|
for rel in sorted(files, key=lambda r: (not r.endswith(".json"), r)):
|
|
1262
1491
|
if rel == VERSION_FILE: # owned by the migration step, never merged
|
|
1263
1492
|
continue
|
|
1264
1493
|
if rel in plan.removed: # a migration moved it away in this run
|
|
1265
1494
|
continue
|
|
1495
|
+
if rel in CREATE_ONCE and version >= CREATE_ONCE[rel] and not plan.exists(rel):
|
|
1496
|
+
continue # past the step that created it: its absence is the human's choice
|
|
1266
1497
|
content = read(os.path.join(tpl, rel))
|
|
1267
1498
|
if rel.startswith(".ai/project/") or rel.startswith(".ai/reports/"):
|
|
1268
1499
|
if not plan.exists(rel):
|
|
1269
1500
|
plan.add("create", rel, content=content)
|
|
1270
1501
|
continue
|
|
1502
|
+
if rel == WEIGHT_MD:
|
|
1503
|
+
weight_mirror(plan, history, content)
|
|
1504
|
+
continue
|
|
1271
1505
|
three_way(plan, history, "ai-init/" + rel, rel, content)
|
|
1272
1506
|
shared_update(plan, history, version)
|
|
1507
|
+
cleanup_update(plan)
|
|
1273
1508
|
deletion_hint(plan)
|
|
1274
1509
|
for rt in runtimes:
|
|
1275
1510
|
block_update(plan, history, rt)
|
|
@@ -1282,8 +1517,8 @@ def build_plan(root, confirm_delete=None, confirm_via=None):
|
|
|
1282
1517
|
("verify_command", "the feedback loop needs it"),
|
|
1283
1518
|
("step_test_command", "a step runs only its own tests"),
|
|
1284
1519
|
("e2e_command", "e2e runs once at the end of a task; write 'none' if there is no suite"),
|
|
1285
|
-
("lint_command", "a sensor reads it;
|
|
1286
|
-
("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"),
|
|
1287
1522
|
):
|
|
1288
1523
|
if re.search(rb"(?m)^" + field.encode() + rb":[ \t]*(#.*)?$", testing):
|
|
1289
1524
|
plan.hints.append(".ai/policies/testing.md: %s is empty — %s" % (field, why))
|
|
@@ -1424,23 +1659,34 @@ def apply(plan):
|
|
|
1424
1659
|
|
|
1425
1660
|
def apply_item(plan, item, written, recorded):
|
|
1426
1661
|
target, action = item["target"], item["action"]
|
|
1662
|
+
cleanup = (item.get("reason") or "").startswith("[cleanup]")
|
|
1427
1663
|
if action == "delete?": # listed only, until a human confirms it
|
|
1428
1664
|
return
|
|
1429
1665
|
mode = None
|
|
1430
1666
|
if action in ("move", "delete"):
|
|
1431
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)
|
|
1432
1672
|
check(plan, item, source, item["src_expect"] if action == "move" else item["expect"], written)
|
|
1433
1673
|
if action == "move":
|
|
1434
1674
|
check(plan, item, target, item["expect"], written)
|
|
1435
1675
|
mode = os.stat(os.path.join(plan.root, source)).st_mode & 0o7777
|
|
1436
|
-
|
|
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):
|
|
1681
|
+
keep_original(plan, source)
|
|
1437
1682
|
recorded.append(target)
|
|
1438
1683
|
# Recorded before the file is gone: an abort later must not lose the note
|
|
1439
1684
|
# of what was taken away, or of who approved a deletion.
|
|
1440
1685
|
record_migration(plan,
|
|
1441
1686
|
[dict(action=action, migration=item["migration"],
|
|
1442
1687
|
**(dict(src=item["src"], dst=target) if action == "move" else dict(path=target)))],
|
|
1443
|
-
[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())]
|
|
1444
1690
|
if action == "delete" else [])
|
|
1445
1691
|
elif item["content"] is not None:
|
|
1446
1692
|
check(plan, item, target, item["expect"], written)
|
|
@@ -1460,10 +1706,25 @@ def apply_item(plan, item, written, recorded):
|
|
|
1460
1706
|
elif action == "delete":
|
|
1461
1707
|
os.remove(os.path.join(plan.root, target))
|
|
1462
1708
|
written[target] = None
|
|
1709
|
+
if (item["reason"] or "").startswith("[cleanup]"):
|
|
1710
|
+
prune_empty(plan.root, target)
|
|
1463
1711
|
if item["conflict_copy"] is not None:
|
|
1464
1712
|
write_file(plan.root, os.path.join(plan.local_dir, target), item["conflict_copy"])
|
|
1465
1713
|
|
|
1466
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
|
+
|
|
1467
1728
|
def plain_pending(root, ignore=()):
|
|
1468
1729
|
"""True when the plain --check would exit 1: an automatic item or a schema
|
|
1469
1730
|
step is pending. Targets in `ignore` (an adopt resuming its own writes) do
|
|
@@ -1514,20 +1775,42 @@ def report(plan, applied):
|
|
|
1514
1775
|
print(" up to date with the installed plugin")
|
|
1515
1776
|
def shown(i):
|
|
1516
1777
|
return "%s -> %s" % (i["src"], i["target"]) if i["src"] else i["target"]
|
|
1517
|
-
|
|
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])
|
|
1518
1796
|
if plan.schema:
|
|
1519
1797
|
frm, to, mods = plan.schema
|
|
1520
1798
|
print(" %-9s %-*s (%d migration%s: %s)" % (
|
|
1521
1799
|
"schema", width, "%d -> %d" % (frm, to), len(mods), "" if len(mods) == 1 else "s",
|
|
1522
1800
|
", ".join("%04d %s" % (m.VERSION, m.TITLE) for m in mods)))
|
|
1523
|
-
for i in
|
|
1801
|
+
for i in rows:
|
|
1524
1802
|
note = i["note"]
|
|
1525
1803
|
if i["migration"] is not None:
|
|
1526
1804
|
note = "[%04d]%s" % (i["migration"], " " + note if note else "")
|
|
1527
1805
|
if i.get("tool") and not note.startswith("[%s]" % i["tool"]):
|
|
1528
1806
|
note = "[%s] %s" % (i["tool"], note)
|
|
1529
1807
|
if i["action"] == "delete?":
|
|
1530
|
-
|
|
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"
|
|
1531
1814
|
if i["action"] == "conflict" and i["conflict_copy"] is not None:
|
|
1532
1815
|
note += "; plugin version %s %s/%s" % ("at" if applied else "will be at", plan.local_dir, i["target"])
|
|
1533
1816
|
print(" %-9s %-*s %s" % (i["action"], width, shown(i), note))
|
|
@@ -1567,30 +1850,42 @@ def main():
|
|
|
1567
1850
|
ap.add_argument("--confirm-delete", metavar="NAME",
|
|
1568
1851
|
help="a human confirms this run's proposed deletions, and is recorded in "
|
|
1569
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")
|
|
1570
1857
|
args = ap.parse_args()
|
|
1571
|
-
confirm_via = None
|
|
1572
|
-
|
|
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
|
|
1573
1862
|
if not args.apply:
|
|
1574
|
-
ap.error("
|
|
1575
|
-
if not
|
|
1576
|
-
ap.error("
|
|
1577
|
-
if adopt.placeholder_name(
|
|
1578
|
-
ap.error("
|
|
1579
|
-
%
|
|
1580
|
-
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()
|
|
1581
1870
|
if not passed:
|
|
1582
1871
|
# Under an agent-session marker the AI_UNATTENDED route is the launcher's
|
|
1583
1872
|
# to declare, not the agent's to type: the hint names it only otherwise.
|
|
1584
1873
|
hint = "run the same command yourself in a terminal outside Claude Code or Codex"
|
|
1585
1874
|
if "agent session" not in why:
|
|
1586
1875
|
hint += ", or set AI_UNATTENDED=1 if a launcher runs it unattended on your behalf"
|
|
1587
|
-
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
|
|
1588
1881
|
if args.budget and not args.check:
|
|
1589
1882
|
ap.error("--budget only makes sense with --check")
|
|
1590
1883
|
if not args.adopt and (args.tool or args.mode != "migrate" or args.split_request or args.split or args.diff
|
|
1591
1884
|
or args.cleanup):
|
|
1592
1885
|
ap.error("--mode, --tool, --split, --split-request, --diff and --cleanup only make sense with --adopt")
|
|
1593
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")
|
|
1594
1889
|
if args.adopt:
|
|
1595
1890
|
if args.confirm_delete is not None and not args.cleanup:
|
|
1596
1891
|
ap.error("--adopt --confirm-delete belongs to --cleanup")
|
|
@@ -1623,13 +1918,14 @@ def main():
|
|
|
1623
1918
|
print("project-update: %s templates not found at %s (run riskrails/install.sh)" % (name, tpl), file=sys.stderr)
|
|
1624
1919
|
return 2
|
|
1625
1920
|
try:
|
|
1626
|
-
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)
|
|
1627
1923
|
except SchemaError as exc:
|
|
1628
1924
|
# --check is read by /ai-status, which reads stdout: it must see the reason.
|
|
1629
1925
|
print("project-update: %s" % exc, file=sys.stdout if args.check else sys.stderr)
|
|
1630
1926
|
return 2
|
|
1631
1927
|
if plan is None:
|
|
1632
|
-
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,
|
|
1633
1929
|
file=sys.stderr)
|
|
1634
1930
|
return 2
|
|
1635
1931
|
if not args.check:
|
|
@@ -1656,7 +1952,7 @@ def main():
|
|
|
1656
1952
|
bits.append("%d deletion(s) a human must confirm" % deletions)
|
|
1657
1953
|
if plan.held is not None:
|
|
1658
1954
|
bits.append("the schema stays at %d until they are settled" % plan.held)
|
|
1659
|
-
print("project is behind the installed plugin: %s — run /
|
|
1955
|
+
print("project is behind the installed plugin: %s — run /rr-setup --update" % ", ".join(bits))
|
|
1660
1956
|
return 1
|
|
1661
1957
|
print("project matches the installed plugin" + (" (%d file(s) differ by hand-merge choice)" % conflicts if conflicts else ""))
|
|
1662
1958
|
return 0
|