riskrails 3.0.1 → 3.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +29 -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 +114 -23
- package/hooks/ai-path-guard-defaults.json +3 -2
- package/hooks/context-guard.py +15 -12
- package/hooks/dialog-gate.py +1 -1
- package/hooks/guard/common.py +2 -0
- package/hooks/guard/dialog.py +107 -27
- package/hooks/guard/git.py +12 -3
- package/hooks/guard/paths.py +150 -37
- package/hooks/project-scaffold.py +3 -6
- package/hooks/runtime-gate.py +134 -33
- package/hooks/update_notice.py +22 -16
- 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/.ai/state/README.md +4 -4
- 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/rr_state/__init__.py +1 -0
- package/skills/ai-task/rr_state/cli.py +210 -0
- package/skills/ai-task/rr_state/close.py +171 -0
- package/skills/ai-task/rr_state/dialog.py +541 -0
- package/skills/ai-task/rr_state/dialog_cmd.py +164 -0
- package/skills/ai-task/rr_state/fs.py +220 -0
- package/skills/ai-task/rr_state/gate.py +125 -0
- package/skills/ai-task/rr_state/gate_cmd.py +128 -0
- package/skills/ai-task/rr_state/handoff.py +226 -0
- package/skills/ai-task/rr_state/journal.py +126 -0
- package/skills/ai-task/rr_state/labels.py +99 -0
- package/skills/ai-task/rr_state/ledger.py +145 -0
- package/skills/ai-task/rr_state/locks.py +84 -0
- package/skills/ai-task/rr_state/memory_cmd.py +310 -0
- package/skills/ai-task/rr_state/policy.py +378 -0
- package/skills/ai-task/rr_state/profile.py +373 -0
- package/skills/ai-task/rr_state/questions.py +336 -0
- package/skills/ai-task/rr_state/questions_cmd.py +321 -0
- package/skills/ai-task/rr_state/report.py +271 -0
- package/skills/ai-task/rr_state/report_cmd.py +230 -0
- package/skills/ai-task/rr_state/review.py +165 -0
- package/skills/ai-task/rr_state/runtime.py +80 -0
- package/skills/ai-task/rr_state/start.py +257 -0
- package/skills/ai-task/rr_state/steps.py +131 -0
- package/skills/ai-task/rr_state/task.py +396 -0
- package/skills/ai-task/rr_state/verify.py +231 -0
- package/skills/ai-task/sensors.py +37 -4
- package/skills/ai-task/state.py +24 -5138
- package/skills/project-init/templates/constitution.md +1 -1
- package/skills/project-init/templates/sdlc-README.md +20 -14
- package/skills/project-update/adopt.py +38 -24
- 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/a51517e13e57420d2d88a6bcfb4055d762d4a0d10a8f88450929c5c4572213a9 +67 -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 +38 -16
- package/skills/project-update/migrations/0003_context_diet.py +1 -1
- package/skills/project-update/migrations/0007_weight_model.py +130 -23
- package/skills/project-update/migrations/0011_retire_risk_tiers.py +37 -0
- package/skills/project-update/migrations/README.md +16 -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 +569 -106
- 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 +24 -4
- package/skills/rr-setup/setup.py +7 -1
- package/skills/rr-spec/SKILL.md +4 -2
- package/skills/rr-status/status.py +36 -9
- 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
|
-
import argparse, contextlib, copy, difflib, hashlib, importlib.util, io, json, os, re, subprocess, sys, tempfile
|
|
51
|
+
import argparse, contextlib, copy, difflib, hashlib, importlib.util, io, json, os, re, shlex, 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
|
|
|
@@ -102,10 +108,12 @@ RULES_DIR = ".ai/rules"
|
|
|
102
108
|
# file is not ours (the path guard refuses it too), and .git is not text.
|
|
103
109
|
# A child holding a .git entry (worktree, nested repository, submodule) is pruned
|
|
104
110
|
# too, by the take-back walk (rendered_rules) and by the writer (nested_tree). The
|
|
105
|
-
#
|
|
106
|
-
#
|
|
111
|
+
# writer also skips a RULES_SKIP name or a symlinked directory (unwalked_dir), so it
|
|
112
|
+
# never writes where the take-back never walks.
|
|
107
113
|
RULES_SKIP = {".git", ".ai", "vendor", "node_modules", "bower_components",
|
|
108
114
|
"third_party", "Pods", "site-packages", ".venv", ".pnpm", ".yarn"}
|
|
115
|
+
# Compared case-insensitively: on a case-insensitive filesystem `Vendor/` is `vendor/`.
|
|
116
|
+
RULES_SKIP_FOLDED = frozenset(n.casefold() for n in RULES_SKIP)
|
|
109
117
|
START, END = "<!-- riskrails:start -->", "<!-- riskrails:end -->"
|
|
110
118
|
OLD_START, OLD_END = b"<!-- claude-agentic:start -->", b"<!-- claude-agentic:end -->" # R35, one version
|
|
111
119
|
|
|
@@ -119,14 +127,14 @@ def renamed_markers(block):
|
|
|
119
127
|
find_block = render_instructions.find_block
|
|
120
128
|
current_markers = render_instructions.current_markers
|
|
121
129
|
MISSING = object()
|
|
122
|
-
HASH_RE = re.compile(rb"sha256:[0-9a-f]{64}")
|
|
123
130
|
|
|
124
131
|
PROJECT_INIT_MAP = [ # template -> target, kind
|
|
125
132
|
("sdlc-README.md", "docs/sdlc/README.md", "update"),
|
|
126
|
-
|
|
127
|
-
("
|
|
128
|
-
("
|
|
129
|
-
("
|
|
133
|
+
# read in place by /rr-spec and /rr-plan; a project copy is an override: never created
|
|
134
|
+
("intent.md", "docs/sdlc/intent/TEMPLATE.md", "retire"),
|
|
135
|
+
("spec.md", "docs/sdlc/specs/TEMPLATE.md", "retire"),
|
|
136
|
+
("plan.md", "docs/sdlc/plans/TEMPLATE.md", "retire"),
|
|
137
|
+
("adr.md", "docs/sdlc/adr/TEMPLATE.md", "retire"),
|
|
130
138
|
# the project's own principles: created once, then it is the project's file
|
|
131
139
|
("constitution.md", "docs/sdlc/constitution.md", "create"),
|
|
132
140
|
]
|
|
@@ -208,7 +216,7 @@ class History:
|
|
|
208
216
|
try:
|
|
209
217
|
with open(os.path.join(root, "index.json"), encoding="utf-8") as fh:
|
|
210
218
|
self.index = json.load(fh)
|
|
211
|
-
except (OSError, ValueError):
|
|
219
|
+
except (OSError, ValueError, RecursionError):
|
|
212
220
|
self.index = {}
|
|
213
221
|
self.root = root
|
|
214
222
|
# {new key: [old keys]} from the migrations' MOVES: a template that moved
|
|
@@ -296,12 +304,59 @@ def git_dirty_ai(root):
|
|
|
296
304
|
tree, where there is nothing to say."""
|
|
297
305
|
try:
|
|
298
306
|
res = subprocess.run(["git", "-C", root, "status", "--porcelain", "--", ".ai"],
|
|
299
|
-
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=30)
|
|
307
|
+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=30, check=False)
|
|
300
308
|
except (OSError, subprocess.SubprocessError):
|
|
301
309
|
return False
|
|
302
310
|
return res.returncode == 0 and bool(res.stdout.strip())
|
|
303
311
|
|
|
304
312
|
|
|
313
|
+
def git_states(root, rel_dir):
|
|
314
|
+
"""A function path -> how git holds a file under `rel_dir` (project-relative):
|
|
315
|
+
"tracked" (committed and unchanged), "uncommitted" (changed, staged or not yet
|
|
316
|
+
tracked, so git has no copy to restore) or "ignored"; None outside a work tree
|
|
317
|
+
or for a path git does not know."""
|
|
318
|
+
def run(*args):
|
|
319
|
+
res = subprocess.run(["git", "-C", root] + list(args), stdout=subprocess.PIPE,
|
|
320
|
+
stderr=subprocess.DEVNULL, timeout=30, check=False)
|
|
321
|
+
return res.stdout.decode("utf-8", "replace") if res.returncode == 0 else None
|
|
322
|
+
try:
|
|
323
|
+
prefix = run("rev-parse", "--show-prefix")
|
|
324
|
+
tracked = run("ls-files", "-z", "--", rel_dir)
|
|
325
|
+
status = run("status", "--porcelain=v1", "-z", "--ignored", "-uall", "--", rel_dir)
|
|
326
|
+
except (OSError, subprocess.SubprocessError):
|
|
327
|
+
return lambda path: None
|
|
328
|
+
if prefix is None or tracked is None or status is None:
|
|
329
|
+
return lambda path: None
|
|
330
|
+
prefix = prefix.strip()
|
|
331
|
+
tracked = set(tracked.split("\0"))
|
|
332
|
+
dirty, ignored = set(), []
|
|
333
|
+
entries = status.split("\0")
|
|
334
|
+
skip = False
|
|
335
|
+
for entry in entries:
|
|
336
|
+
if skip: # the origin path of a rename
|
|
337
|
+
skip = False
|
|
338
|
+
continue
|
|
339
|
+
if len(entry) < 4:
|
|
340
|
+
continue
|
|
341
|
+
xy, path = entry[:2], entry[3:]
|
|
342
|
+
if prefix and path.startswith(prefix):
|
|
343
|
+
path = path[len(prefix):]
|
|
344
|
+
if "R" in xy or "C" in xy:
|
|
345
|
+
skip = True
|
|
346
|
+
if xy == "!!":
|
|
347
|
+
ignored.append(path)
|
|
348
|
+
else:
|
|
349
|
+
dirty.add(path)
|
|
350
|
+
|
|
351
|
+
def classify(path):
|
|
352
|
+
if path in dirty:
|
|
353
|
+
return "uncommitted"
|
|
354
|
+
if any(path == i or (i.endswith("/") and path.startswith(i)) for i in ignored):
|
|
355
|
+
return "ignored"
|
|
356
|
+
return "tracked" if path in tracked else None
|
|
357
|
+
return classify
|
|
358
|
+
|
|
359
|
+
|
|
305
360
|
# ---------------------------------------------------------------- merging
|
|
306
361
|
def closest(candidates, ours, score):
|
|
307
362
|
best, best_score = None, -1.0
|
|
@@ -407,6 +462,22 @@ def detect_version(plan):
|
|
|
407
462
|
return int(text)
|
|
408
463
|
|
|
409
464
|
|
|
465
|
+
def unsafe_path(root, path):
|
|
466
|
+
"""Why a project-relative path must not be deleted: a symlink on the way to it,
|
|
467
|
+
or a real path outside the project; None when it is safe. Run at plan time and
|
|
468
|
+
again right before the delete."""
|
|
469
|
+
real_root = os.path.realpath(root)
|
|
470
|
+
here = root
|
|
471
|
+
for part in path.split("/"):
|
|
472
|
+
here = os.path.join(here, part)
|
|
473
|
+
if os.path.islink(here):
|
|
474
|
+
return "%s is a symlink" % os.path.relpath(here, root).replace(os.sep, "/")
|
|
475
|
+
real = os.path.realpath(os.path.join(root, path))
|
|
476
|
+
if real != real_root and not real.startswith(real_root + os.sep):
|
|
477
|
+
return "%s resolves outside the project (%s)" % (path, real)
|
|
478
|
+
return None
|
|
479
|
+
|
|
480
|
+
|
|
410
481
|
class MigrationContext:
|
|
411
482
|
"""What a migration sees: the project as this run leaves it, not the disk."""
|
|
412
483
|
|
|
@@ -523,16 +594,7 @@ class MigrationContext:
|
|
|
523
594
|
"""Why a file must not be deleted through this path, or None: it is a
|
|
524
595
|
symlink, it is reached through one, or it resolves outside the project."""
|
|
525
596
|
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
|
|
597
|
+
return unsafe_path(self.root, path)
|
|
536
598
|
|
|
537
599
|
def override_note(self, path):
|
|
538
600
|
"""What an edited copy of a shared file now means."""
|
|
@@ -546,6 +608,10 @@ class MigrationContext:
|
|
|
546
608
|
if text not in self.plan.hints:
|
|
547
609
|
self.plan.hints.append(text)
|
|
548
610
|
|
|
611
|
+
def conflict(self, path, note):
|
|
612
|
+
"""Hold the schema version on a file this migration could not handle; `note` says why."""
|
|
613
|
+
self._add("conflict", path, note)
|
|
614
|
+
|
|
549
615
|
def exists(self, path):
|
|
550
616
|
return self.read(path) is not None
|
|
551
617
|
|
|
@@ -569,6 +635,12 @@ class MigrationContext:
|
|
|
569
635
|
return json.loads(raw, object_pairs_hook=OrderedDict)
|
|
570
636
|
except ValueError:
|
|
571
637
|
return None
|
|
638
|
+
except RecursionError:
|
|
639
|
+
# Not "absent": a file nested past the parser's limit is there. The migration goes on
|
|
640
|
+
# as if it were absent (0007 then creates a default weight.json beside it, overwriting
|
|
641
|
+
# nothing); the conflict holds the schema version, so the next run tries again.
|
|
642
|
+
self._add("conflict", path, "nested too deeply, so this migration could not read it")
|
|
643
|
+
return None
|
|
572
644
|
|
|
573
645
|
# ---- operations. Each is idempotent: a target already in its final state
|
|
574
646
|
# records nothing, so a re-run after an interrupted apply is a no-op.
|
|
@@ -577,6 +649,16 @@ class MigrationContext:
|
|
|
577
649
|
if not isinstance(kw.get("content", b""), bytes):
|
|
578
650
|
raise SchemaError("%04d: %s content must be bytes, not %s"
|
|
579
651
|
% (self.module.VERSION, target, type(kw["content"]).__name__))
|
|
652
|
+
if not isinstance(note, str):
|
|
653
|
+
raise SchemaError("%04d: %s note must be str, not %s"
|
|
654
|
+
% (self.module.VERSION, target, type(note).__name__))
|
|
655
|
+
deep = action == "conflict" and isinstance(note, str) and note.startswith("nested too deeply")
|
|
656
|
+
if action == "conflict" and any(i["action"] == "conflict" and i["target"] == target
|
|
657
|
+
and i["migration"] == self.module.VERSION
|
|
658
|
+
and (i["note"] == note or deep and isinstance(i["note"], str)
|
|
659
|
+
and i["note"].startswith("nested too deeply"))
|
|
660
|
+
for i in self.plan.items):
|
|
661
|
+
return # one deep file read twice, or read then edited: one row, one held schema
|
|
580
662
|
self.plan.add(action, target, note, migration=self.module.VERSION, **kw)
|
|
581
663
|
|
|
582
664
|
def create(self, path, content):
|
|
@@ -613,7 +695,12 @@ class MigrationContext:
|
|
|
613
695
|
|
|
614
696
|
def edit_json(self, path, fn):
|
|
615
697
|
"""Rewrite a JSON file the project has. A policy file's leaf changes are
|
|
616
|
-
listed for confirmation, exactly as a merged policy change is.
|
|
698
|
+
listed for confirmation, exactly as a merged policy change is. fn gets a
|
|
699
|
+
deep copy of the document and returns the new one. It must not walk the
|
|
700
|
+
document with Python recursion (a file that just passed copy.deepcopy can
|
|
701
|
+
be 470-490 levels deep at the default limit), or it guards RecursionError
|
|
702
|
+
itself: an unguarded fn's RecursionError is reported as its own, the run
|
|
703
|
+
fails and no file is blamed."""
|
|
617
704
|
raw = self.read(path)
|
|
618
705
|
if raw is None:
|
|
619
706
|
return
|
|
@@ -624,11 +711,25 @@ class MigrationContext:
|
|
|
624
711
|
# the schema version, so the next run tries again.
|
|
625
712
|
self._add("conflict", path, "is not valid JSON, so this migration could not run (%s)" % exc)
|
|
626
713
|
return
|
|
627
|
-
|
|
628
|
-
|
|
714
|
+
except RecursionError:
|
|
715
|
+
self._add("conflict", path, "nested too deeply, so this migration could not run")
|
|
716
|
+
return
|
|
717
|
+
try:
|
|
718
|
+
copied = copy.deepcopy(before)
|
|
719
|
+
except RecursionError:
|
|
720
|
+
self._add("conflict", path, "nested too deeply, so this migration could not run")
|
|
629
721
|
return
|
|
630
|
-
|
|
631
|
-
|
|
722
|
+
# Outside the guard: an unguarded fn's RecursionError is reported as its own, not the file's.
|
|
723
|
+
after = fn(copied)
|
|
724
|
+
try:
|
|
725
|
+
if after == before:
|
|
726
|
+
return
|
|
727
|
+
policy = leaf_changes(before, after) if "/policies/" in "/" + path else None
|
|
728
|
+
content = dump_json(after)
|
|
729
|
+
except RecursionError: # comparing or writing past the limit
|
|
730
|
+
self._add("conflict", path, "nested too deeply, so this migration could not run")
|
|
731
|
+
return
|
|
732
|
+
self._add("update", path, "migrated", content=content, policy=policy)
|
|
632
733
|
|
|
633
734
|
def patch_state(self, fn): # noqa: D401
|
|
634
735
|
"""Change the task in flight. update.py writes the state file the
|
|
@@ -686,6 +787,8 @@ def run_migrations(plan, version, history):
|
|
|
686
787
|
state = json.loads(raw, object_pairs_hook=OrderedDict)
|
|
687
788
|
except ValueError as exc:
|
|
688
789
|
raise SchemaError("%s is not valid JSON (%s) — inspect it by hand" % (path, exc)) from exc
|
|
790
|
+
except RecursionError as exc:
|
|
791
|
+
raise SchemaError("%s is nested too deeply to parse — inspect it by hand" % path) from exc
|
|
689
792
|
if not isinstance(state, dict):
|
|
690
793
|
raise SchemaError("%s is not a task state object" % path)
|
|
691
794
|
for mod in mods:
|
|
@@ -710,11 +813,14 @@ class Plan:
|
|
|
710
813
|
self.schema = None # (from, to, [migration modules]) when migrations are pending
|
|
711
814
|
self.confirm_delete = None # the human who confirmed this run's deletions
|
|
712
815
|
self.confirm_via = None # why that gate passed: "terminal" or "unattended"
|
|
816
|
+
self.confirm_cleanup = None # the human who confirmed this run's [cleanup] deletions
|
|
817
|
+
self.cleanup_via = None
|
|
713
818
|
self.held = None # the schema version kept until a migration item is settled
|
|
714
819
|
self.final = {} # target -> bytes after this run (for cross-file fixes)
|
|
715
820
|
self.removed = set() # targets this run takes away
|
|
716
821
|
self.seen = {} # target -> sha of what was on disk when it was first read,
|
|
717
822
|
# or None when nothing was there: apply's precondition
|
|
823
|
+
self.git_kept = set() # [cleanup] targets git holds or should hold: a hint and an original/ copy
|
|
718
824
|
self.report_dir = ".ai/reports/project-update-%s" % datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
719
825
|
|
|
720
826
|
# The project as this run leaves it: planned content first, then the disk.
|
|
@@ -765,26 +871,95 @@ class Plan:
|
|
|
765
871
|
return base
|
|
766
872
|
|
|
767
873
|
|
|
874
|
+
WEIGHT_JSON, WEIGHT_MD = ".ai/policies/weight.json", ".ai/policies/weight.md"
|
|
875
|
+
_WEIGHT = None
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def weight_module():
|
|
879
|
+
"""skills/ai-task/weight.py, the renderer behind `state.py weight --mirror`."""
|
|
880
|
+
global _WEIGHT # pylint: disable=global-statement # loaded once per run
|
|
881
|
+
if _WEIGHT is None:
|
|
882
|
+
spec = importlib.util.spec_from_file_location("riskrails_weight", os.path.join(SKILLS, "ai-task", "weight.py"))
|
|
883
|
+
_WEIGHT = importlib.util.module_from_spec(spec)
|
|
884
|
+
spec.loader.exec_module(_WEIGHT)
|
|
885
|
+
return _WEIGHT
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def render_weight(data):
|
|
889
|
+
"""weight.md for the weight.json bytes `data`; the renderer reads a path."""
|
|
890
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
891
|
+
path = os.path.join(tmp, "weight.json")
|
|
892
|
+
with open(path, "wb") as fh:
|
|
893
|
+
fh.write(data)
|
|
894
|
+
return weight_module().mirror(path).encode("utf-8")
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def weight_mirror(plan, history, theirs):
|
|
898
|
+
"""weight.md is generated from weight.json. A mirror that is exactly the render of
|
|
899
|
+
the project's JSON on disk is rendered again from the JSON this run leaves behind:
|
|
900
|
+
merged as text with the template's, whose sha256 line names the template's JSON, it
|
|
901
|
+
was a conflict on every update. A mirror edited by hand, or stale, merges as before."""
|
|
902
|
+
ours = plan.read(WEIGHT_MD)
|
|
903
|
+
path = os.path.join(plan.root, WEIGHT_JSON)
|
|
904
|
+
disk = read(path) if os.path.isfile(path) and not os.path.islink(path) else None
|
|
905
|
+
after = plan.read(WEIGHT_JSON)
|
|
906
|
+
fresh = None
|
|
907
|
+
rendering_raised = False
|
|
908
|
+
if ours is not None and disk is not None and after is not None:
|
|
909
|
+
try:
|
|
910
|
+
if ours == render_weight(disk):
|
|
911
|
+
fresh = render_weight(after)
|
|
912
|
+
except Exception as exc: # pylint: disable=broad-except
|
|
913
|
+
# not a table the renderer reads (or too deep, or no temp dir, or no renderer):
|
|
914
|
+
# the text merge decides, and it is always safe
|
|
915
|
+
fresh = None
|
|
916
|
+
rendering_raised = True
|
|
917
|
+
# L3: say so, so a renderer bug is not read as a hand-edited mirror
|
|
918
|
+
plan.hints.append("%s: not rendered from weight.json (%s); merged as text"
|
|
919
|
+
% (WEIGHT_MD, type(exc).__name__))
|
|
920
|
+
if fresh is None:
|
|
921
|
+
three_way(plan, history, "ai-init/" + WEIGHT_MD, WEIGHT_MD, theirs)
|
|
922
|
+
# L4: not when the renderer raised: `state.py weight --mirror` would fail the same way
|
|
923
|
+
stale = None if rendering_raised else stale_mirror_hint(ours, disk)
|
|
924
|
+
if stale:
|
|
925
|
+
plan.hints.append(stale)
|
|
926
|
+
elif fresh != ours:
|
|
927
|
+
plan.add("update", WEIGHT_MD, "rendered from weight.json", content=fresh)
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def stale_mirror_hint(ours, disk):
|
|
931
|
+
"""L3: the sha256 in weight.md's header names a weight.json other than the one on disk
|
|
932
|
+
(an --apply interrupted between the two writes, or a JSON edited without regenerating)."""
|
|
933
|
+
if ours is None or disk is None:
|
|
934
|
+
return None
|
|
935
|
+
head = ours[:2000]
|
|
936
|
+
found = re.search(r"^<!--.*\bsha256:\s*([0-9a-fA-F]{64})\b.*-->", head if isinstance(head, str) else head.decode("utf-8", "replace"), re.M)
|
|
937
|
+
if not found or found.group(1).lower() == hashlib.sha256(disk if isinstance(disk, bytes) else disk.encode("utf-8")).hexdigest():
|
|
938
|
+
return None
|
|
939
|
+
return ("%s: rendered from a different weight.json than the one on disk (stale: an interrupted "
|
|
940
|
+
"--apply, or the JSON edited without regenerating) — `state.py weight --mirror` "
|
|
941
|
+
"regenerates it from the JSON and REPLACES any hand edits in it, so keep or move "
|
|
942
|
+
"them first" % WEIGHT_MD)
|
|
943
|
+
|
|
944
|
+
|
|
768
945
|
def three_way(plan, history, key, target, theirs):
|
|
769
946
|
ours = plan.read(target)
|
|
770
947
|
if ours is None:
|
|
771
948
|
plan.add("create", target, content=theirs)
|
|
772
949
|
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):
|
|
950
|
+
if ours == theirs:
|
|
776
951
|
return
|
|
777
|
-
versions = [v for v in history.versions(key) if
|
|
778
|
-
if any(
|
|
952
|
+
versions = [v for v in history.versions(key) if v != theirs]
|
|
953
|
+
if any(v == ours for v in versions):
|
|
779
954
|
policy = None
|
|
780
955
|
if target.endswith(".json") and "/policies/" in "/" + target:
|
|
781
956
|
try:
|
|
782
957
|
policy = leaf_changes(json.loads(ours, object_pairs_hook=OrderedDict),
|
|
783
958
|
json.loads(theirs, object_pairs_hook=OrderedDict))
|
|
784
|
-
except ValueError:
|
|
959
|
+
except (ValueError, RecursionError): # not JSON, or too deep to read or flatten: no policy lines
|
|
785
960
|
policy = None
|
|
786
961
|
plan.add("update", target, "unchanged since it was installed",
|
|
787
|
-
content=
|
|
962
|
+
content=theirs, policy=policy)
|
|
788
963
|
return
|
|
789
964
|
|
|
790
965
|
if target.endswith(".json"):
|
|
@@ -795,38 +970,39 @@ def three_way(plan, history, key, target, theirs):
|
|
|
795
970
|
except ValueError:
|
|
796
971
|
plan.add("conflict", target, "not valid JSON; not merged", conflict_copy=theirs)
|
|
797
972
|
return
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
973
|
+
except RecursionError: # parsing, flatten or merge_json past the recursion limit
|
|
974
|
+
plan.add("conflict", target, "nested too deeply; not merged", conflict_copy=theirs)
|
|
975
|
+
return
|
|
976
|
+
try:
|
|
977
|
+
base = closest(bases, o, json_score) if bases else MISSING
|
|
978
|
+
conflicts = []
|
|
979
|
+
merged = merge_json(base, o, t, [], conflicts)
|
|
980
|
+
policy = leaf_changes(o, merged) if "/policies/" in "/" + target else None
|
|
981
|
+
content = dump_json(merged) if merged != o else None
|
|
982
|
+
except RecursionError:
|
|
983
|
+
plan.add("conflict", target, "nested too deeply; not merged", conflict_copy=theirs)
|
|
984
|
+
return
|
|
802
985
|
if merged == o and not conflicts:
|
|
803
986
|
return
|
|
804
987
|
if merged != o:
|
|
805
|
-
plan.add("merge", target, "your edits kept", content=
|
|
988
|
+
plan.add("merge", target, "your edits kept", content=content, policy=policy)
|
|
806
989
|
if conflicts:
|
|
807
990
|
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
991
|
return
|
|
816
992
|
|
|
817
993
|
if target == ROUTER_FILE and len(SHARED_SECTION_RE.findall(ours)) == 1:
|
|
818
994
|
router_merge(plan, ours, versions, theirs)
|
|
819
995
|
return
|
|
820
|
-
base = closest(versions,
|
|
821
|
-
merged = merge_text(
|
|
996
|
+
base = closest(versions, ours, text_ratio) if versions else None
|
|
997
|
+
merged = merge_text(ours, base, theirs) if base is not None else None
|
|
822
998
|
if merged is not None and target == ROUTER_FILE and len(SHARED_SECTION_RE.findall(merged)) != 1:
|
|
823
999
|
plan.add("conflict", target, "the merge would not name the shared files exactly once "
|
|
824
1000
|
"(## Shared files)", conflict_copy=theirs)
|
|
825
1001
|
return
|
|
826
1002
|
if merged is None:
|
|
827
1003
|
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=
|
|
1004
|
+
elif merged != ours:
|
|
1005
|
+
plan.add("merge", target, "your edits kept", content=merged)
|
|
830
1006
|
|
|
831
1007
|
|
|
832
1008
|
def split_shared_section(text):
|
|
@@ -838,8 +1014,9 @@ def split_shared_section(text):
|
|
|
838
1014
|
return None
|
|
839
1015
|
start = found[0].start()
|
|
840
1016
|
nxt = re.compile(rb"(?m)^## ").search(text, found[0].end())
|
|
841
|
-
if nxt is None:
|
|
842
|
-
|
|
1017
|
+
if nxt is None: # at the end: the blank line before it belongs to the section
|
|
1018
|
+
head = text[:start]
|
|
1019
|
+
return (head[:-1] if head.endswith(b"\n\n") else head), text[start:], b""
|
|
843
1020
|
end = nxt.start()
|
|
844
1021
|
eol = text.find(b"\n", end)
|
|
845
1022
|
follow = text[end:eol + 1] if eol >= 0 else text[end:]
|
|
@@ -863,7 +1040,9 @@ def router_merge(plan, ours, versions, theirs):
|
|
|
863
1040
|
if o_rest == t_rest or (base is not None and base == t_rest):
|
|
864
1041
|
return
|
|
865
1042
|
merged = merge_text(o_rest, base, t_rest) if base is not None else None
|
|
866
|
-
if merged is not None:
|
|
1043
|
+
if merged is not None and not follow: # the project had it at the end: it stays there
|
|
1044
|
+
merged = merged + (b"" if merged.endswith(b"\n") else b"\n") + b"\n" + o_sec
|
|
1045
|
+
elif merged is not None:
|
|
867
1046
|
merged = put_section(merged, o_sec, [follow, t_split[2] if t_split else b""])
|
|
868
1047
|
if merged is None or len(SHARED_SECTION_RE.findall(merged)) != 1:
|
|
869
1048
|
plan.add("conflict", ROUTER_FILE, "edited here and changed in the plugin", conflict_copy=theirs)
|
|
@@ -886,27 +1065,6 @@ def put_section(text, section, follows):
|
|
|
886
1065
|
return text + (b"" if text.endswith(b"\n") else b"\n") + section
|
|
887
1066
|
|
|
888
1067
|
|
|
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
1068
|
def block_update(plan, history, runtime):
|
|
911
1069
|
name, block_tpl, minimal_tpl = INSTRUCTION_FILE[runtime]
|
|
912
1070
|
if name in plan.removed: # a migration moved this instruction file away
|
|
@@ -985,7 +1143,7 @@ def shared_update(plan, history, version):
|
|
|
985
1143
|
edited.append(path)
|
|
986
1144
|
elif not named:
|
|
987
1145
|
ctx.hint("%s: unedited, but %s does not name the shared files (## Shared files), so the "
|
|
988
|
-
"copy stays — restore that section, then run /
|
|
1146
|
+
"copy stays — restore that section, then run /rr-setup --update again"
|
|
989
1147
|
% (path, ROUTER_FILE))
|
|
990
1148
|
else:
|
|
991
1149
|
candidates.append(path)
|
|
@@ -1008,14 +1166,201 @@ def shared_update(plan, history, version):
|
|
|
1008
1166
|
"adds nothing" % (len(edited), ", ".join(edited), SHARED))
|
|
1009
1167
|
|
|
1010
1168
|
|
|
1169
|
+
def retire_templates(plan, history, retired):
|
|
1170
|
+
"""[(history key, target)] the plugin now supplies from its own tree. A
|
|
1171
|
+
missing copy stays missing; an unedited one is offered for deletion, same
|
|
1172
|
+
gate as a shared override; an edited one is a project override, named once."""
|
|
1173
|
+
edited = []
|
|
1174
|
+
for key, target in retired:
|
|
1175
|
+
data = plan.read(target)
|
|
1176
|
+
if data is None:
|
|
1177
|
+
continue
|
|
1178
|
+
known = history.hashes(key)
|
|
1179
|
+
if sha(data) not in known and sha(data.replace(b"\r\n", b"\n")) not in known:
|
|
1180
|
+
edited.append(target)
|
|
1181
|
+
continue
|
|
1182
|
+
why = "[sdlc] the plugin supplies it: skills/project-init/templates/" + key.split("/", 1)[1]
|
|
1183
|
+
if plan.confirm_delete:
|
|
1184
|
+
plan.add("delete", target, why, reason=why)
|
|
1185
|
+
plan.remove(target)
|
|
1186
|
+
else:
|
|
1187
|
+
plan.add("delete?", target, why, reason=why)
|
|
1188
|
+
if edited:
|
|
1189
|
+
plan.hints.append("%d file(s) override the plugin's SDLC template and no longer receive its "
|
|
1190
|
+
"changes: %s" % (len(edited), ", ".join(edited)))
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
def task_journal(root, task_id):
|
|
1194
|
+
"""(finished, abandoned, last event as a datetime) from the task's journal,
|
|
1195
|
+
or None when it holds no readable event. A task is finished when its journal
|
|
1196
|
+
has task_closed or gate_approved, or when logs/<id>/state.json records a
|
|
1197
|
+
result (so a torn task_closed line does not make a closed task look unclosed)."""
|
|
1198
|
+
closed = abandoned = False
|
|
1199
|
+
last = None
|
|
1200
|
+
try:
|
|
1201
|
+
with open(os.path.join(root, ".ai", "reports", task_id, "events.jsonl"), encoding="utf-8") as fh:
|
|
1202
|
+
lines = fh.read().splitlines()
|
|
1203
|
+
except (OSError, UnicodeDecodeError):
|
|
1204
|
+
return None
|
|
1205
|
+
for line in lines:
|
|
1206
|
+
try:
|
|
1207
|
+
ev = json.loads(line)
|
|
1208
|
+
when = datetime.strptime(ev["ts"][:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
|
|
1209
|
+
except (ValueError, KeyError, TypeError, RecursionError):
|
|
1210
|
+
continue
|
|
1211
|
+
if last is None or when > last:
|
|
1212
|
+
last = when
|
|
1213
|
+
if ev.get("event") == "task_closed":
|
|
1214
|
+
closed = True
|
|
1215
|
+
data = ev.get("data")
|
|
1216
|
+
abandoned = (isinstance(data, dict) and data.get("abandoned") is True) or ev.get("detail") == "abandoned"
|
|
1217
|
+
elif ev.get("event") == "gate_approved":
|
|
1218
|
+
closed = True
|
|
1219
|
+
try:
|
|
1220
|
+
with open(os.path.join(root, ".ai", "state", "logs", task_id, "state.json"), encoding="utf-8") as fh:
|
|
1221
|
+
result = json.load(fh).get("result")
|
|
1222
|
+
except (OSError, ValueError, AttributeError, UnicodeDecodeError, RecursionError):
|
|
1223
|
+
result = None
|
|
1224
|
+
if isinstance(result, str) and result:
|
|
1225
|
+
closed = True
|
|
1226
|
+
abandoned = abandoned or result == "abandoned"
|
|
1227
|
+
return None if last is None else (closed, abandoned, last)
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
def cleanup_update(plan, now=None):
|
|
1231
|
+
"""Old run logs and stale unclosed tasks, proposed for deletion and never
|
|
1232
|
+
deleted unasked (only with --confirm-cleanup). A closed task's `*.log` files
|
|
1233
|
+
go (its state.json stays); an abandoned task loses every file under
|
|
1234
|
+
.ai/reports/<id>/ and .ai/state/logs/<id>/ except that state.json; an unclosed
|
|
1235
|
+
one idle for STALE_DAYS loses every file in both. The task in flight is
|
|
1236
|
+
never touched. `now` is a datetime; RISKRAILS_NOW (ISO, Z) overrides it."""
|
|
1237
|
+
if now is None:
|
|
1238
|
+
try:
|
|
1239
|
+
now = datetime.strptime(env("NOW") or utc_now(), "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
|
1240
|
+
except ValueError:
|
|
1241
|
+
print("project-update: RISKRAILS_NOW %r is not a UTC time like 2026-01-31T12:00:00Z" % env("NOW"),
|
|
1242
|
+
file=sys.stderr)
|
|
1243
|
+
raise SystemExit(2) from None
|
|
1244
|
+
flight = None
|
|
1245
|
+
for name in (STATE_FILE, LEGACY_STATE_FILE):
|
|
1246
|
+
raw = plan.read(name)
|
|
1247
|
+
if raw is not None:
|
|
1248
|
+
try:
|
|
1249
|
+
flight = json.loads(raw).get("task_id")
|
|
1250
|
+
except (ValueError, AttributeError, RecursionError):
|
|
1251
|
+
return # unreadable: a task may be in flight, so no task is proposed as stale
|
|
1252
|
+
break
|
|
1253
|
+
reports =os.path.join(plan.root, ".ai", "reports")
|
|
1254
|
+
logs = os.path.join(plan.root, ".ai", "state", "logs")
|
|
1255
|
+
|
|
1256
|
+
def files(base, rel, only_logs=False, keep=()):
|
|
1257
|
+
out = []
|
|
1258
|
+
for here, _dirs, names in os.walk(os.path.join(base, rel)):
|
|
1259
|
+
for n in names:
|
|
1260
|
+
full = os.path.join(here, n)
|
|
1261
|
+
if os.path.isfile(full) and not os.path.islink(full) and (not only_logs or n.endswith(".log")) \
|
|
1262
|
+
and n not in keep:
|
|
1263
|
+
out.append(os.path.relpath(full, plan.root).replace(os.sep, "/"))
|
|
1264
|
+
return out
|
|
1265
|
+
|
|
1266
|
+
for task_id in sorted(os.listdir(reports)) if os.path.isdir(reports) else []:
|
|
1267
|
+
journal = task_journal(plan.root, task_id)
|
|
1268
|
+
if journal is None or task_id == flight:
|
|
1269
|
+
continue
|
|
1270
|
+
closed, abandoned, last = journal
|
|
1271
|
+
if abandoned:
|
|
1272
|
+
why, targets = "[cleanup] abandoned task %s" % task_id, \
|
|
1273
|
+
files(reports, task_id) + files(logs, task_id, keep=("state.json",))
|
|
1274
|
+
elif not closed and now - last > timedelta(days=STALE_DAYS):
|
|
1275
|
+
why = "[cleanup] unclosed task %s, last event %s" % (task_id, last.strftime("%Y-%m-%d"))
|
|
1276
|
+
targets = files(reports, task_id) + files(logs, task_id)
|
|
1277
|
+
elif closed:
|
|
1278
|
+
why, targets = "[cleanup] log of closed task %s" % task_id, files(logs, task_id, only_logs=True)
|
|
1279
|
+
else:
|
|
1280
|
+
continue
|
|
1281
|
+
# .ai/reports/ is usually git-tracked; only .ai/state/ is git-ignored run state
|
|
1282
|
+
held = git_states(plan.root, ".ai/reports/" + task_id)
|
|
1283
|
+
skipped = set()
|
|
1284
|
+
for target in sorted(targets):
|
|
1285
|
+
unsafe = unsafe_path(plan.root, target)
|
|
1286
|
+
if unsafe: # --apply would abort on it: say so instead of proposing it
|
|
1287
|
+
if unsafe not in skipped:
|
|
1288
|
+
skipped.add(unsafe)
|
|
1289
|
+
plan.hints.append("%s: not proposed for deletion, %s — remove it yourself if it should go"
|
|
1290
|
+
% (target, unsafe))
|
|
1291
|
+
continue
|
|
1292
|
+
note = why
|
|
1293
|
+
state = held(target) if target.startswith(".ai/reports/") else None
|
|
1294
|
+
if state == "tracked":
|
|
1295
|
+
note += "; tracked: git can restore a committed copy"
|
|
1296
|
+
elif state == "uncommitted":
|
|
1297
|
+
note += "; uncommitted: git holds no copy of it"
|
|
1298
|
+
elif state == "ignored":
|
|
1299
|
+
note += "; git-ignored: not recoverable through git"
|
|
1300
|
+
if state in ("tracked", "uncommitted"):
|
|
1301
|
+
plan.git_kept.add(target)
|
|
1302
|
+
if plan.confirm_cleanup:
|
|
1303
|
+
plan.add("delete", target, note, reason=why)
|
|
1304
|
+
plan.remove(target)
|
|
1305
|
+
else:
|
|
1306
|
+
plan.add("delete?", target, note, reason=why)
|
|
1307
|
+
|
|
1308
|
+
|
|
1011
1309
|
def deletion_hint(plan):
|
|
1012
|
-
"""M4: git is the rollback of a deletion; it must hold .ai/ first.
|
|
1013
|
-
|
|
1310
|
+
"""M4: git is the rollback of a migration's deletion; it must hold .ai/ first.
|
|
1311
|
+
A [cleanup] deletion of git-ignored run state is not counted: git does not roll it back;
|
|
1312
|
+
one under .ai/reports/ that git tracks, or should, is."""
|
|
1313
|
+
if any(i["action"] in ("delete?", "delete") and i["target"].startswith(".ai/")
|
|
1314
|
+
and (not (i.get("reason") or "").startswith("[cleanup]") or i["target"] in plan.git_kept)
|
|
1315
|
+
for i in plan.items) \
|
|
1014
1316
|
and git_dirty_ai(plan.root):
|
|
1015
1317
|
plan.hints.append("commit .ai/ first: git is this migration's rollback "
|
|
1016
1318
|
"(`git status -- .ai` lists what it does not hold yet)")
|
|
1017
1319
|
|
|
1018
1320
|
|
|
1321
|
+
TRACKED_IGNORED_SHOWN = 10 # per-file hints before tracked_ignored_hint counts the rest
|
|
1322
|
+
|
|
1323
|
+
|
|
1324
|
+
def tracked_ignored_hint(plan):
|
|
1325
|
+
"""A file under .ai/state that git tracks although an ignore rule lists it: the rule
|
|
1326
|
+
has no effect on it. Read-only: the human untracks it; nothing is planned.
|
|
1327
|
+
At most TRACKED_IGNORED_SHOWN files get their own hint; one more line counts the rest."""
|
|
1328
|
+
try:
|
|
1329
|
+
res = subprocess.run(["git", "-C", plan.root, "ls-files", "-z", "-ci", "--exclude-standard",
|
|
1330
|
+
"--", ".ai/state"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
|
1331
|
+
timeout=30, check=False)
|
|
1332
|
+
except (OSError, subprocess.SubprocessError):
|
|
1333
|
+
return
|
|
1334
|
+
if res.returncode != 0:
|
|
1335
|
+
return
|
|
1336
|
+
# --literal-pathspecs: a * ? [ ] in a name is that name, not a pattern that matches its neighbours.
|
|
1337
|
+
all_cmd = "git ls-files -z -ci --exclude-standard -- .ai/state | xargs -0 -r git --literal-pathspecs rm --cached --"
|
|
1338
|
+
names = [os.fsdecode(raw) for raw in res.stdout.split(b"\0") if raw]
|
|
1339
|
+
for path in names[:TRACKED_IGNORED_SHOWN]:
|
|
1340
|
+
# A control character (or a byte that is not UTF-8, which fsdecode keeps as a lone
|
|
1341
|
+
# surrogate) in the name would forge a report line or break the copy-paste command:
|
|
1342
|
+
# show it escaped, and untrack by the NUL-safe listing.
|
|
1343
|
+
shown = "".join(c if c.isprintable() else repr(c)[1:-1] for c in path)
|
|
1344
|
+
if shown == path:
|
|
1345
|
+
plan.hints.append("%s: tracked by git but ignored by an ignore rule — the rule has no "
|
|
1346
|
+
"effect on a tracked file, so every run leaves the tree dirty and "
|
|
1347
|
+
"`git commit -a` sweeps it in; untrack it yourself, from the project root: "
|
|
1348
|
+
"`git --literal-pathspecs rm --cached -- %s` and commit (the file stays on disk)"
|
|
1349
|
+
% (shown, shlex.quote(path)))
|
|
1350
|
+
else:
|
|
1351
|
+
plan.hints.append("%s: tracked by git but ignored by an ignore rule — the rule has no "
|
|
1352
|
+
"effect on a tracked file, so every run leaves the tree dirty and "
|
|
1353
|
+
"`git commit -a` sweeps it in; this name cannot be typed safely, so "
|
|
1354
|
+
"untrack it, from the project root, with `%s`, which untracks EVERY tracked, ignored file "
|
|
1355
|
+
"under .ai/state, not only this one, and commit (the files stay on disk)"
|
|
1356
|
+
% (shown, all_cmd))
|
|
1357
|
+
if len(names) > TRACKED_IGNORED_SHOWN:
|
|
1358
|
+
plan.hints.append(".ai/state: %d more file(s) tracked by git but ignored by an ignore rule, "
|
|
1359
|
+
"not listed; untrack ALL tracked, ignored files under .ai/state "
|
|
1360
|
+
"(the ones listed above too), from the project root, with `%s` and commit (the files stay on disk)"
|
|
1361
|
+
% (len(names) - TRACKED_IGNORED_SHOWN, all_cmd))
|
|
1362
|
+
|
|
1363
|
+
|
|
1019
1364
|
def rendered_rules(plan):
|
|
1020
1365
|
"""Every (file, slug) in the project that carries a rule block today, plus
|
|
1021
1366
|
every path-scoped copy under .claude/rules/. Walked rather than remembered:
|
|
@@ -1058,6 +1403,41 @@ def nested_tree(root, rel_dir):
|
|
|
1058
1403
|
return None
|
|
1059
1404
|
|
|
1060
1405
|
|
|
1406
|
+
def unwalked_dir(root, rel_dir):
|
|
1407
|
+
"""Why the take-back walk (rendered_rules) never enters `rel_dir`, or None:
|
|
1408
|
+
a RULES_SKIP name on the way, or a symlinked directory on the way."""
|
|
1409
|
+
here = root
|
|
1410
|
+
for part in (p for p in rel_dir.split("/") if p not in ("", ".")):
|
|
1411
|
+
here = os.path.join(here, part)
|
|
1412
|
+
if part.casefold() in RULES_SKIP_FOLDED:
|
|
1413
|
+
return "%s is under a directory the take-back never walks (%s)" % (rel_dir, part)
|
|
1414
|
+
if os.path.islink(here):
|
|
1415
|
+
return "%s is a symlink, which the take-back never follows" % os.path.relpath(here, root).replace(os.sep, "/")
|
|
1416
|
+
return None
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
def _frozen_block(plan, target, slug):
|
|
1420
|
+
"""True when `target` is a readable regular file inside the project, holding a
|
|
1421
|
+
block for `slug`, at a place the take-back walk (rendered_rules) never enters: a
|
|
1422
|
+
RULES_SKIP name or a nested git tree on its real path. A symlink that leads inside
|
|
1423
|
+
the walked part of the project is removed by the same run; one that leads outside
|
|
1424
|
+
the project, a directory, a FIFO or an unreadable file is not read at all."""
|
|
1425
|
+
full = os.path.join(plan.root, target)
|
|
1426
|
+
try:
|
|
1427
|
+
real = os.path.realpath(full)
|
|
1428
|
+
base = os.path.realpath(plan.root)
|
|
1429
|
+
if os.path.commonpath([real, base]) != base or not os.path.isfile(real):
|
|
1430
|
+
return False
|
|
1431
|
+
rel_dir = os.path.relpath(os.path.dirname(real), base).replace(os.sep, "/")
|
|
1432
|
+
parts = [p for p in rel_dir.split("/") if p not in ("", ".")]
|
|
1433
|
+
if not (any(p in RULES_SKIP for p in parts) or nested_tree(base, rel_dir) is not None):
|
|
1434
|
+
return False
|
|
1435
|
+
held = plan.read(target)
|
|
1436
|
+
except (OSError, ValueError):
|
|
1437
|
+
return False
|
|
1438
|
+
return held is not None and slug in render_instructions.rule_slugs_in(held.decode("utf-8", "replace"))
|
|
1439
|
+
|
|
1440
|
+
|
|
1061
1441
|
def rules_update(plan, runtimes, planned=None):
|
|
1062
1442
|
"""Render `.ai/rules/<slug>.md` into the instruction file of every directory
|
|
1063
1443
|
it names, and take back what a rule no longer asks for. The text around a
|
|
@@ -1094,6 +1474,14 @@ def rules_update(plan, runtimes, planned=None):
|
|
|
1094
1474
|
plan.hints.append("%s: %s is a nested git tree (worktree, repository or submodule)"
|
|
1095
1475
|
" — rule %s is not rendered there" % (target, tree, item["slug"]))
|
|
1096
1476
|
continue
|
|
1477
|
+
skip = unwalked_dir(plan.root, os.path.dirname(target)) if item["kind"] == "block" else None
|
|
1478
|
+
if skip is not None:
|
|
1479
|
+
hint = "%s: %s — rule %s is not rendered there" % (target, skip, item["slug"])
|
|
1480
|
+
if _frozen_block(plan, target, item["slug"]):
|
|
1481
|
+
hint += ("; an earlier rendering of it is still there, the take-back never walks there:"
|
|
1482
|
+
" remove that block by hand")
|
|
1483
|
+
plan.hints.append(hint)
|
|
1484
|
+
continue
|
|
1097
1485
|
current = plan.read(target)
|
|
1098
1486
|
if item["kind"] == "doc":
|
|
1099
1487
|
new = content.encode()
|
|
@@ -1198,8 +1586,10 @@ def constitution_hint(plan):
|
|
|
1198
1586
|
plan.hints.append(line.replace(path, rel))
|
|
1199
1587
|
|
|
1200
1588
|
|
|
1201
|
-
def build_plan(root, confirm_delete=None, confirm_via=None):
|
|
1589
|
+
def build_plan(root, confirm_delete=None, confirm_via=None, confirm_cleanup=None, cleanup_via=None):
|
|
1202
1590
|
plan = Plan(root)
|
|
1591
|
+
plan.confirm_cleanup = confirm_cleanup
|
|
1592
|
+
plan.cleanup_via = cleanup_via
|
|
1203
1593
|
plan.confirm_delete = confirm_delete
|
|
1204
1594
|
plan.confirm_via = confirm_via
|
|
1205
1595
|
has_ai = os.path.isdir(os.path.join(root, ".ai"))
|
|
@@ -1231,7 +1621,7 @@ def build_plan(root, confirm_delete=None, confirm_via=None):
|
|
|
1231
1621
|
if stuck:
|
|
1232
1622
|
plan.held = version
|
|
1233
1623
|
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 /
|
|
1624
|
+
"deletion with --apply --confirm-delete NAME, then run /rr-setup --update again"
|
|
1235
1625
|
% (version, ", ".join("%s (%s)" % (i["target"], i["action"]) for i in stuck)))
|
|
1236
1626
|
else:
|
|
1237
1627
|
# Last of the migration items: an apply that stops short leaves the
|
|
@@ -1243,18 +1633,22 @@ def build_plan(root, confirm_delete=None, confirm_via=None):
|
|
|
1243
1633
|
if has_sdlc:
|
|
1244
1634
|
tpl = TEMPLATES["project-init"]
|
|
1245
1635
|
entries = list(PROJECT_INIT_MAP)
|
|
1636
|
+
retired = []
|
|
1246
1637
|
for rt in runtimes:
|
|
1247
1638
|
entries += RUNTIME_MAP[rt]
|
|
1248
1639
|
for src, target, kind in entries:
|
|
1249
1640
|
content = read(os.path.join(tpl, src))
|
|
1250
1641
|
if target in plan.removed: # a migration moved it away in this run
|
|
1251
1642
|
continue
|
|
1252
|
-
if kind == "
|
|
1643
|
+
if kind == "retire":
|
|
1644
|
+
retired.append(("project-init/" + src, target))
|
|
1645
|
+
elif kind == "create":
|
|
1253
1646
|
if not plan.exists(target):
|
|
1254
1647
|
plan.add("create", target, content=content.replace(
|
|
1255
1648
|
b"{{PROJECT}}", os.path.basename(root).encode()))
|
|
1256
1649
|
else:
|
|
1257
1650
|
three_way(plan, history, "project-init/" + src, target, content)
|
|
1651
|
+
retire_templates(plan, history, retired)
|
|
1258
1652
|
gitignore_update(plan, os.path.join(tpl, "gitignore.snippet"))
|
|
1259
1653
|
|
|
1260
1654
|
if has_ai:
|
|
@@ -1263,7 +1657,7 @@ def build_plan(root, confirm_delete=None, confirm_via=None):
|
|
|
1263
1657
|
for dirpath, _, names in os.walk(os.path.join(tpl, ".ai")):
|
|
1264
1658
|
for n in names:
|
|
1265
1659
|
files.append(os.path.relpath(os.path.join(dirpath, n), tpl))
|
|
1266
|
-
# JSON before markdown, so the
|
|
1660
|
+
# JSON before markdown, so the weight mirror is rendered from the merged JSON.
|
|
1267
1661
|
for rel in sorted(files, key=lambda r: (not r.endswith(".json"), r)):
|
|
1268
1662
|
if rel == VERSION_FILE: # owned by the migration step, never merged
|
|
1269
1663
|
continue
|
|
@@ -1276,13 +1670,18 @@ def build_plan(root, confirm_delete=None, confirm_via=None):
|
|
|
1276
1670
|
if not plan.exists(rel):
|
|
1277
1671
|
plan.add("create", rel, content=content)
|
|
1278
1672
|
continue
|
|
1673
|
+
if rel == WEIGHT_MD:
|
|
1674
|
+
weight_mirror(plan, history, content)
|
|
1675
|
+
continue
|
|
1279
1676
|
three_way(plan, history, "ai-init/" + rel, rel, content)
|
|
1280
1677
|
shared_update(plan, history, version)
|
|
1678
|
+
cleanup_update(plan)
|
|
1281
1679
|
deletion_hint(plan)
|
|
1282
1680
|
for rt in runtimes:
|
|
1283
1681
|
block_update(plan, history, rt)
|
|
1284
1682
|
rules_update(plan, runtimes)
|
|
1285
1683
|
gitignore_update(plan, os.path.join(tpl, "gitignore.snippet"))
|
|
1684
|
+
tracked_ignored_hint(plan)
|
|
1286
1685
|
|
|
1287
1686
|
testing = plan.read(".ai/policies/testing.md")
|
|
1288
1687
|
if testing is not None:
|
|
@@ -1290,8 +1689,8 @@ def build_plan(root, confirm_delete=None, confirm_via=None):
|
|
|
1290
1689
|
("verify_command", "the feedback loop needs it"),
|
|
1291
1690
|
("step_test_command", "a step runs only its own tests"),
|
|
1292
1691
|
("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;
|
|
1692
|
+
("lint_command", "a sensor reads it; read the project's config files for it, and write 'none' if there is none"),
|
|
1693
|
+
("typecheck_command", "a sensor reads it; read the project's config files for it, and write 'none' if there is none"),
|
|
1295
1694
|
):
|
|
1296
1695
|
if re.search(rb"(?m)^" + field.encode() + rb":[ \t]*(#.*)?$", testing):
|
|
1297
1696
|
plan.hints.append(".ai/policies/testing.md: %s is empty — %s" % (field, why))
|
|
@@ -1383,7 +1782,7 @@ def record_migration(plan, ops, deletions):
|
|
|
1383
1782
|
if os.path.isfile(full):
|
|
1384
1783
|
try:
|
|
1385
1784
|
existing = json.loads(read(full), object_pairs_hook=OrderedDict)
|
|
1386
|
-
except ValueError:
|
|
1785
|
+
except (ValueError, RecursionError): # too deep reads as malformed
|
|
1387
1786
|
existing = {}
|
|
1388
1787
|
if not isinstance(existing, dict): # hand-edited into another shape
|
|
1389
1788
|
existing = {}
|
|
@@ -1432,16 +1831,25 @@ def apply(plan):
|
|
|
1432
1831
|
|
|
1433
1832
|
def apply_item(plan, item, written, recorded):
|
|
1434
1833
|
target, action = item["target"], item["action"]
|
|
1834
|
+
cleanup = (item.get("reason") or "").startswith("[cleanup]")
|
|
1435
1835
|
if action == "delete?": # listed only, until a human confirms it
|
|
1436
1836
|
return
|
|
1437
1837
|
mode = None
|
|
1438
1838
|
if action in ("move", "delete"):
|
|
1439
1839
|
source = item["src"] if action == "move" else target
|
|
1840
|
+
# the plan-time symlink check is repeated: a path swapped since is refused
|
|
1841
|
+
why = unsafe_path(plan.root, source)
|
|
1842
|
+
if why:
|
|
1843
|
+
raise Abort(item, why)
|
|
1440
1844
|
check(plan, item, source, item["src_expect"] if action == "move" else item["expect"], written)
|
|
1441
1845
|
if action == "move":
|
|
1442
1846
|
check(plan, item, target, item["expect"], written)
|
|
1443
1847
|
mode = os.stat(os.path.join(plan.root, source)).st_mode & 0o7777
|
|
1444
|
-
|
|
1848
|
+
# git-ignored task state: a move is byte-identical; a [cleanup] deletion of it is NOT
|
|
1849
|
+
# recoverable through git (the logs are git-ignored), and copying what it removes
|
|
1850
|
+
# would defeat the cleanup, so no original/ copy is kept. A report file git tracks,
|
|
1851
|
+
# or should, is kept like any other deletion.
|
|
1852
|
+
if not source.startswith(".ai/state/") and (not cleanup or source in plan.git_kept):
|
|
1445
1853
|
keep_original(plan, source)
|
|
1446
1854
|
recorded.append(target)
|
|
1447
1855
|
# Recorded before the file is gone: an abort later must not lose the note
|
|
@@ -1449,7 +1857,8 @@ def apply_item(plan, item, written, recorded):
|
|
|
1449
1857
|
record_migration(plan,
|
|
1450
1858
|
[dict(action=action, migration=item["migration"],
|
|
1451
1859
|
**(dict(src=item["src"], dst=target) if action == "move" else dict(path=target)))],
|
|
1452
|
-
[dict(path=target, confirmed_by=plan.
|
|
1860
|
+
[dict(path=target, confirmed_by=plan.confirm_cleanup if cleanup else plan.confirm_delete,
|
|
1861
|
+
via=plan.cleanup_via if cleanup else plan.confirm_via, at=utc_now())]
|
|
1453
1862
|
if action == "delete" else [])
|
|
1454
1863
|
elif item["content"] is not None:
|
|
1455
1864
|
check(plan, item, target, item["expect"], written)
|
|
@@ -1469,10 +1878,25 @@ def apply_item(plan, item, written, recorded):
|
|
|
1469
1878
|
elif action == "delete":
|
|
1470
1879
|
os.remove(os.path.join(plan.root, target))
|
|
1471
1880
|
written[target] = None
|
|
1881
|
+
if (item["reason"] or "").startswith("[cleanup]"):
|
|
1882
|
+
prune_empty(plan.root, target)
|
|
1472
1883
|
if item["conflict_copy"] is not None:
|
|
1473
1884
|
write_file(plan.root, os.path.join(plan.local_dir, target), item["conflict_copy"])
|
|
1474
1885
|
|
|
1475
1886
|
|
|
1887
|
+
def prune_empty(root, target):
|
|
1888
|
+
"""The directories a cleanup deletion left empty, up to (not including)
|
|
1889
|
+
.ai/reports and .ai/state/logs; a directory with anything in it stays."""
|
|
1890
|
+
top = ".ai/state/logs" if target.startswith(".ai/state/logs/") else ".ai/reports"
|
|
1891
|
+
here = os.path.dirname(target)
|
|
1892
|
+
while here.startswith(top + "/"):
|
|
1893
|
+
try:
|
|
1894
|
+
os.rmdir(os.path.join(root, here))
|
|
1895
|
+
except OSError:
|
|
1896
|
+
return
|
|
1897
|
+
here = os.path.dirname(here)
|
|
1898
|
+
|
|
1899
|
+
|
|
1476
1900
|
def plain_pending(root, ignore=()):
|
|
1477
1901
|
"""True when the plain --check would exit 1: an automatic item or a schema
|
|
1478
1902
|
step is pending. Targets in `ignore` (an adopt resuming its own writes) do
|
|
@@ -1523,20 +1947,42 @@ def report(plan, applied):
|
|
|
1523
1947
|
print(" up to date with the installed plugin")
|
|
1524
1948
|
def shown(i):
|
|
1525
1949
|
return "%s -> %s" % (i["src"], i["target"]) if i["src"] else i["target"]
|
|
1526
|
-
|
|
1950
|
+
# a cleanup is one line per directory, however many files it holds
|
|
1951
|
+
rows, groups = [], {}
|
|
1952
|
+
for i in plan.items:
|
|
1953
|
+
if (i["reason"] or "").startswith("[cleanup]"):
|
|
1954
|
+
parts = i["target"].split("/")
|
|
1955
|
+
where = "/".join(parts[:4] if i["target"].startswith(".ai/state/logs/") else parts[:3])
|
|
1956
|
+
key = (where, i["action"], i["note"])
|
|
1957
|
+
if key in groups:
|
|
1958
|
+
groups[key]["count"] += 1
|
|
1959
|
+
continue
|
|
1960
|
+
row = dict(i, migration=None, src=None, count=1)
|
|
1961
|
+
groups[key] = row
|
|
1962
|
+
row["target"] = where
|
|
1963
|
+
i = row
|
|
1964
|
+
rows.append(i)
|
|
1965
|
+
for (where, _action, _note), row in groups.items():
|
|
1966
|
+
row["target"] = "%s/ (%d file%s)" % (where, row["count"], "" if row["count"] == 1 else "s")
|
|
1967
|
+
width = max([len(shown(i)) for i in rows] + [10])
|
|
1527
1968
|
if plan.schema:
|
|
1528
1969
|
frm, to, mods = plan.schema
|
|
1529
1970
|
print(" %-9s %-*s (%d migration%s: %s)" % (
|
|
1530
1971
|
"schema", width, "%d -> %d" % (frm, to), len(mods), "" if len(mods) == 1 else "s",
|
|
1531
1972
|
", ".join("%04d %s" % (m.VERSION, m.TITLE) for m in mods)))
|
|
1532
|
-
for i in
|
|
1973
|
+
for i in rows:
|
|
1533
1974
|
note = i["note"]
|
|
1534
1975
|
if i["migration"] is not None:
|
|
1535
1976
|
note = "[%04d]%s" % (i["migration"], " " + note if note else "")
|
|
1536
1977
|
if i.get("tool") and not note.startswith("[%s]" % i["tool"]):
|
|
1537
1978
|
note = "[%s] %s" % (i["tool"], note)
|
|
1538
1979
|
if i["action"] == "delete?":
|
|
1539
|
-
|
|
1980
|
+
if (i["reason"] or "").startswith("[cleanup]"):
|
|
1981
|
+
note += "; needs --apply --confirm-cleanup NAME"
|
|
1982
|
+
if i["target"].startswith(".ai/state/"):
|
|
1983
|
+
note += " (git-ignored: not recoverable through git)"
|
|
1984
|
+
else:
|
|
1985
|
+
note += "; needs --apply --confirm-delete NAME"
|
|
1540
1986
|
if i["action"] == "conflict" and i["conflict_copy"] is not None:
|
|
1541
1987
|
note += "; plugin version %s %s/%s" % ("at" if applied else "will be at", plan.local_dir, i["target"])
|
|
1542
1988
|
print(" %-9s %-*s %s" % (i["action"], width, shown(i), note))
|
|
@@ -1547,8 +1993,12 @@ def report(plan, applied):
|
|
|
1547
1993
|
tail = ""
|
|
1548
1994
|
if any(i["policy"] for i in auto):
|
|
1549
1995
|
tail += ", %d policy change(s) to confirm" % sum(len(i["policy"] or []) for i in auto)
|
|
1550
|
-
|
|
1551
|
-
|
|
1996
|
+
# the summary names the flag each row names: [cleanup] rows settle with --confirm-cleanup only
|
|
1997
|
+
cleanups = [i for i in deletions if (i["reason"] or "").startswith("[cleanup]")]
|
|
1998
|
+
if len(deletions) > len(cleanups):
|
|
1999
|
+
tail += ", %d deletion(s) awaiting --confirm-delete" % (len(deletions) - len(cleanups))
|
|
2000
|
+
if cleanups:
|
|
2001
|
+
tail += ", %d cleanup file(s) awaiting --confirm-cleanup" % len(cleanups)
|
|
1552
2002
|
print("%d automatic, %d conflict(s)%s" % (len(auto), len(conflicts), tail))
|
|
1553
2003
|
|
|
1554
2004
|
|
|
@@ -1576,30 +2026,42 @@ def main():
|
|
|
1576
2026
|
ap.add_argument("--confirm-delete", metavar="NAME",
|
|
1577
2027
|
help="a human confirms this run's proposed deletions, and is recorded in "
|
|
1578
2028
|
"the migration report; only with --apply")
|
|
2029
|
+
ap.add_argument("--confirm-cleanup", metavar="NAME",
|
|
2030
|
+
help="a human confirms this run's [cleanup] deletions (run logs and stale task files, "
|
|
2031
|
+
"git-ignored and not recoverable through git); only with --apply. "
|
|
2032
|
+
"--confirm-delete does not settle them")
|
|
1579
2033
|
args = ap.parse_args()
|
|
1580
|
-
confirm_via = None
|
|
1581
|
-
|
|
2034
|
+
confirm_via = cleanup_via = None
|
|
2035
|
+
for flag, name in (("--confirm-delete", args.confirm_delete), ("--confirm-cleanup", args.confirm_cleanup)):
|
|
2036
|
+
if name is None:
|
|
2037
|
+
continue
|
|
1582
2038
|
if not args.apply:
|
|
1583
|
-
ap.error("
|
|
1584
|
-
if not
|
|
1585
|
-
ap.error("
|
|
1586
|
-
if adopt.placeholder_name(
|
|
1587
|
-
ap.error("
|
|
1588
|
-
%
|
|
1589
|
-
passed,
|
|
2039
|
+
ap.error("%s only makes sense with --apply" % flag)
|
|
2040
|
+
if not name.strip():
|
|
2041
|
+
ap.error("%s needs the name of the human who confirmed the deletion" % flag)
|
|
2042
|
+
if adopt.placeholder_name(name):
|
|
2043
|
+
ap.error("%s got the placeholder %r; type your own name, it is the audit trail"
|
|
2044
|
+
% (flag, name.strip()))
|
|
2045
|
+
passed, via, why = adopt.human_gate()
|
|
1590
2046
|
if not passed:
|
|
1591
2047
|
# Under an agent-session marker the AI_UNATTENDED route is the launcher's
|
|
1592
2048
|
# to declare, not the agent's to type: the hint names it only otherwise.
|
|
1593
2049
|
hint = "run the same command yourself in a terminal outside Claude Code or Codex"
|
|
1594
2050
|
if "agent session" not in why:
|
|
1595
2051
|
hint += ", or set AI_UNATTENDED=1 if a launcher runs it unattended on your behalf"
|
|
1596
|
-
return adopt.refuse("
|
|
2052
|
+
return adopt.refuse("%s is typed by a human, and %s" % (flag, why), hint)
|
|
2053
|
+
if flag == "--confirm-delete":
|
|
2054
|
+
confirm_via = via
|
|
2055
|
+
else:
|
|
2056
|
+
cleanup_via = via
|
|
1597
2057
|
if args.budget and not args.check:
|
|
1598
2058
|
ap.error("--budget only makes sense with --check")
|
|
1599
2059
|
if not args.adopt and (args.tool or args.mode != "migrate" or args.split_request or args.split or args.diff
|
|
1600
2060
|
or args.cleanup):
|
|
1601
2061
|
ap.error("--mode, --tool, --split, --split-request, --diff and --cleanup only make sense with --adopt")
|
|
1602
2062
|
root = os.path.abspath(args.root)
|
|
2063
|
+
if args.adopt and args.confirm_cleanup is not None:
|
|
2064
|
+
ap.error("--confirm-cleanup does not apply to --adopt")
|
|
1603
2065
|
if args.adopt:
|
|
1604
2066
|
if args.confirm_delete is not None and not args.cleanup:
|
|
1605
2067
|
ap.error("--adopt --confirm-delete belongs to --cleanup")
|
|
@@ -1632,13 +2094,14 @@ def main():
|
|
|
1632
2094
|
print("project-update: %s templates not found at %s (run riskrails/install.sh)" % (name, tpl), file=sys.stderr)
|
|
1633
2095
|
return 2
|
|
1634
2096
|
try:
|
|
1635
|
-
plan = build_plan(root, confirm_delete=getattr(args, "confirm_delete", None), confirm_via=confirm_via
|
|
2097
|
+
plan = build_plan(root, confirm_delete=getattr(args, "confirm_delete", None), confirm_via=confirm_via,
|
|
2098
|
+
confirm_cleanup=args.confirm_cleanup, cleanup_via=cleanup_via)
|
|
1636
2099
|
except SchemaError as exc:
|
|
1637
2100
|
# --check is read by /ai-status, which reads stdout: it must see the reason.
|
|
1638
2101
|
print("project-update: %s" % exc, file=sys.stdout if args.check else sys.stderr)
|
|
1639
2102
|
return 2
|
|
1640
2103
|
if plan is None:
|
|
1641
|
-
print("project-update: %s has neither .ai/ nor docs/sdlc/ — run /
|
|
2104
|
+
print("project-update: %s has neither .ai/ nor docs/sdlc/ — run /rr-setup first" % root,
|
|
1642
2105
|
file=sys.stderr)
|
|
1643
2106
|
return 2
|
|
1644
2107
|
if not args.check:
|
|
@@ -1665,7 +2128,7 @@ def main():
|
|
|
1665
2128
|
bits.append("%d deletion(s) a human must confirm" % deletions)
|
|
1666
2129
|
if plan.held is not None:
|
|
1667
2130
|
bits.append("the schema stays at %d until they are settled" % plan.held)
|
|
1668
|
-
print("project is behind the installed plugin: %s — run /
|
|
2131
|
+
print("project is behind the installed plugin: %s — run /rr-setup --update" % ", ".join(bits))
|
|
1669
2132
|
return 1
|
|
1670
2133
|
print("project matches the installed plugin" + (" (%d file(s) differ by hand-merge choice)" % conflicts if conflicts else ""))
|
|
1671
2134
|
return 0
|