@miller-tech/uap 1.159.0 → 1.160.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/dist/.tsbuildinfo +1 -1
- package/dist/cli/fidelity.d.ts.map +1 -1
- package/dist/cli/fidelity.js +15 -0
- package/dist/cli/fidelity.js.map +1 -1
- package/dist/cli/policy-select.d.ts +10 -0
- package/dist/cli/policy-select.d.ts.map +1 -1
- package/dist/cli/policy-select.js +30 -0
- package/dist/cli/policy-select.js.map +1 -1
- package/dist/cli/policy.d.ts.map +1 -1
- package/dist/cli/policy.js +20 -1
- package/dist/cli/policy.js.map +1 -1
- package/dist/cli/setup.d.ts.map +1 -1
- package/dist/cli/setup.js +20 -0
- package/dist/cli/setup.js.map +1 -1
- package/package.json +2 -2
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/commitment_reserve.py +341 -0
- package/src/policies/schemas/policies/commitment-reserve.md +67 -0
- package/src/policies/schemas/policies/proportional-commitment.md +39 -0
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +158 -5
- package/tools/agents/tests/test_doubling_break.py +239 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""commitment-reserve enforcer: an all-in move with no way back is blocked
|
|
2
|
+
until a reserve exists ("never go full" -- commit hard, but hold something back).
|
|
3
|
+
|
|
4
|
+
The failure class this closes (all observed live on this project):
|
|
5
|
+
- a local model "fixed" gates by DELETING real implementation files into
|
|
6
|
+
stubs (guts-source incident) -- a wholesale overwrite with no backup;
|
|
7
|
+
- destructive git resets / cleans that vaporized uncommitted work the
|
|
8
|
+
session then could not reconstruct;
|
|
9
|
+
- bare force-pushes that rewrote remote history with no local reserve.
|
|
10
|
+
|
|
11
|
+
The rule is NOT "never do destructive things" -- it is "destructive moves are
|
|
12
|
+
allowed only once something is held in reserve". Every block message names the
|
|
13
|
+
reserve that unlocks it.
|
|
14
|
+
|
|
15
|
+
Matching is SEGMENT-ANCHORED: the command is split on ;/&/| into segments,
|
|
16
|
+
quoted spans are stripped, and a rule fires only when the segment's leading
|
|
17
|
+
verb is git/rm. A `grep "git reset --hard"`, an `echo`, or a commit message
|
|
18
|
+
that merely MENTIONS a destructive pattern never trips the gate.
|
|
19
|
+
|
|
20
|
+
Shell (Bash/run_bash) all-in moves, blocked without a reserve:
|
|
21
|
+
- `git reset --hard` reserve: `git stash` first (inline counts)
|
|
22
|
+
- forced push (`--force`, `-f`, `+refspec`) `--force-with-lease` is allowed
|
|
23
|
+
by THIS gate; note the project git-safety hook may still block it -- the
|
|
24
|
+
sanctioned fallback there is a merge commit, not a bigger hammer.
|
|
25
|
+
- `git clean -f...` / `--force` reserve: `git stash -u` first
|
|
26
|
+
- `git checkout/restore` wholesale discards (final arg `.`/`*`, incl.
|
|
27
|
+
`checkout HEAD -- .` and `restore --worktree .`)
|
|
28
|
+
- recursive+forced delete of a protected source root (src, test, tests,
|
|
29
|
+
lib, tools, scripts, or the policy-definition dir) -- scratch/derived
|
|
30
|
+
dirs (node_modules, dist, /tmp) stay allowed.
|
|
31
|
+
|
|
32
|
+
Unlocks (verified, not honor-system where possible):
|
|
33
|
+
- an inline reserve-CREATING stash anywhere in the command (`git stash`,
|
|
34
|
+
`git stash push/-u/--include-untracked`). Reserve-destroying or inert
|
|
35
|
+
stash subcommands (drop/clear/pop/list/show/apply/branch) do NOT unlock.
|
|
36
|
+
- `UAP_RESERVE_OK=1` ONLY as a leading env assignment of the destructive
|
|
37
|
+
segment itself (set it only AFTER creating a backup/stash). A mention
|
|
38
|
+
elsewhere in the command (echo, comment, quoted string) does not count.
|
|
39
|
+
|
|
40
|
+
Write-tool stub overwrites (the guts-source incident), blocked:
|
|
41
|
+
- overwriting an existing source file >= 4 KiB with content < 20% of its
|
|
42
|
+
size. Reserve: a same-day backup under `.uap-backups/<today>/` that is a
|
|
43
|
+
real regular file (not a symlink) of at least half the original's size --
|
|
44
|
+
an empty or unrelated same-named file does not count. Incremental Edit
|
|
45
|
+
operations are never touched by this gate.
|
|
46
|
+
|
|
47
|
+
Known accepted limits (heuristic gate, fail-open by design): absolute-path
|
|
48
|
+
rm targets, `find -delete`, and xargs pipelines are not modeled; the gate's
|
|
49
|
+
purpose is stopping reflexive all-in moves, not adversarial evasion.
|
|
50
|
+
|
|
51
|
+
Operator overrides: UAP_RESERVE_OK=1 or UAP_COMMITMENT_RESERVE_OFF=1 in the
|
|
52
|
+
environment. DELIVER_ACTIVE=1 sessions are exempt (the deliver harness keeps
|
|
53
|
+
its own snapshot/restore reserve).
|
|
54
|
+
"""
|
|
55
|
+
import os
|
|
56
|
+
import re
|
|
57
|
+
import sys
|
|
58
|
+
from datetime import date
|
|
59
|
+
from pathlib import Path
|
|
60
|
+
|
|
61
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
62
|
+
from _common import emit, parse_cli, repo_root, worktree_root # noqa: E402
|
|
63
|
+
|
|
64
|
+
BASH_OPS = {"Bash", "bash", "run_bash", "shell", "execute_command"}
|
|
65
|
+
WRITE_OPS = {"Write", "write", "write_file", "create_file"}
|
|
66
|
+
|
|
67
|
+
RESERVE_TOKEN = "UAP_RESERVE_OK=1"
|
|
68
|
+
|
|
69
|
+
# stash CREATE forms hold a reserve; drop/clear/pop destroy or consume one and
|
|
70
|
+
# list/show/apply/branch create nothing -- none of those unlock.
|
|
71
|
+
_STASH_CREATE = re.compile(
|
|
72
|
+
r"\bgit\s+stash\b(?!\s+(?:drop|clear|pop|list|show|apply|branch)\b)"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
_QUOTED_SPAN = re.compile(r"'[^']*'|\"[^\"]*\"")
|
|
76
|
+
_SEGMENT_SPLIT = re.compile(r"[;|&\n]+")
|
|
77
|
+
_ENV_ASSIGN = re.compile(r"[A-Za-z_]\w*=\S*")
|
|
78
|
+
_WRAPPERS = {"sudo", "env", "command", "nohup"}
|
|
79
|
+
# git global flags that take a separate value token (skip flag + value when
|
|
80
|
+
# locating the subcommand: `git -C /x reset --hard`).
|
|
81
|
+
_GIT_VALUED_FLAGS = {"-C", "-c", "--git-dir", "--work-tree"}
|
|
82
|
+
|
|
83
|
+
# Losing these roots loses the work itself. Derived/scratch dirs
|
|
84
|
+
# (node_modules, dist, build, coverage, tmp) are reconstructible and
|
|
85
|
+
# deliberately NOT protected.
|
|
86
|
+
PROTECTED_ROOTS = {"src", "test", "tests", "lib", "policies", "tools", "scripts"}
|
|
87
|
+
|
|
88
|
+
# Deleting a SCRATCH dir nested under a protected root (rm -rf src/__pycache__)
|
|
89
|
+
# is targeted cleanup, not an all-in move -- the reconstructible-dir principle
|
|
90
|
+
# applies at any depth.
|
|
91
|
+
SCRATCH_NAMES = {
|
|
92
|
+
"__pycache__", "node_modules", "dist", "build", "coverage", ".cache",
|
|
93
|
+
"tmp", ".next", ".turbo", "generated", ".pytest_cache",
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
WHOLESALE_TARGETS = {".", "./", "*", "./*", "/", "..", "../", "~", "~/"}
|
|
97
|
+
|
|
98
|
+
MSG_PREFIX = "commitment-reserve: all-in move with no way back -- "
|
|
99
|
+
MSG_RESET = (
|
|
100
|
+
"`git reset --hard` discards every uncommitted change. Hold a reserve "
|
|
101
|
+
"first: `git stash` (an inline `git stash && ...` also counts), then retry."
|
|
102
|
+
)
|
|
103
|
+
MSG_PUSH = (
|
|
104
|
+
"forced push rewrites remote history with nothing held back. Prefer "
|
|
105
|
+
"`--force-with-lease`; if the git-safety hook blocks that too, use the "
|
|
106
|
+
"merge-commit fallback instead of forcing."
|
|
107
|
+
)
|
|
108
|
+
MSG_CLEAN = (
|
|
109
|
+
"`git clean --force` deletes untracked files wholesale. Hold a reserve "
|
|
110
|
+
"first: `git stash -u`, then retry."
|
|
111
|
+
)
|
|
112
|
+
MSG_DISCARD = (
|
|
113
|
+
"wholesale discard of the working tree. Restore specific files instead, "
|
|
114
|
+
"or `git stash` first so the discarded state stays recoverable."
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _segments(cmd: str) -> list[list[str]]:
|
|
119
|
+
"""Split a command into per-segment token lists, with line continuations
|
|
120
|
+
joined and quoted spans stripped (so mentions inside strings never match)."""
|
|
121
|
+
flat = cmd.replace("\\\n", " ")
|
|
122
|
+
flat = _QUOTED_SPAN.sub(" ", flat)
|
|
123
|
+
return [seg.split() for seg in _SEGMENT_SPLIT.split(flat) if seg.strip()]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _strip_prefix(tokens: list[str]) -> tuple[list[str], bool]:
|
|
127
|
+
"""Drop leading env assignments / wrappers; report whether the reserve
|
|
128
|
+
marker led the segment."""
|
|
129
|
+
reserve = False
|
|
130
|
+
i = 0
|
|
131
|
+
while i < len(tokens):
|
|
132
|
+
t = tokens[i]
|
|
133
|
+
if _ENV_ASSIGN.fullmatch(t):
|
|
134
|
+
if t == RESERVE_TOKEN:
|
|
135
|
+
reserve = True
|
|
136
|
+
i += 1
|
|
137
|
+
continue
|
|
138
|
+
if t in _WRAPPERS:
|
|
139
|
+
i += 1
|
|
140
|
+
continue
|
|
141
|
+
break
|
|
142
|
+
return tokens[i:], reserve
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _git_subcommand(rest: list[str]) -> tuple[str, list[str]]:
|
|
146
|
+
"""Locate the git subcommand, skipping global flags (incl. valued ones)."""
|
|
147
|
+
i = 0
|
|
148
|
+
while i < len(rest):
|
|
149
|
+
t = rest[i]
|
|
150
|
+
if t in _GIT_VALUED_FLAGS:
|
|
151
|
+
i += 2
|
|
152
|
+
continue
|
|
153
|
+
if t.startswith("-"):
|
|
154
|
+
i += 1
|
|
155
|
+
continue
|
|
156
|
+
return t, rest[i + 1 :]
|
|
157
|
+
return "", []
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _rf_flag(tokens: list[str]) -> bool:
|
|
161
|
+
flags = "".join(t.lstrip("-") for t in tokens if t.startswith("-") and not t.startswith("--"))
|
|
162
|
+
long_flags = {t for t in tokens if t.startswith("--")}
|
|
163
|
+
recursive = bool(re.search(r"[rR]", flags)) or "--recursive" in long_flags
|
|
164
|
+
forced = "f" in flags or "--force" in long_flags
|
|
165
|
+
return recursive and forced
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _check_git_segment(sub: str, rest: list[str]) -> str | None:
|
|
169
|
+
if sub == "reset" and "--hard" in rest:
|
|
170
|
+
return MSG_RESET
|
|
171
|
+
if sub == "push":
|
|
172
|
+
for t in rest:
|
|
173
|
+
if t in {"--force", "-f"}:
|
|
174
|
+
return MSG_PUSH
|
|
175
|
+
if t.startswith("+") and len(t) > 1:
|
|
176
|
+
return MSG_PUSH
|
|
177
|
+
if sub == "clean":
|
|
178
|
+
dry = any(
|
|
179
|
+
t == "--dry-run" or (re.fullmatch(r"-[A-Za-z]+", t) and "n" in t)
|
|
180
|
+
for t in rest
|
|
181
|
+
)
|
|
182
|
+
forced = any(t == "--force" or re.fullmatch(r"-[A-Za-z]*f[A-Za-z]*", t) for t in rest)
|
|
183
|
+
if forced and not dry:
|
|
184
|
+
return MSG_CLEAN
|
|
185
|
+
if sub in {"checkout", "restore"}:
|
|
186
|
+
if any(t in {"-f", "--force"} for t in rest):
|
|
187
|
+
return MSG_DISCARD
|
|
188
|
+
positional = [t for t in rest if t == "--" or not t.startswith("-")]
|
|
189
|
+
if any(t in WHOLESALE_TARGETS for t in positional):
|
|
190
|
+
return MSG_DISCARD
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _check_rm_segment(rest: list[str]) -> str | None:
|
|
195
|
+
if not _rf_flag(rest):
|
|
196
|
+
return None
|
|
197
|
+
for t in rest:
|
|
198
|
+
if t.startswith("-"):
|
|
199
|
+
continue
|
|
200
|
+
if t in WHOLESALE_TARGETS or t.startswith(("..", "~")):
|
|
201
|
+
return t
|
|
202
|
+
norm = t[2:] if t.startswith("./") else t
|
|
203
|
+
if norm.rstrip("/").rsplit("/", 1)[-1] in SCRATCH_NAMES:
|
|
204
|
+
continue
|
|
205
|
+
root = norm.split("/", 1)[0]
|
|
206
|
+
if root in PROTECTED_ROOTS:
|
|
207
|
+
return t
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# Stub-overwrite thresholds: an existing source file this big replaced by
|
|
212
|
+
# content this much smaller is a guts-source overwrite, not an edit.
|
|
213
|
+
MIN_OLD_BYTES = 4096
|
|
214
|
+
SHRINK_RATIO = 0.20
|
|
215
|
+
BACKUP_MIN_RATIO = 0.5 # a real reserve is a copy, not a same-named stub
|
|
216
|
+
|
|
217
|
+
CODE_EXT = {
|
|
218
|
+
".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".go", ".rs",
|
|
219
|
+
".java", ".rb", ".c", ".h", ".cpp", ".hpp", ".cs", ".sh",
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _env_reserve() -> bool:
|
|
224
|
+
return (
|
|
225
|
+
os.environ.get("UAP_RESERVE_OK") == "1"
|
|
226
|
+
or os.environ.get("UAP_COMMITMENT_RESERVE_OFF") == "1"
|
|
227
|
+
or os.environ.get("DELIVER_ACTIVE") == "1"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _backup_exists_today(target: Path, old_size: int) -> bool:
|
|
232
|
+
"""A same-day backup under .uap-backups/<today>/ is the reserve that
|
|
233
|
+
unlocks a stub overwrite (the mandatory-file-backup flow). It must be a
|
|
234
|
+
real regular file of at least half the original's size -- an empty or
|
|
235
|
+
symlinked same-named file is not a reserve."""
|
|
236
|
+
today = date.today().isoformat()
|
|
237
|
+
floor = max(1, int(old_size * BACKUP_MIN_RATIO))
|
|
238
|
+
for root in {worktree_root(), repo_root()}:
|
|
239
|
+
day_dir = root / ".uap-backups" / today
|
|
240
|
+
if not day_dir.is_dir():
|
|
241
|
+
continue
|
|
242
|
+
candidates: list[Path] = []
|
|
243
|
+
try:
|
|
244
|
+
rel = target.relative_to(root)
|
|
245
|
+
mirrored = day_dir / rel
|
|
246
|
+
if mirrored.exists():
|
|
247
|
+
candidates.append(mirrored)
|
|
248
|
+
except ValueError:
|
|
249
|
+
pass
|
|
250
|
+
try:
|
|
251
|
+
candidates.extend(day_dir.rglob(target.name))
|
|
252
|
+
except (OSError, ValueError):
|
|
253
|
+
pass
|
|
254
|
+
for p in candidates:
|
|
255
|
+
try:
|
|
256
|
+
if p.name != target.name or p.is_symlink() or not p.is_file():
|
|
257
|
+
continue
|
|
258
|
+
if p.stat().st_size >= floor:
|
|
259
|
+
return True
|
|
260
|
+
except (OSError, ValueError):
|
|
261
|
+
continue
|
|
262
|
+
return False
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _check_bash(cmd: str) -> None:
|
|
266
|
+
stash_reserve = bool(_STASH_CREATE.search(_QUOTED_SPAN.sub(" ", cmd)))
|
|
267
|
+
for tokens in _segments(cmd):
|
|
268
|
+
tokens, seg_reserve = _strip_prefix(tokens)
|
|
269
|
+
if not tokens or seg_reserve or stash_reserve:
|
|
270
|
+
continue
|
|
271
|
+
verb = tokens[0].rsplit("/", 1)[-1]
|
|
272
|
+
if verb == "git":
|
|
273
|
+
sub, rest = _git_subcommand(tokens[1:])
|
|
274
|
+
why = _check_git_segment(sub, rest)
|
|
275
|
+
if why:
|
|
276
|
+
emit(False, MSG_PREFIX + why)
|
|
277
|
+
elif verb == "rm":
|
|
278
|
+
target = _check_rm_segment(tokens[1:])
|
|
279
|
+
if target:
|
|
280
|
+
emit(
|
|
281
|
+
False,
|
|
282
|
+
"commitment-reserve: recursive forced delete of a source "
|
|
283
|
+
f"root ('{target}') removes the work itself, not scratch. "
|
|
284
|
+
"Delete specific files, or create a backup/stash first.",
|
|
285
|
+
)
|
|
286
|
+
emit(True, "no all-in pattern")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _check_write(args: dict) -> None:
|
|
290
|
+
raw_path = str(args.get("file_path") or args.get("path") or "")
|
|
291
|
+
if not raw_path:
|
|
292
|
+
emit(True, "no target path")
|
|
293
|
+
try:
|
|
294
|
+
target = Path(raw_path).resolve()
|
|
295
|
+
except (OSError, ValueError, RuntimeError):
|
|
296
|
+
emit(True, "unresolvable path")
|
|
297
|
+
if target.suffix.lower() not in CODE_EXT:
|
|
298
|
+
emit(True, "not a source file")
|
|
299
|
+
try:
|
|
300
|
+
old_size = target.stat().st_size
|
|
301
|
+
except (OSError, ValueError):
|
|
302
|
+
emit(True, "new file")
|
|
303
|
+
if old_size < MIN_OLD_BYTES:
|
|
304
|
+
emit(True, "existing file below stub-overwrite threshold")
|
|
305
|
+
content = args.get("content")
|
|
306
|
+
if content is None:
|
|
307
|
+
content = args.get("contents", args.get("text"))
|
|
308
|
+
if not isinstance(content, str):
|
|
309
|
+
emit(True, "no inline content to compare")
|
|
310
|
+
if len(content.encode("utf-8", "replace")) >= old_size * SHRINK_RATIO:
|
|
311
|
+
emit(True, "overwrite keeps comparable substance")
|
|
312
|
+
if _backup_exists_today(target, old_size):
|
|
313
|
+
emit(True, "reserve exists (.uap-backups backup for today)")
|
|
314
|
+
emit(
|
|
315
|
+
False,
|
|
316
|
+
"commitment-reserve: this overwrite replaces "
|
|
317
|
+
f"{target.name} ({old_size} bytes) with under 20% of its content -- that "
|
|
318
|
+
"is how real implementations get gutted into stubs. Edit incrementally, "
|
|
319
|
+
f"or back the file up to .uap-backups/{date.today().isoformat()}/ first "
|
|
320
|
+
"(that backup is the reserve that unlocks the overwrite).",
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def main() -> None:
|
|
325
|
+
operation, args = parse_cli()
|
|
326
|
+
if not isinstance(args, dict):
|
|
327
|
+
emit(True, "non-dict args payload")
|
|
328
|
+
if _env_reserve():
|
|
329
|
+
emit(True, "reserve confirmed via environment")
|
|
330
|
+
if operation in BASH_OPS:
|
|
331
|
+
cmd = str(args.get("command") or "")
|
|
332
|
+
if not cmd:
|
|
333
|
+
emit(True, "no command payload")
|
|
334
|
+
_check_bash(cmd)
|
|
335
|
+
if operation in WRITE_OPS:
|
|
336
|
+
_check_write(args)
|
|
337
|
+
emit(True, "operation out of scope")
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
if __name__ == "__main__":
|
|
341
|
+
main()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# commitment-reserve
|
|
2
|
+
|
|
3
|
+
**Category**: safety
|
|
4
|
+
**Level**: REQUIRED
|
|
5
|
+
**Enforcement Stage**: pre-exec
|
|
6
|
+
**Tags**: safety, reversibility, backup, restraint, never-go-full
|
|
7
|
+
|
|
8
|
+
## Rule
|
|
9
|
+
|
|
10
|
+
An **all-in move with no way back** is blocked until a reserve exists. Commit
|
|
11
|
+
hard to an approach — but never commit everything ("never go full": the ones
|
|
12
|
+
who hold something back are the ones who can still recover).
|
|
13
|
+
|
|
14
|
+
Matching is segment-anchored and quote-aware: a rule fires only when the
|
|
15
|
+
segment's leading verb is `git`/`rm`, so a `grep`, an `echo`, or a commit
|
|
16
|
+
message that merely *mentions* a destructive pattern never trips the gate.
|
|
17
|
+
|
|
18
|
+
Blocked without a reserve (`Bash`/`run_bash`):
|
|
19
|
+
|
|
20
|
+
- `git reset --hard` — reserve: `git stash` first (an inline
|
|
21
|
+
`git stash && …` in the same command also counts).
|
|
22
|
+
- Forced push (`--force`, `-f`, `+refspec`) — `--force-with-lease` is allowed
|
|
23
|
+
by this gate; if the project's git-safety hook blocks that too, the
|
|
24
|
+
sanctioned fallback is a merge commit, not a bigger hammer.
|
|
25
|
+
- `git clean -f…` / `--force` — reserve: `git stash -u` first. Dry runs
|
|
26
|
+
(`-n` / `--dry-run`) stay allowed.
|
|
27
|
+
- `git checkout`/`restore` wholesale discards — final arg `.`/`*`, including
|
|
28
|
+
`checkout HEAD -- .`, `restore --worktree .`, and bare `checkout -f`.
|
|
29
|
+
- Recursive **and** forced delete (`rm -rf` and flag-order variants) of a
|
|
30
|
+
protected source root (`src`, `test`, `tests`, `lib`, `tools`, `scripts`,
|
|
31
|
+
the policy-definition dir), of the whole tree (`.`, `*`, `/`), or of a
|
|
32
|
+
parent/home escape (`..`, `~`). Scratch/derived dirs stay allowed at any
|
|
33
|
+
depth (`node_modules`, `dist`, `src/__pycache__`, `src/generated`, …) —
|
|
34
|
+
they are reconstructible; source is not.
|
|
35
|
+
|
|
36
|
+
Blocked stub overwrites (`Write`):
|
|
37
|
+
|
|
38
|
+
- Overwriting an existing source file ≥ 4 KiB with content under 20% of its
|
|
39
|
+
size — the signature of a real implementation being gutted into a stub.
|
|
40
|
+
Reserve: back the file up to `.uap-backups/<today>/` first. The backup is
|
|
41
|
+
**verified**: a real regular file (not a symlink) of at least half the
|
|
42
|
+
original's size — an empty or unrelated same-named file does not count.
|
|
43
|
+
Incremental `Edit` operations are never touched.
|
|
44
|
+
|
|
45
|
+
Unlocks:
|
|
46
|
+
|
|
47
|
+
- An inline reserve-**creating** stash (`git stash`, `git stash push`,
|
|
48
|
+
`-u`/`--include-untracked`). Reserve-destroying or inert stash subcommands
|
|
49
|
+
(`drop`, `clear`, `pop`, `list`, `show`, `apply`, `branch`) do **not**
|
|
50
|
+
unlock.
|
|
51
|
+
- `UAP_RESERVE_OK=1` **only as a leading env assignment of the destructive
|
|
52
|
+
segment itself** (set it only after creating the backup/stash). A mention
|
|
53
|
+
elsewhere in the command does not count.
|
|
54
|
+
- A verified same-day `.uap-backups/` backup, for stub overwrites.
|
|
55
|
+
|
|
56
|
+
Operator overrides: `UAP_RESERVE_OK=1` / `UAP_COMMITMENT_RESERVE_OFF=1` in
|
|
57
|
+
the environment; `DELIVER_ACTIVE=1` sessions are exempt (the deliver harness
|
|
58
|
+
keeps its own snapshot reserve).
|
|
59
|
+
|
|
60
|
+
## Why
|
|
61
|
+
|
|
62
|
+
Every catastrophic agent incident on this project was a full-commitment
|
|
63
|
+
failure: real implementation files deleted into stubs to satisfy gates,
|
|
64
|
+
destructive resets that vaporized uncommitted work, force-pushes with nothing
|
|
65
|
+
held back. The rule is not "never do destructive things" — it is "destructive
|
|
66
|
+
moves are allowed only once something is held in reserve", so the recovery
|
|
67
|
+
path always stays open.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# proportional-commitment
|
|
2
|
+
|
|
3
|
+
**Category**: custom
|
|
4
|
+
**Level**: RECOMMENDED
|
|
5
|
+
**Enforcement Stage**: pre-exec
|
|
6
|
+
**Tags**: judgment, proportionality, restraint
|
|
7
|
+
|
|
8
|
+
## Rule
|
|
9
|
+
|
|
10
|
+
Effort and blast radius must scale with the task. Commit hard to an approach,
|
|
11
|
+
but never commit **everything** -- always hold a reserve of judgment and a way
|
|
12
|
+
back:
|
|
13
|
+
|
|
14
|
+
1. **Smallest viable intervention first.** Do not rewrite a whole file when an
|
|
15
|
+
edit suffices; do not restructure a module to fix a line; do not regenerate
|
|
16
|
+
what can be patched.
|
|
17
|
+
2. **No orchestration for small work.** Do not spawn subagents, decompose into
|
|
18
|
+
epics, or fan out parallel workers for a task whose diff is small. The
|
|
19
|
+
overhead must be justified by the work, not by enthusiasm.
|
|
20
|
+
3. **No destructive reset as a first resort.** `git reset --hard`, `rm -rf`,
|
|
21
|
+
force-push, and wholesale overwrites are last resorts taken only after the
|
|
22
|
+
incremental path has demonstrably failed -- and only with a backup or branch
|
|
23
|
+
point held in reserve.
|
|
24
|
+
4. **Keep one alternative alive.** Before committing to any expensive path
|
|
25
|
+
(long build, large refactor, multi-agent run), be able to state the fallback
|
|
26
|
+
you will use if it fails. If there is no fallback, the commitment is too big.
|
|
27
|
+
5. **Stop doubling down.** If the same approach has failed twice, the third
|
|
28
|
+
attempt must be a different approach, not a louder version of the same one.
|
|
29
|
+
|
|
30
|
+
## Why
|
|
31
|
+
|
|
32
|
+
Total commitment is a trap: an agent that goes all-in on one approach, one
|
|
33
|
+
rewrite, or one orchestration pattern surrenders the judgment needed to step
|
|
34
|
+
back out ("hold something back" -- the Tropic Thunder rule). Every major
|
|
35
|
+
incident in this project's history is a full-commitment failure: thousands of
|
|
36
|
+
identical subagent spawns, real implementation files deleted into stubs,
|
|
37
|
+
monolithic sessions overflowing their context window, gates saturated until
|
|
38
|
+
the remedy for a blocked action was itself blocked. Proportional commitment
|
|
39
|
+
keeps the recovery path open.
|
|
Binary file
|