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
package/hooks/guard/paths.py
CHANGED
|
@@ -59,21 +59,18 @@ WHY_APPROVE = T(
|
|
|
59
59
|
"and tells the session to run state.py questions --sync. An unattended run\n"
|
|
60
60
|
"exports AI_UNATTENDED=1 in the launcher's environment, and the journal then\n"
|
|
61
61
|
"records the approval as unattended for ever. See .ai/policies/safety.md.")
|
|
62
|
-
WHY_SET_BY = T(
|
|
63
|
-
"\n\nA task gets review_rounds_max review verdicts; after that the human decides,\n"
|
|
64
|
-
"in their own terminal:\n"
|
|
65
|
-
" python3 <plugin>/skills/ai-task/state.py --root . set review_status <value> --by \"<name>\"\n"
|
|
66
|
-
"Show them the findings in .ai/reports/<task-id>/task.md §Review. An unattended\n"
|
|
67
|
-
"run exports AI_UNATTENDED=1 in the launcher's environment. See .ai/policies/safety.md.")
|
|
68
62
|
WHY_WORK_CAP = T(
|
|
69
63
|
"This command names more paths than the guard can check within its timeout; split it into "
|
|
70
64
|
"smaller commands, or pass the list through a file and xargs.")
|
|
71
65
|
|
|
72
66
|
TOKEN_END = "([[:space:]\"';&|)<>]|$)"
|
|
73
67
|
|
|
74
|
-
# The --confirm-delete rule.
|
|
68
|
+
# The --confirm-delete rule, and --confirm-cleanup (the same human gate; any argparse prefix).
|
|
75
69
|
SEP = "(([^|;&]|[<>]&|&>)*[[:space:]])?" + Q
|
|
76
|
-
|
|
70
|
+
CONFIRM_DEL = "d(e(l(e(t(e)?)?)?)?)?"
|
|
71
|
+
CONFIRM_CLEAN = "c(l(e(a(n(u(p)?)?)?)?)?)?"
|
|
72
|
+
CONFIRM = ("--co(n(f(i(r(m(-(" + CONFIRM_DEL + "|" + CONFIRM_CLEAN + ")?)?)?)?)?)?)?"
|
|
73
|
+
"(=|[[:space:]]|['\"]|$)")
|
|
77
74
|
APPLY = "--ap(p(l(y)?)?)?" + END
|
|
78
75
|
HEAD = "(^|[[:space:]'\"/|;&(`])(update\\.py|-[A-Za-z]*m[[:space:]]*" + Q + "update|" + VAR + ")" + Q + "[[:space:]]"
|
|
79
76
|
CONFIRM_RE = HEAD + SEP + CONFIRM + SEP + APPLY + "|" + HEAD + SEP + APPLY + SEP + CONFIRM
|
|
@@ -84,22 +81,36 @@ VERB = "([[:space:]]+--)?[[:space:]]+" + Q + "approve" + END
|
|
|
84
81
|
BOUND = "(^|[|;&([:space:]`])"
|
|
85
82
|
APPROVE_RE = (BOUND + "(python3?[[:space:]]+)?" + Q + "[^[:space:]'\"]*state\\.py" + Q + ARGS + VERB + "|"
|
|
86
83
|
+ BOUND + "(python3?[[:space:]]+)?" + Q + VAR + Q + ARGS + VERB)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
#
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
# reaches state.py: raising a mode needs no human, and a lowering opens its own
|
|
95
|
-
# decision there before human_route() refuses it (review H1, T-2026-10-28-010).
|
|
84
|
+
# R36.11: the other human-only state.py commands — `profile set` (dialog on only)
|
|
85
|
+
# and the --by forms of verify, review and reject (dialog on or off) — run from an
|
|
86
|
+
# agent session only as the next live pick, like approve. `start --mode light --by`
|
|
87
|
+
# is denied with the dialog off only: `light` is the one --mode value that can
|
|
88
|
+
# never raise a mode; with the dialog on it reaches state.py, which opens the
|
|
89
|
+
# lowering decision (review H1, T-2026-10-28-010); any other `start --mode X --by`
|
|
90
|
+
# reaches state.py too (T-2026-10-30-0758 Q1, Q2).
|
|
96
91
|
PROFILE_SET_VERB = "([[:space:]]+--)?[[:space:]]+" + Q + "profile" + Q + "[[:space:]]+" + Q + "set" + END
|
|
97
92
|
PROFILE_SET_RE = (BOUND + "(python3?[[:space:]]+)?" + Q + "[^[:space:]'\"]*state\\.py" + Q + ARGS + PROFILE_SET_VERB
|
|
98
93
|
+ "|" + BOUND + "(python3?[[:space:]]+)?" + Q + VAR + Q + ARGS + PROFILE_SET_VERB)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
94
|
+
BY_TOK = Q + "--by?" + Q + "(=|[[:space:]]|$)"
|
|
95
|
+
MODE_LIGHT = Q + "--m(o(d(e)?)?)?" + Q + "(=|[[:space:]]+)" + Q + "light" + END
|
|
96
|
+
SKIP = "([^|;&`]*[[:space:]])?"
|
|
97
|
+
HUMAN_BY_VERB = ("([[:space:]]+--)?[[:space:]]+" + Q + "(verify|review|reject)" + END + SKIP + BY_TOK)
|
|
98
|
+
START_LIGHT_VERB = ("([[:space:]]+--)?[[:space:]]+" + Q + "start" + END + SKIP + "("
|
|
99
|
+
+ MODE_LIGHT + SKIP + BY_TOK + "|" + BY_TOK + SKIP + MODE_LIGHT + ")")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def state_verb_re(verb):
|
|
103
|
+
"""The state.py command whose verb part is `verb`, as an ERE: by path or by a variable."""
|
|
104
|
+
return (BOUND + "(python3?[[:space:]]+)?" + Q + "[^[:space:]'\"]*state\\.py" + Q + ARGS
|
|
105
|
+
+ "(" + verb + ")|" + BOUND + "(python3?[[:space:]]+)?" + Q + VAR + Q + ARGS + "(" + verb + ")")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
HUMAN_BY_RE = state_verb_re(HUMAN_BY_VERB)
|
|
109
|
+
START_LIGHT_RE = state_verb_re(START_LIGHT_VERB)
|
|
110
|
+
WHY_HUMAN = T(
|
|
111
|
+
"\n\nThis command is the human's, whether or not the dialog is on. A human runs it in their\n"
|
|
112
|
+
"own terminal, outside the agent session; an unattended run exports AI_UNATTENDED=1 in the\n"
|
|
113
|
+
"launcher's environment. See .ai/policies/safety.md.")
|
|
103
114
|
WHY_PICK = T(
|
|
104
115
|
"\n\nWith the dialog on, this command runs from an agent session only as the human's pick.\n"
|
|
105
116
|
"The command that needs it opened a decision: 'state.py decision show' prints the dialog;\n"
|
|
@@ -137,21 +148,106 @@ LN_SRC_RE = ".*(^|[|;&[:space:]])ln([[:space:]]+-[^[:space:]]+)*[[:space:]]+([^[
|
|
|
137
148
|
LONG_CMD = 4096
|
|
138
149
|
# Whole words only, so --cov, --config and approve_invoice do not trip it.
|
|
139
150
|
LONG_TRIGGER_RE = ("(^|[^A-Za-z0-9_])approve([^A-Za-z0-9_]|$)|--by(=|[[:space:]]|$)|"
|
|
140
|
-
"--con?f?i?r?m?-?d?e?l?e?t?e?(=|[[:space:]]|$)|state\\.py|update\\.py")
|
|
151
|
+
"--con?f?i?r?m?-?(d?e?l?e?t?e?|c?l?e?a?n?u?p?)(=|[[:space:]]|$)|state\\.py|update\\.py")
|
|
141
152
|
# Tools that write a file named on their command line without a redirect; the
|
|
142
153
|
# movers do not parse them, so any protected path they name is refused (H1).
|
|
143
154
|
FETCHERS = "(^|[|;&[:space:]])(scp|tar|zip|unzip|curl|wget)([[:space:]]+[^;&|[:space:]]+)*[[:space:]]+"
|
|
144
155
|
# An interpreter one-liner is not parsed, its text is scanned: one that names a
|
|
145
156
|
# secret, or one that names a control file and looks like a write, is refused (H3).
|
|
146
|
-
INTERP_RE = "(^|[|;&[:space:]])(python3?|perl|ruby|node|php)([[:space:]]+-[^[:space:]]+)*[[:space:]]+-(c|e)([[:space:]]|$)"
|
|
157
|
+
INTERP_RE = ("(^|[|;&[:space:]])(python3?|perl|ruby|node|php)([[:space:]]+-[^[:space:]]+)*[[:space:]]+-(c|e)([[:space:]]|$)|"
|
|
158
|
+
"(^|[|;&[:space:]])php([[:space:]]+-[^[:space:]]+)*[[:space:]]+-r([[:space:]]|$)")
|
|
147
159
|
CONTROL_TEXT_RE = "\\.ai/state/|\\.ai/reports/"
|
|
148
|
-
|
|
149
|
-
|
|
160
|
+
# perl or ruby with -i, behind any blank, separator, ( ` { or quote, so every wrapper and
|
|
161
|
+
# position is caught; Guard.inplace_edit carves out the segments of a text tool (grep, echo,
|
|
162
|
+
# git log ...) that merely name it. An -e/-E/-ne code argument may be one quoted word.
|
|
163
|
+
INPLACE_RE = ("(^|[|;&(`{'\"[:space:]])"
|
|
164
|
+
"(perl|ruby)([[:space:]]+-([CEIMmr][[:space:]]*[^[:space:]]+|[a-zA-Z]*[eE][[:space:]]+('[^']*'|\"[^\"]*\")|"
|
|
165
|
+
"[^[:space:]]+))*[[:space:]]+-[acdfghlnpsStTuUvwWXy]*i")
|
|
166
|
+
_TEXT_TOOLS = {"grep", "egrep", "fgrep", "rg", "ag", "ack", "echo", "printf"}
|
|
167
|
+
_GIT_TEXT = {"log", "grep", "commit", "show", "diff", "tag", "notes"}
|
|
168
|
+
_STD_WRITE = re.compile(r"(writeSync|writevSync)\([ \t]*(1|2|process\.std(out|err)\.fd)[ \t]*,|"
|
|
169
|
+
r"v?fprintf\([ \t]*STD(OUT|ERR)[ \t]*,")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
_SHELL_SINKS = {"sh", "bash", "zsh", "dash", "ksh", "ssh", "source", ".", "eval"}
|
|
173
|
+
_XARGS_ARG = {"-I", "-n", "-P", "-L", "-s", "-E", "-d", "-a"}
|
|
174
|
+
_GIT_RUNS = ("GIT_", "PAGER", "EDITOR", "VISUAL", "SSH_COMMAND")
|
|
175
|
+
_GIT_CFG = re.compile(r"[A-Za-z0-9_.-]+=[A-Za-z0-9_./:-]*$")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _tool_word(w):
|
|
179
|
+
"""The bare word, /bin/<w> or /usr/bin/<w>; any other path is a program of its own."""
|
|
180
|
+
if w.startswith("/bin/"):
|
|
181
|
+
return w[5:]
|
|
182
|
+
if w.startswith("/usr/bin/"):
|
|
183
|
+
return w[9:]
|
|
184
|
+
return w
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def shell_sink(seg):
|
|
188
|
+
"""A segment that runs its input or its arguments as shell text."""
|
|
189
|
+
words = seg.split()
|
|
190
|
+
if not words:
|
|
191
|
+
return False
|
|
192
|
+
w = _tool_word(words[0])
|
|
193
|
+
if w in _SHELL_SINKS:
|
|
194
|
+
return True
|
|
195
|
+
return w == "xargs" and any(_tool_word(x) in _SHELL_SINKS for x in words[1:])
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _plain_text_tool(words, i, git_runs):
|
|
199
|
+
if i >= len(words):
|
|
200
|
+
return False
|
|
201
|
+
w = _tool_word(words[i])
|
|
202
|
+
if w in _TEXT_TOOLS:
|
|
203
|
+
return True
|
|
204
|
+
if w != "git" or git_runs:
|
|
205
|
+
return False
|
|
206
|
+
if any(x.startswith(("-O", "--open-files-in-pager", "--config-env")) for x in words[i + 1:]):
|
|
207
|
+
return False
|
|
208
|
+
k = i + 1
|
|
209
|
+
while k < len(words):
|
|
210
|
+
x = words[k]
|
|
211
|
+
if x in ("-C", "-c"):
|
|
212
|
+
if x == "-c" and (k + 1 >= len(words) or not _GIT_CFG.match(words[k + 1])):
|
|
213
|
+
return False
|
|
214
|
+
k += 2
|
|
215
|
+
elif x.startswith("-"):
|
|
216
|
+
k += 1
|
|
217
|
+
else:
|
|
218
|
+
return x in _GIT_TEXT
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def text_tool_segment(seg, git_runs=False):
|
|
223
|
+
"""A segment whose command word is a text tool: a program named in it is an argument.
|
|
224
|
+
xargs and find -exec are looked through; git_runs is set when the line may hand git a
|
|
225
|
+
command to run (a stripped GIT_PAGER=... assignment)."""
|
|
226
|
+
words = seg.split()
|
|
227
|
+
if not words:
|
|
228
|
+
return False
|
|
229
|
+
w = _tool_word(words[0])
|
|
230
|
+
if w == "xargs":
|
|
231
|
+
i = 1
|
|
232
|
+
while i < len(words) and words[i].startswith("-"):
|
|
233
|
+
i += 2 if words[i] in _XARGS_ARG else 1
|
|
234
|
+
return _plain_text_tool(words, i, git_runs)
|
|
235
|
+
if w == "find":
|
|
236
|
+
found = False
|
|
237
|
+
for k, x in enumerate(words):
|
|
238
|
+
if x in ("-exec", "-execdir", "-ok", "-okdir"):
|
|
239
|
+
if not _plain_text_tool(words, k + 1, git_runs):
|
|
240
|
+
return False
|
|
241
|
+
found = True
|
|
242
|
+
return found
|
|
243
|
+
return _plain_text_tool(words, 0, git_runs)
|
|
244
|
+
|
|
245
|
+
|
|
150
246
|
EVAL_WORD_RE = "(^|[|;&[:space:]])eval([[:space:]]|$)"
|
|
151
247
|
SECRET_TEXT_RE = "\\.env|secrets?/|credentials?|id_rsa|\\.pem|\\.aws"
|
|
152
248
|
WRITE_CALL_RE = (
|
|
153
249
|
"(^|[^A-Za-z0-9_])(write|writelines|write_text|write_bytes|write_file|writeFile|writeFileSync|appendFile|"
|
|
154
|
-
"appendFileSync|createWriteStream|binwrite|syswrite|fwrite|fputs|file_put_contents|dump|unlink|unlinkSync|"
|
|
250
|
+
"appendFileSync|createWriteStream|binwrite|syswrite|fwrite|fputs|fprintf|vfprintf|writeSync|writevSync|file_put_contents|dump|unlink|unlinkSync|"
|
|
155
251
|
"remove|removedirs|rename|renames|renameSync|rmdir|rmdirSync|rm|rmSync|rmtree|mkdir|mkdirSync|makedirs|"
|
|
156
252
|
"mkdtemp|truncate|truncateSync|ftruncate|symlink|symlinkSync|symlink_to|hardlink_to|link|linkSync|link_to|"
|
|
157
253
|
"touch|chmod|chown|utime|utimes|copyFile|copyFileSync|cp|cpSync|move|delete|system|exec|execSync|execFile|"
|
|
@@ -1171,7 +1267,8 @@ class PathGuard: # pylint: disable=too-many-instance-
|
|
|
1171
1267
|
p = self.p
|
|
1172
1268
|
if p.tool not in ("Read", "Edit", "Write", "MultiEdit", "NotebookEdit", "Bash"):
|
|
1173
1269
|
return
|
|
1174
|
-
|
|
1270
|
+
# AI_UNATTENDED is set when non-empty and not 0, false or no: one rule (risk 0587)
|
|
1271
|
+
unattended = env_b("AI_UNATTENDED").strip().lower() not in ("", "0", "false", "no")
|
|
1175
1272
|
if p.tool == "Bash" and not unattended:
|
|
1176
1273
|
self.confirm_delete(p.bash_command())
|
|
1177
1274
|
root = find_ai_root(p.cwd, self.ctx.pwd)
|
|
@@ -1216,6 +1313,22 @@ class PathGuard: # pylint: disable=too-many-instance-
|
|
|
1216
1313
|
command = b_bytes(cmd).decode("utf-8", "replace").strip()
|
|
1217
1314
|
return True, bool(pick and pick.get("label") == command)
|
|
1218
1315
|
|
|
1316
|
+
def inplace_edit(self, cmd):
|
|
1317
|
+
"""perl or ruby -i as a command: INPLACE_RE on the line less the segments of a text tool."""
|
|
1318
|
+
m = self.m
|
|
1319
|
+
if not m.ere_match(INPLACE_RE, cmd):
|
|
1320
|
+
return False
|
|
1321
|
+
if "<<" in cmd: # a heredoc body is read by whoever reads it
|
|
1322
|
+
return True
|
|
1323
|
+
segs = shell_segments(cmd).split("\n")
|
|
1324
|
+
if "|" in cmd and any(shell_sink(sg) for sg in segs):
|
|
1325
|
+
return True # a text tool's output may run as shell text
|
|
1326
|
+
git_runs = any(t in cmd for t in _GIT_RUNS)
|
|
1327
|
+
if git_runs and any(_tool_word(sg.split(None, 1)[0]) == "git" for sg in segs if sg.strip()):
|
|
1328
|
+
return True # the assignment that runs a command is not in the segment
|
|
1329
|
+
keep = [sg for sg in segs if not text_tool_segment(sg, git_runs)]
|
|
1330
|
+
return m.ere_match(INPLACE_RE, "\n".join(keep))
|
|
1331
|
+
|
|
1219
1332
|
def interpreter_text(self, cmd):
|
|
1220
1333
|
"""The text of an interpreter one-liner or an eval, scanned whole (H3)."""
|
|
1221
1334
|
m = self.m
|
|
@@ -1224,10 +1337,10 @@ class PathGuard: # pylint: disable=too-many-instance-
|
|
|
1224
1337
|
if (is_eval or is_interp) and m.ere_match(SECRET_TEXT_RE, cmd):
|
|
1225
1338
|
raise Deny("Refusing an interpreter one-liner that references a sensitive path." + WHY_SENSITIVE)
|
|
1226
1339
|
if m.ere_match(CONTROL_TEXT_RE, cmd):
|
|
1227
|
-
if is_eval or
|
|
1340
|
+
if is_eval or self.inplace_edit(cmd):
|
|
1228
1341
|
raise Deny("Refusing an interpreter one-liner that writes the task's own control files." + WHY_APPROVE)
|
|
1229
1342
|
if is_interp:
|
|
1230
|
-
scan = cmd.replace("stdout.write", " ").replace("stderr.write", " ")
|
|
1343
|
+
scan = _STD_WRITE.sub(" ", cmd.replace("stdout.write", " ").replace("stderr.write", " "))
|
|
1231
1344
|
pscan = cmd.replace("copy.copy(", " ")
|
|
1232
1345
|
if (m.ere_match(WRITE_CALL_RE, scan) or m.ere_match(WRITE_WORD_RE, cmd)
|
|
1233
1346
|
or (m.ere_match("Path", cmd)
|
|
@@ -1255,14 +1368,14 @@ class PathGuard: # pylint: disable=too-many-instance-
|
|
|
1255
1368
|
on, picked = self.dialog_consent(cmd)
|
|
1256
1369
|
if m.ere_match(APPROVE_RE, cmd_joined) and not unattended and self.task_in_flight() and not picked:
|
|
1257
1370
|
raise Deny("Refusing 'state.py approve' from an agent session." + WHY_APPROVE)
|
|
1258
|
-
if on and not picked and not unattended:
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
if
|
|
1264
|
-
raise Deny(T("Refusing 'state.py
|
|
1265
|
-
"
|
|
1371
|
+
if on and not picked and not unattended and m.ere_match(PROFILE_SET_RE, cmd_joined):
|
|
1372
|
+
raise Deny("Refusing 'state.py profile set' from an agent session." + WHY_PICK)
|
|
1373
|
+
if not picked and not unattended and m.ere_match(HUMAN_BY_RE, cmd_joined):
|
|
1374
|
+
raise Deny(T("Refusing a verdict with 'state.py … --by' from an agent session.")
|
|
1375
|
+
+ (WHY_PICK if on else WHY_HUMAN))
|
|
1376
|
+
if not on and not picked and not unattended and m.ere_match(START_LIGHT_RE, cmd_joined):
|
|
1377
|
+
raise Deny(T("Refusing 'state.py start --mode light … --by' from an agent session: lowering the "
|
|
1378
|
+
"mode is the human's.") + WHY_HUMAN)
|
|
1266
1379
|
self.interpreter_text(cmd)
|
|
1267
1380
|
|
|
1268
1381
|
# Top-level splitting only: the string a sh -c or an eval runs is not
|
|
@@ -204,7 +204,8 @@ class Scaffold:
|
|
|
204
204
|
if os.path.exists(os.path.join(d, ".gitkeep")):
|
|
205
205
|
return
|
|
206
206
|
os.makedirs(d, exist_ok=True)
|
|
207
|
-
open(os.path.join(d, ".gitkeep"), "wb")
|
|
207
|
+
with open(os.path.join(d, ".gitkeep"), "wb"):
|
|
208
|
+
pass
|
|
208
209
|
self.created.append(rel + "/.gitkeep")
|
|
209
210
|
|
|
210
211
|
def append_once(self, path, marker, template, label):
|
|
@@ -236,12 +237,8 @@ def main(argv):
|
|
|
236
237
|
on = runtimes(runtime, root)
|
|
237
238
|
s = Scaffold(tpl, root)
|
|
238
239
|
|
|
239
|
-
# shared SDLC
|
|
240
|
+
# shared SDLC (the intent/spec/plan/adr templates ship with the plugin; a project TEMPLATE.md is only an override)
|
|
240
241
|
s.put("sdlc-README.md", "docs/sdlc/README.md")
|
|
241
|
-
s.put("intent.md", "docs/sdlc/intent/TEMPLATE.md")
|
|
242
|
-
s.put("spec.md", "docs/sdlc/specs/TEMPLATE.md")
|
|
243
|
-
s.put("plan.md", "docs/sdlc/plans/TEMPLATE.md")
|
|
244
|
-
s.put("adr.md", "docs/sdlc/adr/TEMPLATE.md")
|
|
245
242
|
s.put("constitution.md", "docs/sdlc/constitution.md", render=True)
|
|
246
243
|
|
|
247
244
|
# per runtime
|
package/hooks/runtime-gate.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Runtime gate: one hook for both runtimes
|
|
3
|
-
codex-model-gate.py
|
|
2
|
+
"""Runtime gate: one hook for both runtimes (Claude Code and Codex). The older
|
|
3
|
+
fable-gate.py and codex-model-gate.py remain only as shims that exec this file.
|
|
4
4
|
|
|
5
5
|
The most capable model of a plan can stop serving an account — a rate limit, a
|
|
6
6
|
used-up usage limit, a model the account cannot reach — and the runtime's own
|
|
@@ -138,7 +138,7 @@ def profile_tier(home, tier, field, default):
|
|
|
138
138
|
with open(profile_file(home), encoding="utf-8") as fh:
|
|
139
139
|
value = json.load(fh)["tiers"][tier][field]
|
|
140
140
|
return value if isinstance(value, str) and value else default
|
|
141
|
-
except (OSError, ValueError, KeyError, TypeError):
|
|
141
|
+
except (OSError, ValueError, KeyError, TypeError, RecursionError):
|
|
142
142
|
return default
|
|
143
143
|
|
|
144
144
|
|
|
@@ -148,7 +148,7 @@ def profile_fable(home):
|
|
|
148
148
|
try:
|
|
149
149
|
with open(profile_file(home), encoding="utf-8") as fh:
|
|
150
150
|
return bool(json.load(fh).get("fable", True))
|
|
151
|
-
except (OSError, ValueError, AttributeError):
|
|
151
|
+
except (OSError, ValueError, AttributeError, RecursionError):
|
|
152
152
|
return True
|
|
153
153
|
|
|
154
154
|
|
|
@@ -205,7 +205,7 @@ def load(path=None):
|
|
|
205
205
|
return data if isinstance(data, dict) else {}
|
|
206
206
|
except OSError:
|
|
207
207
|
return import_legacy() if path in (None, STATE) else {}
|
|
208
|
-
except ValueError:
|
|
208
|
+
except (ValueError, RecursionError):
|
|
209
209
|
return {}
|
|
210
210
|
|
|
211
211
|
|
|
@@ -218,7 +218,7 @@ def import_legacy():
|
|
|
218
218
|
try:
|
|
219
219
|
with open(LEGACY_STATE, encoding="utf-8") as fh:
|
|
220
220
|
old = json.load(fh)
|
|
221
|
-
except (OSError, ValueError):
|
|
221
|
+
except (OSError, ValueError, RecursionError):
|
|
222
222
|
return {}
|
|
223
223
|
data = {"imported_from": os.path.basename(LEGACY_STATE)}
|
|
224
224
|
if isinstance(old, dict) and isinstance(old.get("unavailable"), dict):
|
|
@@ -341,7 +341,7 @@ def claude_code_version(path):
|
|
|
341
341
|
continue
|
|
342
342
|
try:
|
|
343
343
|
version = json.loads(line).get("version")
|
|
344
|
-
except (ValueError, AttributeError):
|
|
344
|
+
except (ValueError, AttributeError, RecursionError):
|
|
345
345
|
continue
|
|
346
346
|
m = re.match(r"(\d+)\.(\d+)\.(\d+)", version) if isinstance(version, str) else None
|
|
347
347
|
if m:
|
|
@@ -415,10 +415,10 @@ def claude_pre_tool_use(payload):
|
|
|
415
415
|
"hookSpecificOutput": {
|
|
416
416
|
"hookEventName": "PreToolUse",
|
|
417
417
|
"permissionDecision": "allow",
|
|
418
|
-
"permissionDecisionReason": f"
|
|
418
|
+
"permissionDecisionReason": f"runtime-gate: {why}; {name} runs on {FALLBACK}",
|
|
419
419
|
"updatedInput": {**tool_input, "model": FALLBACK},
|
|
420
420
|
"additionalContext": (
|
|
421
|
-
f"
|
|
421
|
+
f"runtime-gate routed this {name} agent to {FALLBACK}: {why}. "
|
|
422
422
|
f"Its answer is {FALLBACK}'s, not Fable's. "
|
|
423
423
|
"`~/.claude/hooks/runtime-gate.py clear` re-enables Fable early."
|
|
424
424
|
),
|
|
@@ -444,7 +444,7 @@ def last_transcript_model(path):
|
|
|
444
444
|
for line in reversed(transcript_tail(path)):
|
|
445
445
|
try:
|
|
446
446
|
model = (json.loads(line).get("message") or {}).get("model")
|
|
447
|
-
except (ValueError, AttributeError):
|
|
447
|
+
except (ValueError, AttributeError, RecursionError):
|
|
448
448
|
continue
|
|
449
449
|
if isinstance(model, str) and model and not model.startswith("<"):
|
|
450
450
|
return model
|
|
@@ -504,9 +504,10 @@ ROLLOUT_TAIL = 64 * 1024
|
|
|
504
504
|
|
|
505
505
|
def number(value):
|
|
506
506
|
try:
|
|
507
|
-
|
|
507
|
+
x = None if value is None else float(value)
|
|
508
508
|
except (TypeError, ValueError):
|
|
509
509
|
return None
|
|
510
|
+
return x if x is None or math.isfinite(x) else None # NaN/Infinity is no reading
|
|
510
511
|
|
|
511
512
|
|
|
512
513
|
def record_quota(weekly, five_hour, resets_at, source, seen_at=None):
|
|
@@ -564,7 +565,7 @@ def rollout_limits(path):
|
|
|
564
565
|
continue
|
|
565
566
|
try:
|
|
566
567
|
event = json.loads(line)
|
|
567
|
-
except ValueError:
|
|
568
|
+
except (ValueError, RecursionError):
|
|
568
569
|
continue
|
|
569
570
|
limits = (event.get("payload") or {}).get("rate_limits") or {}
|
|
570
571
|
weekly = five = None
|
|
@@ -801,10 +802,10 @@ def codex_pre_tool_use(payload):
|
|
|
801
802
|
journal_fallback(payload, label, model, to, rec)
|
|
802
803
|
out["hookSpecificOutput"]["permissionDecision"] = "allow"
|
|
803
804
|
out["hookSpecificOutput"]["updatedInput"] = updated
|
|
804
|
-
context = (f"
|
|
805
|
+
context = (f"runtime-gate: {why}. {label} runs on {to}{via} instead, so its answer is "
|
|
805
806
|
f"{to}'s, not {EXPERT}'s. Say so when you report it. ")
|
|
806
807
|
else:
|
|
807
|
-
context = (f"
|
|
808
|
+
context = (f"runtime-gate: {why}. {label} is not rerouted ({not_why}): it runs on {EXPERT} "
|
|
808
809
|
"and may fail the same way. Say so when you report it. ")
|
|
809
810
|
out["hookSpecificOutput"]["additionalContext"] = context + "`runtime-gate.py clear` re-enables the expert model early."
|
|
810
811
|
respond(out)
|
|
@@ -864,7 +865,7 @@ def profile():
|
|
|
864
865
|
with open(profile_file(HOME), encoding="utf-8") as fh:
|
|
865
866
|
data = json.load(fh)
|
|
866
867
|
return data if isinstance(data, dict) else {}
|
|
867
|
-
except (OSError, ValueError):
|
|
868
|
+
except (OSError, ValueError, RecursionError):
|
|
868
869
|
return {}
|
|
869
870
|
|
|
870
871
|
|
|
@@ -919,7 +920,7 @@ def project_task(cwd):
|
|
|
919
920
|
try:
|
|
920
921
|
with open(path, encoding="utf-8") as fh:
|
|
921
922
|
task = json.load(fh).get("task_id")
|
|
922
|
-
except (OSError, ValueError, AttributeError):
|
|
923
|
+
except (OSError, ValueError, AttributeError, RecursionError):
|
|
923
924
|
return None, None
|
|
924
925
|
return (here, task) if task else (None, None)
|
|
925
926
|
parent = os.path.dirname(here)
|
|
@@ -985,11 +986,18 @@ def codex_missed_reroute(payload):
|
|
|
985
986
|
"or an unavailable model, stop and say so instead of retrying.")}})
|
|
986
987
|
|
|
987
988
|
|
|
989
|
+
def ai_unattended():
|
|
990
|
+
"""AI_UNATTENDED is set when it is non-empty and not 0, false or no (case
|
|
991
|
+
and blanks ignored): one rule in every reader (risk 0587)."""
|
|
992
|
+
return os.environ.get("AI_UNATTENDED", "").strip().lower() not in ("", "0", "false", "no")
|
|
993
|
+
|
|
994
|
+
|
|
988
995
|
def unattended():
|
|
989
|
-
"""Nobody can answer an ask: AI_UNATTENDED
|
|
990
|
-
CLAUDE_CODE_SESSION_ATTENDED=0 (set by `claude -p` and the SDK).
|
|
991
|
-
an older Claude Code — still asks.
|
|
992
|
-
|
|
996
|
+
"""Nobody can answer an ask: AI_UNATTENDED set (ai_unattended), or Claude
|
|
997
|
+
Code's own CLAUDE_CODE_SESSION_ATTENDED=0 (set by `claude -p` and the SDK).
|
|
998
|
+
Unset — an older Claude Code — still asks. dialog.json background_sessions
|
|
999
|
+
is the dialog's opt-in only: this gate still treats ATTENDED=0 as unattended."""
|
|
1000
|
+
return ai_unattended() or os.environ.get("CLAUDE_CODE_SESSION_ATTENDED") == "0"
|
|
993
1001
|
|
|
994
1002
|
|
|
995
1003
|
def will_ask():
|
|
@@ -1117,24 +1125,31 @@ def disabled():
|
|
|
1117
1125
|
|
|
1118
1126
|
|
|
1119
1127
|
def context_line(payload):
|
|
1120
|
-
"""`Opus 5.5 [1m] · 123k/1000k (12%)`
|
|
1128
|
+
"""`Opus 5.5 [1m] high · 123k/1000k (12%)` (the effort right after the model,
|
|
1129
|
+
when the payload names one) from a Claude Code statusline payload, or ""
|
|
1121
1130
|
when it names neither the model nor the context. The context_window fields differ
|
|
1122
1131
|
between Claude Code versions: the tokens come from current_usage (input plus cache
|
|
1123
1132
|
reads and writes, what the next turn re-reads), else from used_percentage."""
|
|
1124
1133
|
model = payload.get("model")
|
|
1125
1134
|
name = str(model.get("display_name") or model.get("id") or "") if isinstance(model, dict) else ""
|
|
1135
|
+
effort = payload.get("effort")
|
|
1136
|
+
effort = effort.get("level") if isinstance(effort, dict) else None
|
|
1137
|
+
if name and isinstance(effort, str) and effort.strip():
|
|
1138
|
+
name = "%s %s" % (name, effort.strip())
|
|
1126
1139
|
window = payload.get("context_window")
|
|
1127
1140
|
window = window if isinstance(window, dict) else {}
|
|
1128
1141
|
|
|
1129
|
-
def
|
|
1130
|
-
|
|
1142
|
+
def finite_number(value):
|
|
1143
|
+
# finite only: Infinity makes round() raise and the whole line would be lost
|
|
1144
|
+
return (value if isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
1145
|
+
and math.isfinite(value) and value >= 0 else None)
|
|
1131
1146
|
|
|
1132
|
-
size =
|
|
1133
|
-
pct =
|
|
1147
|
+
size = finite_number(window.get("context_window_size"))
|
|
1148
|
+
pct = finite_number(window.get("used_percentage"))
|
|
1134
1149
|
usage = window.get("current_usage")
|
|
1135
1150
|
used = None
|
|
1136
1151
|
if isinstance(usage, dict):
|
|
1137
|
-
parts = [
|
|
1152
|
+
parts = [finite_number(usage.get(k)) for k in
|
|
1138
1153
|
("input_tokens", "cache_creation_input_tokens", "cache_read_input_tokens")]
|
|
1139
1154
|
if any(p is not None for p in parts):
|
|
1140
1155
|
used = sum(p or 0 for p in parts)
|
|
@@ -1148,15 +1163,96 @@ def context_line(payload):
|
|
|
1148
1163
|
return " · ".join(part for part in (name, context) if part)
|
|
1149
1164
|
|
|
1150
1165
|
|
|
1166
|
+
SL_RESET, SL_SEP = "\033[00m", " \033[02m|\033[00m "
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
def reset_text(value, fmt):
|
|
1170
|
+
"""A rate-limit window's resets_at (epoch seconds or an ISO timestamp) as
|
|
1171
|
+
local time in `fmt`, or ""."""
|
|
1172
|
+
try:
|
|
1173
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
1174
|
+
when = dt.datetime.fromtimestamp(value)
|
|
1175
|
+
elif isinstance(value, str) and value.strip():
|
|
1176
|
+
text = value.strip()
|
|
1177
|
+
when = (dt.datetime.fromtimestamp(float(text)) if re.fullmatch(r"\d+(\.\d+)?", text)
|
|
1178
|
+
else dt.datetime.fromisoformat(text.replace("Z", "+00:00")).astimezone())
|
|
1179
|
+
else:
|
|
1180
|
+
return ""
|
|
1181
|
+
return when.strftime(fmt)
|
|
1182
|
+
except (ValueError, OverflowError, OSError):
|
|
1183
|
+
return ""
|
|
1184
|
+
|
|
1185
|
+
|
|
1186
|
+
def git_branch(cwd):
|
|
1187
|
+
"""The branch checked out in cwd, else the short commit, else ""."""
|
|
1188
|
+
for args in (["symbolic-ref", "--short", "-q", "HEAD"], ["rev-parse", "--short", "HEAD"]):
|
|
1189
|
+
try:
|
|
1190
|
+
done = subprocess.run(["git", "--no-optional-locks", "-C", cwd] + args, capture_output=True,
|
|
1191
|
+
text=True, timeout=2, check=False)
|
|
1192
|
+
except (OSError, subprocess.SubprocessError):
|
|
1193
|
+
return ""
|
|
1194
|
+
if done.returncode == 0 and done.stdout.strip():
|
|
1195
|
+
return done.stdout.strip()
|
|
1196
|
+
return ""
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
def second_line(payload):
|
|
1200
|
+
"""`user@host:cwd | branch | 5h:12% (↻14:00) 7d:41% (↻Fri 03:04) | $1.23`, the
|
|
1201
|
+
colours of a Debian colour PS1; each part after the prompt is skipped when its
|
|
1202
|
+
data is missing."""
|
|
1203
|
+
workspace = payload.get("workspace")
|
|
1204
|
+
cwd = workspace.get("current_dir") if isinstance(workspace, dict) else None
|
|
1205
|
+
cwd = cwd if isinstance(cwd, str) and cwd else os.getcwd()
|
|
1206
|
+
user = os.environ.get("USER") or os.environ.get("LOGNAME") or ""
|
|
1207
|
+
if not user:
|
|
1208
|
+
try:
|
|
1209
|
+
import getpass # pylint: disable=import-outside-toplevel
|
|
1210
|
+
user = getpass.getuser()
|
|
1211
|
+
except (ImportError, KeyError, OSError):
|
|
1212
|
+
user = "?"
|
|
1213
|
+
import socket # pylint: disable=import-outside-toplevel
|
|
1214
|
+
host = socket.gethostname().split(".")[0]
|
|
1215
|
+
out = "\033[01;32m%s@%s%s:\033[01;34m%s%s" % (user, host, SL_RESET, cwd, SL_RESET)
|
|
1216
|
+
branch = git_branch(cwd) if os.path.isdir(cwd) else ""
|
|
1217
|
+
if branch:
|
|
1218
|
+
out += "%s\033[35m%s%s" % (SL_SEP, branch, SL_RESET)
|
|
1219
|
+
limits = payload.get("rate_limits")
|
|
1220
|
+
limits = limits if isinstance(limits, dict) else {}
|
|
1221
|
+
parts = []
|
|
1222
|
+
for key, label, fmt in (("five_hour", "5h", "%H:%M"), ("seven_day", "7d", "%a %H:%M")):
|
|
1223
|
+
window = limits.get(key)
|
|
1224
|
+
pct = window.get("used_percentage") if isinstance(window, dict) else None
|
|
1225
|
+
if isinstance(pct, (int, float)) and not isinstance(pct, bool) and math.isfinite(pct):
|
|
1226
|
+
reset = reset_text(window.get("resets_at"), fmt)
|
|
1227
|
+
parts.append("%s:%d%%%s" % (label, round(pct), " (↻%s)" % reset if reset else ""))
|
|
1228
|
+
if parts:
|
|
1229
|
+
out += "%s\033[33m%s%s" % (SL_SEP, " ".join(parts), SL_RESET)
|
|
1230
|
+
cost = payload.get("cost")
|
|
1231
|
+
cost = cost.get("total_cost_usd") if isinstance(cost, dict) else None
|
|
1232
|
+
if isinstance(cost, (int, float)) and not isinstance(cost, bool) and math.isfinite(cost):
|
|
1233
|
+
out += "%s\033[32m$%.2f%s" % (SL_SEP, cost, SL_RESET)
|
|
1234
|
+
return out
|
|
1235
|
+
|
|
1236
|
+
|
|
1237
|
+
def emit(text):
|
|
1238
|
+
"""One statusline line as UTF-8 whatever the locale (`·` and `↻` are not ASCII)."""
|
|
1239
|
+
sys.stdout.flush()
|
|
1240
|
+
sys.stdout.buffer.write((text + "\n").encode("utf-8", "replace"))
|
|
1241
|
+
sys.stdout.buffer.flush()
|
|
1242
|
+
|
|
1243
|
+
|
|
1151
1244
|
def statusline_main(argv):
|
|
1152
|
-
"""`statusline [--context] [--then <command>]`: check the weekly limit,
|
|
1153
|
-
model and
|
|
1154
|
-
|
|
1155
|
-
|
|
1245
|
+
"""`statusline [--context] [--line] [--then <command>]`: check the weekly limit,
|
|
1246
|
+
print the model, effort and context use with --context (cyan), the prompt line
|
|
1247
|
+
(user@host:cwd | branch | limits | cost) with --line, then hand the same input
|
|
1248
|
+
to the user's own statusline command and pass its output and exit code through.
|
|
1249
|
+
Neither the check nor the lines ever get in the way of the statusline."""
|
|
1156
1250
|
data = sys.stdin.buffer.read()
|
|
1157
|
-
|
|
1158
|
-
|
|
1251
|
+
flags = set()
|
|
1252
|
+
while argv[1:2] in (["--context"], ["--line"]):
|
|
1253
|
+
flags.add(argv[1])
|
|
1159
1254
|
argv = argv[:1] + argv[2:]
|
|
1255
|
+
show_context = "--context" in flags
|
|
1160
1256
|
payload = None
|
|
1161
1257
|
try:
|
|
1162
1258
|
payload = json.loads(data.decode("utf-8", "replace"))
|
|
@@ -1171,7 +1267,12 @@ def statusline_main(argv):
|
|
|
1171
1267
|
try:
|
|
1172
1268
|
line = context_line(payload)
|
|
1173
1269
|
if line:
|
|
1174
|
-
|
|
1270
|
+
emit("\033[36m%s%s" % (line, SL_RESET))
|
|
1271
|
+
except Exception: # pylint: disable=broad-exception-caught # fail open, as above
|
|
1272
|
+
pass
|
|
1273
|
+
if "--line" in flags and isinstance(payload, dict):
|
|
1274
|
+
try:
|
|
1275
|
+
emit(second_line(payload))
|
|
1175
1276
|
except Exception: # pylint: disable=broad-exception-caught # fail open, as above
|
|
1176
1277
|
pass
|
|
1177
1278
|
if len(argv) >= 3 and argv[1] == "--then" and argv[2].strip():
|