riskrails 4.1.0 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +25 -15
- package/agents/ai-reviewer.md.tmpl +14 -1
- package/ai/agents/implementer.md +6 -0
- package/ai/agents/reviewer.md +8 -1
- package/bin/ansi.js +12 -0
- package/bin/menu.js +71 -12
- package/bin/riskrails.js +240 -14
- package/bin/uninstall.js +115 -0
- package/bin/wizard.js +72 -0
- package/hooks/AGENTS.md +0 -6
- package/hooks/CLAUDE.md +0 -6
- package/hooks/context-guard.py +11 -1
- package/hooks/dialog-gate.py +3 -2
- package/hooks/guard/decision.py +4 -3
- package/hooks/guard/dialog.py +12 -2
- package/hooks/guard/ere.py +29 -10
- package/hooks/guard/git.py +2 -1
- package/hooks/guard/paths.py +48 -1406
- package/hooks/guard/paths_base.py +66 -0
- package/hooks/guard/paths_bash.py +176 -0
- package/hooks/guard/paths_classify.py +205 -0
- package/hooks/guard/paths_fs.py +137 -0
- package/hooks/guard/paths_human.py +105 -0
- package/hooks/guard/paths_interp.py +162 -0
- package/hooks/guard/paths_movers.py +393 -0
- package/hooks/guard/paths_shell.py +288 -0
- package/hooks/runtime-gate.py +87 -0
- package/hooks/update_notice.py +23 -8
- package/package.json +1 -1
- package/skills/AGENTS.md +0 -6
- package/skills/CLAUDE.md +0 -6
- package/skills/ai-init/templates/.ai/policies/coding.md +9 -0
- package/skills/ai-init/templates/gitignore.snippet +2 -0
- package/skills/ai-task/memory_cleanup.py +213 -0
- package/skills/ai-task/memory_compact.py +25 -0
- package/skills/ai-task/memory_config.py +120 -0
- package/skills/ai-task/memory_export.py +246 -0
- package/skills/ai-task/memory_format.py +7 -3
- package/skills/ai-task/memory_hook.py +172 -0
- package/skills/ai-task/memory_io.py +122 -0
- package/skills/ai-task/memory_migrate.py +187 -0
- package/skills/ai-task/memory_packages.py +83 -0
- package/skills/ai-task/memory_risks.py +84 -0
- package/skills/ai-task/memory_status.py +76 -0
- package/skills/ai-task/memory_sync.py +20 -1481
- package/skills/ai-task/memory_verify.py +316 -0
- package/skills/ai-task/rr_state/cli.py +1 -0
- package/skills/ai-task/rr_state/close.py +2 -2
- package/skills/ai-task/rr_state/dialog.py +32 -7
- package/skills/ai-task/rr_state/dialog_cmd.py +38 -5
- package/skills/ai-task/rr_state/gate.py +10 -4
- package/skills/ai-task/rr_state/labels.py +33 -4
- package/skills/ai-task/rr_state/memory_cmd.py +84 -64
- package/skills/ai-task/rr_state/questions_cmd.py +5 -4
- package/skills/ai-task/rr_state/report.py +73 -10
- package/skills/ai-task/rr_state/report_cmd.py +2 -0
- package/skills/ai-task/rr_state/review.py +18 -14
- package/skills/ai-task/rr_state/start.py +12 -9
- package/skills/ai-task/rr_state/steps.py +16 -7
- package/skills/ai-task/rr_state/verify.py +17 -12
- package/skills/project-update/history/blobs/dac90781793b8a1c8e5f3e0307665b6394d922140c3831c5508d019f79e1ed0a +41 -0
- package/skills/project-update/history/blobs/fa0cb370b024e1a30e1355912032657b08f609e47773a603c193b1e609b0f997 +8 -0
- package/skills/project-update/history/index.json +8 -4
- package/skills/project-update/rr_update/__init__.py +1 -0
- package/skills/project-update/rr_update/apply.py +188 -0
- package/skills/project-update/rr_update/blocks.py +38 -0
- package/skills/project-update/rr_update/build.py +96 -0
- package/skills/project-update/rr_update/cleanup.py +194 -0
- package/skills/project-update/rr_update/cli.py +102 -0
- package/skills/project-update/rr_update/files.py +106 -0
- package/skills/project-update/rr_update/gate.py +60 -0
- package/skills/project-update/rr_update/hints.py +128 -0
- package/skills/project-update/rr_update/history.py +117 -0
- package/skills/project-update/rr_update/kept.py +120 -0
- package/skills/project-update/rr_update/paths.py +108 -0
- package/skills/project-update/rr_update/plan.py +123 -0
- package/skills/project-update/rr_update/report.py +51 -0
- package/skills/project-update/rr_update/rules.py +178 -0
- package/skills/project-update/rr_update/shared.py +74 -0
- package/skills/project-update/rr_update/siblings.py +37 -0
- package/skills/project-update/rr_update/weights.py +171 -0
- package/skills/project-update/update.py +8 -1682
- package/skills/rr-build/SKILL.md +5 -2
- package/skills/rr-setup/SKILL.md +4 -2
- package/skills/rr-setup/setup.py +13 -12
- package/skills/rr-ship/SKILL.md +2 -2
- package/skills/rr-spec/SKILL.md +6 -0
- package/skills/rr-status/SKILL.md +2 -0
- package/skills/rr-status/status.py +5 -3
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Coding policy
|
|
2
|
+
|
|
3
|
+
<!-- /ai-init fills the project-specific half of this file from the repository.
|
|
4
|
+
The rules above the line are general and apply everywhere. -->
|
|
5
|
+
|
|
6
|
+
## General
|
|
7
|
+
|
|
8
|
+
- Follow the conventions already in the file you are editing, even when they are
|
|
9
|
+
not the conventions you would choose. Consistency inside a file beats
|
|
10
|
+
correctness in the abstract.
|
|
11
|
+
- One logical change per step. If you find yourself writing "and also", stop.
|
|
12
|
+
- No new abstraction until there are three call sites that need it.
|
|
13
|
+
- Extend through the framework's own extension points before writing a new
|
|
14
|
+
mechanism: decorators, event subscribers, form extensions, template overrides,
|
|
15
|
+
compiler passes, configuration.
|
|
16
|
+
- Never modify `vendor/` or `node_modules/`. A needed change to a third-party
|
|
17
|
+
package becomes a decorator, an event listener, or a patch that is applied on
|
|
18
|
+
install and reviewable in git.
|
|
19
|
+
- Dead code is deleted only in a task that is about deleting it, with evidence
|
|
20
|
+
that it is dead.
|
|
21
|
+
- A function does one thing and its name says which. When the name needs
|
|
22
|
+
"and", split it.
|
|
23
|
+
- Inputs come in as parameters and the result goes out as the return value;
|
|
24
|
+
files, the network and the clock stay at the edge, in the caller.
|
|
25
|
+
- An error message says what failed, what was expected, what was found, and
|
|
26
|
+
the way out.
|
|
27
|
+
|
|
28
|
+
In existing code the file's conventions win; the last three lines hold for the
|
|
29
|
+
functions you add.
|
|
30
|
+
|
|
31
|
+
## Comments
|
|
32
|
+
|
|
33
|
+
Write the comment that explains *why*, and only when the reason is not visible in
|
|
34
|
+
the code. Do not narrate what the next line does. Do not leave commented-out
|
|
35
|
+
code; git remembers it.
|
|
36
|
+
|
|
37
|
+
## Project specifics
|
|
38
|
+
|
|
39
|
+
<!-- KNOWN FACT entries only: the linters that actually run, the static-analysis
|
|
40
|
+
level actually configured, the naming and layering the code actually uses.
|
|
41
|
+
Cite the config file for each. -->
|
|
@@ -40,7 +40,8 @@
|
|
|
40
40
|
"ai-init/.ai/agents/implementer.md": [
|
|
41
41
|
"7e5c824bb71fe5353ec8a67827145b8fbc22ecd902ade1ad3005fafeed4ffb71",
|
|
42
42
|
"94da08ac6945d5614cc7918d450aab94f3aa8a929a1c8f9fa9a0a9ac51176287",
|
|
43
|
-
"1fe2f3d3bfe2d1b671aa759b89ac1d88ae73eefed2208a485c9e972fa7b079e6"
|
|
43
|
+
"1fe2f3d3bfe2d1b671aa759b89ac1d88ae73eefed2208a485c9e972fa7b079e6",
|
|
44
|
+
"71364f3e392fe460b5b32cfec8de047a0aae8f88f144d110f90a329163677d42"
|
|
44
45
|
],
|
|
45
46
|
"ai-init/.ai/agents/manager.md": [
|
|
46
47
|
"0aeb04b04262683cac6513545683486a73dc473b447bba43b5aaab18403957a6",
|
|
@@ -67,7 +68,8 @@
|
|
|
67
68
|
"e71abd1823fbbe6e635b141f04a301c3d24c2cdd56c7a5c2b25c7ea1898be54f",
|
|
68
69
|
"be34ab013c39ace1e1ec902b9bcfe9653ba6aaeddb9df63fde2d2aa17bfb2263",
|
|
69
70
|
"6956a4c991262b5733645f29d7067c091c1126721623b07275b4f276560ec28e",
|
|
70
|
-
"707e67834ec3295705c4e391b794d8600a3fbc3c02dda16db50035f84e6b47c9"
|
|
71
|
+
"707e67834ec3295705c4e391b794d8600a3fbc3c02dda16db50035f84e6b47c9",
|
|
72
|
+
"499e51e1a393f776aa425aeb2be575f0a58eb483d83bc9f1c7da94ff38c3fb0e"
|
|
71
73
|
],
|
|
72
74
|
"ai-init/.ai/agents/risk.md": [
|
|
73
75
|
"d7146a591898984115124c83174a2c93cc080ad594f132f3d89c8b41b006c805",
|
|
@@ -83,7 +85,8 @@
|
|
|
83
85
|
"7f713c6ecd00bc2379deda511d8bda786341dbe5f5187f3b899302d904fed6bc"
|
|
84
86
|
],
|
|
85
87
|
"ai-init/.ai/policies/coding.md": [
|
|
86
|
-
"3dd55bf5f9d9471c68af3353166da11593a11e639f7557397ce46787291fa629"
|
|
88
|
+
"3dd55bf5f9d9471c68af3353166da11593a11e639f7557397ce46787291fa629",
|
|
89
|
+
"dac90781793b8a1c8e5f3e0307665b6394d922140c3831c5508d019f79e1ed0a"
|
|
87
90
|
],
|
|
88
91
|
"ai-init/.ai/policies/context-management.md": [
|
|
89
92
|
"230df2220e9c73c563c3c926d26f5c6dbe249df26a56fa43b7b321cdee2d5783",
|
|
@@ -395,7 +398,8 @@
|
|
|
395
398
|
"ai-init/gitignore.snippet": [
|
|
396
399
|
"5c0619c1ee059477c9d92cf70b72da5d238d9176fc27ab0d38104ecba795624c",
|
|
397
400
|
"a00db51859bb7943174017a34b4a100f98e53d6c3b32f73d8f87d6072016a709",
|
|
398
|
-
"5a93f78b4ed4d416c171139b2ce610a117f9c1dd46fbb56ca86729cb4c53d10c"
|
|
401
|
+
"5a93f78b4ed4d416c171139b2ce610a117f9c1dd46fbb56ca86729cb4c53d10c",
|
|
402
|
+
"fa0cb370b024e1a30e1355912032657b08f609e47773a603c193b1e609b0f997"
|
|
399
403
|
],
|
|
400
404
|
"ai-init/junie-guidelines.block.md": [
|
|
401
405
|
"e8df28f07cb784d768bcafbced70fd15771afa61dbec7dc4c1c3f858f9002fec",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""rr_update: the pieces of skills/project-update/update.py, one module per concern, lower layers first."""
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Writing the plan: the preconditions, the atomic write, the record of what was taken away, and the abort.
|
|
2
|
+
|
|
3
|
+
shell: current_sha, check, default_mode, write_file, keep_original, record_run, apply, apply_item, prune_empty
|
|
4
|
+
core: Abort
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import tempfile
|
|
10
|
+
from collections import OrderedDict
|
|
11
|
+
|
|
12
|
+
from rr_update import files
|
|
13
|
+
from rr_update import paths
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Abort(Exception):
|
|
17
|
+
"""A precondition failed: the project is not what the dry run described."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, item, why):
|
|
20
|
+
super().__init__(why)
|
|
21
|
+
self.item, self.why = item, why
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def current_sha(root, target):
|
|
25
|
+
"""The sha of a target now, or None when it is absent. A path that is not a
|
|
26
|
+
regular file has no content this tool may replace."""
|
|
27
|
+
path = os.path.join(root, target)
|
|
28
|
+
if os.path.islink(path) or (os.path.exists(path) and not os.path.isfile(path)):
|
|
29
|
+
raise Abort(None, "%s is not a regular file" % target)
|
|
30
|
+
return files.sha(files.read(path)) if os.path.isfile(path) else None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def check(plan, item, target, expect, written):
|
|
34
|
+
"""The target must still be what the dry run read, or what this run wrote."""
|
|
35
|
+
try:
|
|
36
|
+
now_sha = current_sha(plan.root, target)
|
|
37
|
+
except Abort as exc:
|
|
38
|
+
raise Abort(item, exc.why) from exc
|
|
39
|
+
want = written.get(target, expect)
|
|
40
|
+
if now_sha != want:
|
|
41
|
+
raise Abort(item, "%s changed since the dry run read it" % target)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def default_mode():
|
|
45
|
+
mask = os.umask(0)
|
|
46
|
+
os.umask(mask)
|
|
47
|
+
return 0o666 & ~mask
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def write_file(root, target, content, mode=None):
|
|
51
|
+
"""Atomic, like state.py: a crash leaves the old file or the new one, never
|
|
52
|
+
half of one. The temp file is created with mkstemp in the target's directory,
|
|
53
|
+
so a planted name cannot redirect the write, and a symlinked target is
|
|
54
|
+
refused rather than followed. The target keeps the mode it had. A symlinked
|
|
55
|
+
*directory* on the way to it is still followed, as every writer here always
|
|
56
|
+
has."""
|
|
57
|
+
path = os.path.join(root, target)
|
|
58
|
+
if os.path.islink(path) or (os.path.exists(path) and not os.path.isfile(path)):
|
|
59
|
+
raise Abort(None, "%s is not a regular file" % target)
|
|
60
|
+
parent = os.path.dirname(path) or "."
|
|
61
|
+
os.makedirs(parent, exist_ok=True)
|
|
62
|
+
if mode is None:
|
|
63
|
+
mode = os.stat(path).st_mode & 0o7777 if os.path.isfile(path) else default_mode()
|
|
64
|
+
fd, tmp = tempfile.mkstemp(dir=parent, prefix=os.path.basename(path) + ".", suffix=".tmp")
|
|
65
|
+
try:
|
|
66
|
+
with os.fdopen(fd, "wb") as fh:
|
|
67
|
+
fh.write(content)
|
|
68
|
+
os.chmod(tmp, mode)
|
|
69
|
+
os.replace(tmp, path)
|
|
70
|
+
except BaseException:
|
|
71
|
+
if os.path.exists(tmp):
|
|
72
|
+
os.unlink(tmp)
|
|
73
|
+
raise
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def keep_original(plan, target):
|
|
77
|
+
"""A faithful copy of what a move or a delete takes away, mode included.
|
|
78
|
+
Nothing else is ever lost, so nothing else is copied."""
|
|
79
|
+
path = os.path.join(plan.root, target)
|
|
80
|
+
write_file(plan.root, os.path.join(plan.report_dir, "original", target),
|
|
81
|
+
files.read(path), mode=os.stat(path).st_mode & 0o7777)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def record_run(plan, ops, deletions):
|
|
86
|
+
"""The record of what was taken away or overwritten, rewritten after every such operation:
|
|
87
|
+
an abort must not lose the note of a change a human approved. A second run on the same
|
|
88
|
+
day appends to the record instead of replacing it."""
|
|
89
|
+
path = os.path.join(plan.report_dir, "migration.json")
|
|
90
|
+
existing = {}
|
|
91
|
+
full = os.path.join(plan.root, path)
|
|
92
|
+
if os.path.isfile(full):
|
|
93
|
+
try:
|
|
94
|
+
existing = json.loads(files.read(full), object_pairs_hook=OrderedDict)
|
|
95
|
+
except (ValueError, RecursionError): # too deep reads as malformed
|
|
96
|
+
existing = {}
|
|
97
|
+
if not isinstance(existing, dict): # hand-edited into another shape
|
|
98
|
+
existing = {}
|
|
99
|
+
record = OrderedDict()
|
|
100
|
+
record["from"] = existing.get("from", plan.version_from)
|
|
101
|
+
# The version the project is actually at now, read from the disk: this record is
|
|
102
|
+
# written before the files change, and an aborted run never reaches the template's.
|
|
103
|
+
version_path = os.path.join(plan.root, paths.VERSION_FILE)
|
|
104
|
+
on_disk = files.read(version_path).decode().strip() if os.path.isfile(version_path) else ""
|
|
105
|
+
record["to"] = int(on_disk) if on_disk.isdigit() else record["from"]
|
|
106
|
+
record["applied_at"] = files.utc_now()
|
|
107
|
+
# A recovery re-run replays the same operation: record it once.
|
|
108
|
+
record["ops"] = list(existing.get("ops", []))
|
|
109
|
+
record["ops"] += [o for o in ops if o not in record["ops"]]
|
|
110
|
+
record["deletions"] = list(existing.get("deletions", []))
|
|
111
|
+
record["deletions"] += [d for d in deletions
|
|
112
|
+
if not any(e.get("path") == d["path"] for e in record["deletions"])]
|
|
113
|
+
if existing and all(existing.get(k) == record[k] for k in ("from", "to", "ops", "deletions")):
|
|
114
|
+
return # a run that changed nothing leaves the record as it was
|
|
115
|
+
write_file(plan.root, path, files.dump_json(record))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def apply(plan):
|
|
119
|
+
"""Write the plan, in order. Every item checks its target first; a violation
|
|
120
|
+
or a failed write aborts before the next item, leaving everything after it
|
|
121
|
+
unwritten. Every operation is idempotent, so the re-run is the recovery."""
|
|
122
|
+
written, recorded = {}, []
|
|
123
|
+
for item in plan.items:
|
|
124
|
+
try:
|
|
125
|
+
apply_item(plan, item, written, recorded)
|
|
126
|
+
except Abort as exc:
|
|
127
|
+
raise Abort(exc.item or item, exc.why) from exc
|
|
128
|
+
except OSError as exc:
|
|
129
|
+
raise Abort(item, str(exc)) from exc
|
|
130
|
+
# The run finished: say which version it actually reached.
|
|
131
|
+
try:
|
|
132
|
+
if recorded or os.path.isfile(os.path.join(plan.root, plan.report_dir, "migration.json")):
|
|
133
|
+
record_run(plan, [], [])
|
|
134
|
+
except OSError as exc:
|
|
135
|
+
raise Abort(None, str(exc)) from exc
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def apply_item(plan, item, written, recorded):
|
|
139
|
+
target, action = item["target"], item["action"]
|
|
140
|
+
reason = item.get("reason") or ""
|
|
141
|
+
cleanup = reason.startswith("[cleanup]")
|
|
142
|
+
if action in ("delete?", "regenerate?"): # listed only, until a human confirms it
|
|
143
|
+
return
|
|
144
|
+
by = dict(confirmed_by=plan.confirm_cleanup if cleanup else plan.confirm_delete,
|
|
145
|
+
via=plan.cleanup_via if cleanup else plan.confirm_via, at=files.utc_now())
|
|
146
|
+
if action == "delete":
|
|
147
|
+
# the plan-time symlink check is repeated: a path swapped since is refused
|
|
148
|
+
why = files.unsafe_path(plan.root, target)
|
|
149
|
+
if why:
|
|
150
|
+
raise Abort(item, why)
|
|
151
|
+
check(plan, item, target, item["expect"], written)
|
|
152
|
+
# git-ignored task state: a [cleanup] deletion of it is NOT recoverable through git, and
|
|
153
|
+
# copying what it removes would defeat the cleanup, so no original/ copy is kept. A report
|
|
154
|
+
# file git tracks, or should, is kept like any other deletion.
|
|
155
|
+
if not target.startswith(".ai/state/") and (not cleanup or target in plan.git_kept):
|
|
156
|
+
keep_original(plan, target)
|
|
157
|
+
recorded.append(target)
|
|
158
|
+
# Recorded before the file is gone: an abort later must not lose the note
|
|
159
|
+
# of what was taken away, or of who approved it.
|
|
160
|
+
record_run(plan, [dict(action=action, path=target)], [dict(path=target, **by)])
|
|
161
|
+
os.remove(os.path.join(plan.root, target))
|
|
162
|
+
written[target] = None
|
|
163
|
+
if reason.startswith(("[cleanup]", "[local]")):
|
|
164
|
+
prune_empty(plan.root, target)
|
|
165
|
+
return
|
|
166
|
+
check(plan, item, target, item["expect"], written)
|
|
167
|
+
if item["keep"] and item["expect"] is not None and target not in written:
|
|
168
|
+
# A change a human confirmed overwrites what they wrote: keep it, so it can be read back.
|
|
169
|
+
if not os.path.exists(os.path.join(plan.root, plan.original_of(target))):
|
|
170
|
+
keep_original(plan, target)
|
|
171
|
+
recorded.append(target)
|
|
172
|
+
record_run(plan, [dict(action=action, path=target, **by)], [])
|
|
173
|
+
write_file(plan.root, target, item["content"])
|
|
174
|
+
written[target] = files.sha(item["content"])
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def prune_empty(root, target):
|
|
178
|
+
"""The directories a cleanup or cache deletion left empty, up to (not including)
|
|
179
|
+
.ai/reports, .ai/state/logs and .ai/local; a directory with anything in it stays."""
|
|
180
|
+
top = ".ai/state/logs" if target.startswith(".ai/state/logs/") else \
|
|
181
|
+
".ai/local" if target.startswith(paths.LOCAL_CACHE + "/") else ".ai/reports"
|
|
182
|
+
here = os.path.dirname(target)
|
|
183
|
+
while here.startswith(top + "/"):
|
|
184
|
+
try:
|
|
185
|
+
os.rmdir(os.path.join(root, here))
|
|
186
|
+
except OSError:
|
|
187
|
+
return
|
|
188
|
+
here = os.path.dirname(here)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""The managed block of one instruction file.
|
|
2
|
+
|
|
3
|
+
shell: block_update
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
import render_instructions # next to the entry, whose directory the entry puts on sys.path
|
|
9
|
+
|
|
10
|
+
from rr_update import files
|
|
11
|
+
from rr_update import paths
|
|
12
|
+
from rr_update import plan as plan_mod
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def block_update(plan, history, runtime):
|
|
16
|
+
"""R15, D5: the managed block of one instruction file. Rewritten between its markers; no block
|
|
17
|
+
-> appended; no file -> a minimal one with the block. Text outside the markers is never touched."""
|
|
18
|
+
name, block_tpl, minimal_tpl = paths.INSTRUCTION_FILE[runtime]
|
|
19
|
+
tpl = files.read(os.path.join(paths.TEMPLATES["ai-init"], block_tpl))
|
|
20
|
+
text = plan.read(name)
|
|
21
|
+
if text is None:
|
|
22
|
+
created = files.read(os.path.join(paths.TEMPLATES["ai-init"], minimal_tpl)).replace(
|
|
23
|
+
b"{{PROJECT}}", os.path.basename(plan.root).encode())
|
|
24
|
+
plan.add("create", name, "with the managed block", content=created.rstrip(b"\n") + b"\n\n" + tpl)
|
|
25
|
+
return
|
|
26
|
+
found = render_instructions.find_block(text)
|
|
27
|
+
if found is None:
|
|
28
|
+
plan.add("regenerate", name, "managed block appended", content=text.rstrip(b"\n") + b"\n\n" + tpl)
|
|
29
|
+
return
|
|
30
|
+
s, e = found[0], found[1]
|
|
31
|
+
ours = text[s:e] + b"\n"
|
|
32
|
+
if ours == tpl:
|
|
33
|
+
return
|
|
34
|
+
content = text[:s] + tpl.rstrip(b"\n") + text[e:]
|
|
35
|
+
if history.shipped("ai-init/" + block_tpl, ours):
|
|
36
|
+
plan.add("regenerate", name, "managed block replaced (unchanged since it was installed)", content=content)
|
|
37
|
+
else:
|
|
38
|
+
plan.gated("regenerate", name, "managed block " + plan_mod.edited_note(plan, name, ours, tpl), content=content)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""The plan of one run: every rule, in the order D3 sets, collected into one Plan.
|
|
2
|
+
|
|
3
|
+
shell: build_plan
|
|
4
|
+
core: PHASE
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
from rr_update import blocks
|
|
10
|
+
from rr_update import cleanup
|
|
11
|
+
from rr_update import files as files_mod
|
|
12
|
+
from rr_update import hints
|
|
13
|
+
from rr_update import history as history_mod
|
|
14
|
+
from rr_update import paths
|
|
15
|
+
from rr_update import plan as plan_mod
|
|
16
|
+
from rr_update import rules
|
|
17
|
+
from rr_update import shared
|
|
18
|
+
from rr_update import weights
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
PHASE = {"split": 0, "delete": 2, "delete?": 2}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_plan(root, confirm_delete=None, confirm_via=None, confirm_cleanup=None, cleanup_via=None):
|
|
25
|
+
"""D3: split_once -> regenerate -> shared -> block per runtime -> rules -> local -> gitignore, cleanup, hints.
|
|
26
|
+
The caller has run check_kept: nothing here reads a kept file for meaning."""
|
|
27
|
+
plan = plan_mod.Plan(root)
|
|
28
|
+
plan.confirm_cleanup = confirm_cleanup
|
|
29
|
+
plan.cleanup_via = cleanup_via
|
|
30
|
+
plan.confirm_delete = confirm_delete
|
|
31
|
+
plan.confirm_via = confirm_via
|
|
32
|
+
has_ai = os.path.isdir(os.path.join(root, ".ai"))
|
|
33
|
+
has_sdlc = os.path.isdir(os.path.join(root, "docs", "sdlc"))
|
|
34
|
+
if not (has_ai or has_sdlc):
|
|
35
|
+
return None
|
|
36
|
+
history = history_mod.History(paths.HISTORY)
|
|
37
|
+
runtimes = history_mod.project_runtimes(root)
|
|
38
|
+
plan.version_from = history_mod.project_version(root) if has_ai else None
|
|
39
|
+
|
|
40
|
+
if has_sdlc:
|
|
41
|
+
tpl = paths.TEMPLATES["project-init"]
|
|
42
|
+
for src, target in paths.SDLC_CREATE: # the project's own from then on
|
|
43
|
+
if not plan.exists(target):
|
|
44
|
+
plan.add("create", target, content=files_mod.read(os.path.join(tpl, src)).replace(
|
|
45
|
+
b"{{PROJECT}}", os.path.basename(root).encode()))
|
|
46
|
+
for rt in runtimes:
|
|
47
|
+
for src, target, kind in paths.RUNTIME_MAP[rt]:
|
|
48
|
+
content = files_mod.read(os.path.join(tpl, src))
|
|
49
|
+
if kind == "create":
|
|
50
|
+
if not plan.exists(target):
|
|
51
|
+
plan.add("create", target, content=content)
|
|
52
|
+
else:
|
|
53
|
+
plan_mod.regenerate(plan, history, "project-init/" + src, target, content)
|
|
54
|
+
hints.gitignore_update(plan, os.path.join(tpl, "gitignore.snippet"))
|
|
55
|
+
|
|
56
|
+
if has_ai:
|
|
57
|
+
tpl = paths.TEMPLATES["ai-init"]
|
|
58
|
+
weights.split_once(plan, history)
|
|
59
|
+
files = []
|
|
60
|
+
for dirpath, _, names in os.walk(os.path.join(tpl, ".ai")):
|
|
61
|
+
for n in names:
|
|
62
|
+
files.append(os.path.relpath(os.path.join(dirpath, n), tpl).replace(os.sep, "/"))
|
|
63
|
+
for rel in sorted(files):
|
|
64
|
+
if rel in paths.NOT_COPIED or rel in (paths.VERIFICATION_MD, paths.WEIGHT_PROJECT):
|
|
65
|
+
continue
|
|
66
|
+
content = files_mod.read(os.path.join(tpl, rel))
|
|
67
|
+
if rel in paths.CREATE_ONLY or rel.startswith(paths.CREATE_ONLY_DIRS):
|
|
68
|
+
if not plan.exists(rel):
|
|
69
|
+
plan.add("create", rel, content=content)
|
|
70
|
+
else:
|
|
71
|
+
plan_mod.regenerate(plan, history, "ai-init/" + rel, rel, content)
|
|
72
|
+
weights.weight_md(plan, history)
|
|
73
|
+
shared.shared_update(plan, history, runtimes)
|
|
74
|
+
rules.rule_sources(plan)
|
|
75
|
+
cleanup.local_cache(plan)
|
|
76
|
+
cleanup.cleanup_update(plan)
|
|
77
|
+
for rt in runtimes:
|
|
78
|
+
blocks.block_update(plan, history, rt)
|
|
79
|
+
rules.rules_update(plan, runtimes)
|
|
80
|
+
cleanup.deletion_hint(plan)
|
|
81
|
+
hints.gitignore_update(plan, os.path.join(tpl, "gitignore.snippet"))
|
|
82
|
+
cleanup.tracked_ignored_hint(plan)
|
|
83
|
+
hints.verification_hints(plan)
|
|
84
|
+
hints.budget_hints(plan, runtimes)
|
|
85
|
+
if plan.items and plan.exists(paths.STATE_FILE):
|
|
86
|
+
plan.hints.append("%s: a task is in flight while files are regenerated — check that the files "
|
|
87
|
+
"its current step may touch still exist afterwards" % paths.STATE_FILE)
|
|
88
|
+
# Last of the items: an apply that stops short leaves the version alone, and the next run plans the rest again.
|
|
89
|
+
ours = history_mod.template_version()
|
|
90
|
+
if plan.version_from != ours:
|
|
91
|
+
plan.add("regenerate" if plan.exists(paths.VERSION_FILE) else "create", paths.VERSION_FILE,
|
|
92
|
+
"schema %d" % ours, content=b"%d\n" % ours)
|
|
93
|
+
if has_sdlc:
|
|
94
|
+
hints.constitution_hint(plan)
|
|
95
|
+
plan.items.sort(key=lambda i: 3 if i["target"] == paths.VERSION_FILE else PHASE.get(i["action"], 1))
|
|
96
|
+
return plan
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""The cleanup proposals: the local cache, the task journals, the [cleanup] rows and the hints about deletion and tracked ignored files.
|
|
2
|
+
|
|
3
|
+
shell: local_cache, task_journal, cleanup_update, deletion_hint, tracked_ignored_hint
|
|
4
|
+
core: TRACKED_IGNORED_SHOWN
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import shlex
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from datetime import datetime, timedelta, timezone
|
|
13
|
+
|
|
14
|
+
from rr_update import files
|
|
15
|
+
from rr_update import paths
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def local_cache(plan):
|
|
19
|
+
"""R18: .ai/local/plugin-update/ (the old merge's conflict copies) is removed; .ai/local/memory/ is never touched."""
|
|
20
|
+
for here, _dirs, names in os.walk(os.path.join(plan.root, paths.LOCAL_CACHE)):
|
|
21
|
+
for name in sorted(names):
|
|
22
|
+
rel = os.path.relpath(os.path.join(here, name), plan.root).replace(os.sep, "/")
|
|
23
|
+
if not files.unsafe_path(plan.root, rel):
|
|
24
|
+
why = "[local] the old merge's cache, not written any more"
|
|
25
|
+
plan.gated("delete", rel, why, reason=why)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def task_journal(root, task_id):
|
|
29
|
+
"""(finished, abandoned, last event as a datetime) from the task's journal,
|
|
30
|
+
or None when it holds no readable event. A task is finished when its journal
|
|
31
|
+
has task_closed or gate_approved, or when logs/<id>/state.json records a
|
|
32
|
+
result (so a torn task_closed line does not make a closed task look unclosed)."""
|
|
33
|
+
closed = abandoned = False
|
|
34
|
+
last = None
|
|
35
|
+
try:
|
|
36
|
+
with open(os.path.join(root, ".ai", "reports", task_id, "events.jsonl"), encoding="utf-8") as fh:
|
|
37
|
+
lines = fh.read().splitlines()
|
|
38
|
+
except (OSError, UnicodeDecodeError):
|
|
39
|
+
return None
|
|
40
|
+
for line in lines:
|
|
41
|
+
try:
|
|
42
|
+
ev = json.loads(line)
|
|
43
|
+
when = datetime.strptime(ev["ts"][:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
|
|
44
|
+
except (ValueError, KeyError, TypeError, RecursionError):
|
|
45
|
+
continue
|
|
46
|
+
if last is None or when > last:
|
|
47
|
+
last = when
|
|
48
|
+
if ev.get("event") == "task_closed":
|
|
49
|
+
closed = True
|
|
50
|
+
data = ev.get("data")
|
|
51
|
+
abandoned = (isinstance(data, dict) and data.get("abandoned") is True) or ev.get("detail") == "abandoned"
|
|
52
|
+
elif ev.get("event") == "gate_approved":
|
|
53
|
+
closed = True
|
|
54
|
+
try:
|
|
55
|
+
with open(os.path.join(root, ".ai", "state", "logs", task_id, "state.json"), encoding="utf-8") as fh:
|
|
56
|
+
result = json.load(fh).get("result")
|
|
57
|
+
except (OSError, ValueError, AttributeError, UnicodeDecodeError, RecursionError):
|
|
58
|
+
result = None
|
|
59
|
+
if isinstance(result, str) and result:
|
|
60
|
+
closed = True
|
|
61
|
+
abandoned = abandoned or result == "abandoned"
|
|
62
|
+
return None if last is None else (closed, abandoned, last)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def cleanup_update(plan, now=None):
|
|
66
|
+
"""Old run logs and stale unclosed tasks, proposed for deletion and never
|
|
67
|
+
deleted unasked (only with --confirm-cleanup). A closed task's `*.log` files
|
|
68
|
+
go (its state.json stays); an abandoned task loses every file under
|
|
69
|
+
.ai/reports/<id>/ and .ai/state/logs/<id>/ except that state.json; an unclosed
|
|
70
|
+
one idle for STALE_DAYS loses every file in both. The task in flight is
|
|
71
|
+
never touched. `now` is a datetime; RISKRAILS_NOW (ISO, Z) overrides it."""
|
|
72
|
+
if now is None:
|
|
73
|
+
try:
|
|
74
|
+
now = datetime.strptime(paths.env("NOW") or files.utc_now(), "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
|
75
|
+
except ValueError:
|
|
76
|
+
print("project-update: RISKRAILS_NOW %r is not a UTC time like 2026-01-31T12:00:00Z" % paths.env("NOW"),
|
|
77
|
+
file=sys.stderr)
|
|
78
|
+
raise SystemExit(2) from None
|
|
79
|
+
flight = None
|
|
80
|
+
raw = plan.read(paths.STATE_FILE)
|
|
81
|
+
if raw is not None:
|
|
82
|
+
try:
|
|
83
|
+
flight = json.loads(raw).get("task_id")
|
|
84
|
+
except (ValueError, AttributeError, RecursionError):
|
|
85
|
+
return # unreadable: a task may be in flight, so no task is proposed as stale
|
|
86
|
+
reports =os.path.join(plan.root, ".ai", "reports")
|
|
87
|
+
logs = os.path.join(plan.root, ".ai", "state", "logs")
|
|
88
|
+
|
|
89
|
+
def files_of(base, rel, only_logs=False, keep=()):
|
|
90
|
+
out = []
|
|
91
|
+
for here, _dirs, names in os.walk(os.path.join(base, rel)):
|
|
92
|
+
for n in names:
|
|
93
|
+
full = os.path.join(here, n)
|
|
94
|
+
if os.path.isfile(full) and not os.path.islink(full) and (not only_logs or n.endswith(".log")) \
|
|
95
|
+
and n not in keep:
|
|
96
|
+
out.append(os.path.relpath(full, plan.root).replace(os.sep, "/"))
|
|
97
|
+
return out
|
|
98
|
+
|
|
99
|
+
for task_id in sorted(os.listdir(reports)) if os.path.isdir(reports) else []:
|
|
100
|
+
journal = task_journal(plan.root, task_id)
|
|
101
|
+
if journal is None or task_id == flight:
|
|
102
|
+
continue
|
|
103
|
+
closed, abandoned, last = journal
|
|
104
|
+
if abandoned:
|
|
105
|
+
why, targets = "[cleanup] abandoned task %s" % task_id, \
|
|
106
|
+
files_of(reports, task_id) + files_of(logs, task_id, keep=("state.json",))
|
|
107
|
+
elif not closed and now - last > timedelta(days=paths.STALE_DAYS):
|
|
108
|
+
why = "[cleanup] unclosed task %s, last event %s" % (task_id, last.strftime("%Y-%m-%d"))
|
|
109
|
+
targets = files_of(reports, task_id) + files_of(logs, task_id)
|
|
110
|
+
elif closed:
|
|
111
|
+
why, targets = "[cleanup] log of closed task %s" % task_id, files_of(logs, task_id, only_logs=True)
|
|
112
|
+
else:
|
|
113
|
+
continue
|
|
114
|
+
# .ai/reports/ is usually git-tracked; only .ai/state/ is git-ignored run state
|
|
115
|
+
held = files.git_states(plan.root, ".ai/reports/" + task_id)
|
|
116
|
+
skipped = set()
|
|
117
|
+
for target in sorted(targets):
|
|
118
|
+
unsafe = files.unsafe_path(plan.root, target)
|
|
119
|
+
if unsafe: # --apply would abort on it: say so instead of proposing it
|
|
120
|
+
if unsafe not in skipped:
|
|
121
|
+
skipped.add(unsafe)
|
|
122
|
+
plan.hints.append("%s: not proposed for deletion, %s — remove it yourself if it should go"
|
|
123
|
+
% (target, unsafe))
|
|
124
|
+
continue
|
|
125
|
+
note = why
|
|
126
|
+
state = held(target) if target.startswith(".ai/reports/") else None
|
|
127
|
+
if state == "tracked":
|
|
128
|
+
note += "; tracked: git can restore a committed copy"
|
|
129
|
+
elif state == "uncommitted":
|
|
130
|
+
note += "; uncommitted: git holds no copy of it"
|
|
131
|
+
elif state == "ignored":
|
|
132
|
+
note += "; git-ignored: not recoverable through git"
|
|
133
|
+
if state in ("tracked", "uncommitted"):
|
|
134
|
+
plan.git_kept.add(target)
|
|
135
|
+
if plan.confirm_cleanup:
|
|
136
|
+
plan.add("delete", target, note, reason=why)
|
|
137
|
+
plan.remove(target)
|
|
138
|
+
else:
|
|
139
|
+
plan.add("delete?", target, note, reason=why)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def deletion_hint(plan):
|
|
143
|
+
"""M4: git is the rollback of a deletion or an overwrite; it must hold .ai/ first.
|
|
144
|
+
A [cleanup] deletion of git-ignored run state is not counted: git does not roll it back;
|
|
145
|
+
one under .ai/reports/ that git tracks, or should, is."""
|
|
146
|
+
if any(i["action"] in ("delete?", "delete", "regenerate?") and i["target"].startswith(".ai/")
|
|
147
|
+
and (not (i.get("reason") or "").startswith("[cleanup]") or i["target"] in plan.git_kept)
|
|
148
|
+
for i in plan.items) \
|
|
149
|
+
and files.git_dirty_ai(plan.root):
|
|
150
|
+
plan.hints.append("commit .ai/ first: git is this update's rollback "
|
|
151
|
+
"(`git status -- .ai` lists what it does not hold yet)")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
TRACKED_IGNORED_SHOWN = 10 # per-file hints before tracked_ignored_hint counts the rest
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def tracked_ignored_hint(plan):
|
|
158
|
+
"""A file under .ai/state that git tracks although an ignore rule lists it: the rule
|
|
159
|
+
has no effect on it. Read-only: the human untracks it; nothing is planned.
|
|
160
|
+
At most TRACKED_IGNORED_SHOWN files get their own hint; one more line counts the rest."""
|
|
161
|
+
try:
|
|
162
|
+
res = subprocess.run(["git", "-C", plan.root, "ls-files", "-z", "-ci", "--exclude-standard",
|
|
163
|
+
"--", ".ai/state"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
|
164
|
+
timeout=30, check=False)
|
|
165
|
+
except (OSError, subprocess.SubprocessError):
|
|
166
|
+
return
|
|
167
|
+
if res.returncode != 0:
|
|
168
|
+
return
|
|
169
|
+
# --literal-pathspecs: a * ? [ ] in a name is that name, not a pattern that matches its neighbours.
|
|
170
|
+
all_cmd = "git ls-files -z -ci --exclude-standard -- .ai/state | xargs -0 -r git --literal-pathspecs rm --cached --"
|
|
171
|
+
names = [os.fsdecode(raw) for raw in res.stdout.split(b"\0") if raw]
|
|
172
|
+
for path in names[:TRACKED_IGNORED_SHOWN]:
|
|
173
|
+
# A control character (or a byte that is not UTF-8, which fsdecode keeps as a lone
|
|
174
|
+
# surrogate) in the name would forge a report line or break the copy-paste command:
|
|
175
|
+
# show it escaped, and untrack by the NUL-safe listing.
|
|
176
|
+
shown = "".join(c if c.isprintable() else repr(c)[1:-1] for c in path)
|
|
177
|
+
if shown == path:
|
|
178
|
+
plan.hints.append("%s: tracked by git but ignored by an ignore rule — the rule has no "
|
|
179
|
+
"effect on a tracked file, so every run leaves the tree dirty and "
|
|
180
|
+
"`git commit -a` sweeps it in; untrack it yourself, from the project root: "
|
|
181
|
+
"`git --literal-pathspecs rm --cached -- %s` and commit (the file stays on disk)"
|
|
182
|
+
% (shown, shlex.quote(path)))
|
|
183
|
+
else:
|
|
184
|
+
plan.hints.append("%s: tracked by git but ignored by an ignore rule — the rule has no "
|
|
185
|
+
"effect on a tracked file, so every run leaves the tree dirty and "
|
|
186
|
+
"`git commit -a` sweeps it in; this name cannot be typed safely, so "
|
|
187
|
+
"untrack it, from the project root, with `%s`, which untracks EVERY tracked, ignored file "
|
|
188
|
+
"under .ai/state, not only this one, and commit (the files stay on disk)"
|
|
189
|
+
% (shown, all_cmd))
|
|
190
|
+
if len(names) > TRACKED_IGNORED_SHOWN:
|
|
191
|
+
plan.hints.append(".ai/state: %d more file(s) tracked by git but ignored by an ignore rule, "
|
|
192
|
+
"not listed; untrack ALL tracked, ignored files under .ai/state "
|
|
193
|
+
"(the ones listed above too), from the project root, with `%s` and commit (the files stay on disk)"
|
|
194
|
+
% (len(names) - TRACKED_IGNORED_SHOWN, all_cmd))
|