macOS Cleaner Diagnose the actual source of disk pressure, reclaim only approved space, and prove that the cleanup did not damage user data or co-resident services. Entry router Choose the narrowest path that can answer the request: | User signal | Route | |---|---| | Identical inactive local files must retain every path and offline availability | Read ; use only authorized explicit pairs, native APFS checks and the bundled guarded executor | | Apple Content Caching, , , , iCloud cache, or “Caching needs more space” | Read completely before probing or proposing commands | | Docker, OrbStack,…

, ' ', text, flags=re.MULTILINE)\n text = text.replace('**', '').replace('`', '')\n text = re.sub(r'\\s+', ' ', text)\n return text.strip()\n\n\ndef code_and_path_tokens(text):\n \"\"\"Tokens from paths and backticked spans — the plan's own identifiers.\n\n These are distinctive on their own, so short ones count (`.../Caches/pip`).\n \"\"\"\n tokens = set()\n spans = re.findall(r'`([^`]+)`', text) + re.findall(r'(?:~|\\.\\.?)?/[\\w./{}+*-]+', text)\n for span in spans:\n for path in re.findall(r'(?:~|\\.\\.?)?/[\\w./{}+*-]+', span):\n base = path.rstrip('/').rsplit('/', 1)[-1]\n tokens.update(token for token in re.split(r'[^0-9A-Za-z]+', base)\n if len(token) >= 2 and not token.isdigit())\n for token in re.split(r'[^0-9A-Za-z]+', span):\n if len(token) >= 2 and not token.isdigit():\n tokens.add(token)\n return tokens\n\n\ndef prose_tokens(text):\n \"\"\"Tokens from free prose.\n\n Four characters or more, otherwise sizes such as \"12.90 GiB\" would make\n every row overlap every other row.\n \"\"\"\n return {token for token in re.split(r'[^0-9A-Za-z]+', text)\n if len(token) >= 4 and not token.isdigit()}\n\n\ndef argv0(command_text):\n \"\"\"The invoked program, which names the thing a package-manager cache acts on.\n\n Chunks arrive with their markdown quoting attached (`pip cache purge`), so\n the leading backtick has to come off before the program name is read.\n \"\"\"\n head = command_text.strip().lstrip('`\\'\" ')\n token = re.split(r'\\s', head, maxsplit=1)[0] if head else ''\n if '/' in token:\n token = token.rsplit('/', 1)[-1]\n return token if re.fullmatch(r'[A-Za-z][\\w.+-]*', token) else ''\n\n\n# --- markdown tables --------------------------------------------------------\n\nclass Row(object):\n def __init__(self, lineno, cells):\n self.lineno = lineno\n self.cells = cells\n\n def text(self):\n return ' | '.join(self.cells)\n\n\nclass Table(object):\n def __init__(self, header, rows):\n self.header = header\n self.rows = rows\n\n def column(self, *keywords):\n \"\"\"Index of the first header cell containing any keyword.\"\"\"\n for index, cell in enumerate(self.header):\n low = cell.lower()\n if any(keyword in low for keyword in keywords):\n return index\n return None\n\n def cell(self, row, index):\n if index is None or index >= len(row.cells):\n return ''\n return row.cells[index]\n\n\ndef split_row(line):\n body = line.strip()\n if body.startswith('|'):\n body = body[1:]\n if body.endswith('|'):\n body = body[:-1]\n # Unescaped pipes split cells; \\| is a literal pipe inside a cell.\n parts = re.split(r'(?\u003c!\\\\)\\|', body)\n return [part.strip() for part in parts]\n\n\ndef is_separator_row(cells):\n return all(re.fullmatch(r':?-{2,}:?', cell.replace(' ', '')) for cell in cells if cell)\n\n\ndef parse_tables(text):\n \"\"\"Every markdown table, in document order. Column count is not assumed.\"\"\"\n tables = []\n lines = text.split('\\n')\n index = 0\n while index \u003c len(lines):\n line = lines[index]\n if not line.lstrip().startswith('|'):\n index += 1\n continue\n header = split_row(line)\n if index + 1 >= len(lines) or not is_separator_row(split_row(lines[index + 1])):\n index += 1\n continue\n index += 2\n rows = []\n while index \u003c len(lines) and lines[index].lstrip().startswith('|'):\n rows.append(Row(index + 1, split_row(lines[index])))\n index += 1\n tables.append(Table(header, rows))\n return tables\n\n\ndef parse_sections(text):\n \"\"\"Heading -> (start, end) line ranges, for scoping evidence searches.\"\"\"\n sections = []\n lines = text.split('\\n')\n for number, line in enumerate(lines):\n match = re.match(r'^(#{1,6})\\s+(.*)

macOS Cleaner Diagnose the actual source of disk pressure, reclaim only approved space, and prove that the cleanup did not damage user data or co-resident services. Entry router Choose the narrowest path that can answer the request: | User signal | Route | |---|---| | Identical inactive local files must retain every path and offline availability | Read ; use only authorized explicit pairs, native APFS checks and the bundled guarded executor | | Apple Content Caching, , , , iCloud cache, or “Caching needs more space” | Read completely before probing or proposing commands | | Docker, OrbStack,…

, line)\n if match:\n sections.append((match.group(2).strip(), number))\n ranges = []\n for position, (title, start) in enumerate(sections):\n end = sections[position + 1][1] if position + 1 \u003c len(sections) else len(lines)\n ranges.append((title, start, end))\n return ranges\n\n\n# --- gate table -------------------------------------------------------------\n\ndef row_class(cells):\n for cell in cells:\n plain = cell.replace('**', '')\n for name in CLASSES:\n if re.search(r'\\b' + re.escape(name) + r'\\b', plain, re.IGNORECASE):\n return name\n return None\n\n\ndef row_verdict(cells):\n \"\"\"The row's verdict, or None when it states no action-set position.\n\n Order matters: 不入动作集 contains 入动作集 and \"not in action set\" contains\n \"in action set\", so the negated form has to be settled first or every\n PRESERVE row reads as a proposal.\n \"\"\"\n for cell in cells:\n plain = cell.replace('**', '')\n for marker in NOT_IN_ACTION_MARKERS:\n if re.search(marker, plain, re.IGNORECASE):\n return 'not in action set'\n for cell in cells:\n plain = cell.replace('**', '')\n if any(re.search(marker, plain, re.IGNORECASE) for marker in UNLOCK_MARKERS):\n return 'unlocked by user'\n for cell in cells:\n plain = cell.replace('**', '')\n for pattern, _kind in IN_ACTION_MARKERS:\n if re.search(pattern, plain, re.IGNORECASE):\n return 'in action set'\n return None\n\n\ndef row_is_hotspot(cells):\n \"\"\"Whether the row marks itself the user-visible hotspot *with a reason*.\n\n The gate allows a smaller lead row when the table marks another row as the\n hotspot \"with a stated reason\". A bare 热点 is a label, not a reason, and\n accepting it let a 0.5 GiB row lead a 41 GiB one on the strength of two\n characters. Whether the accompanying text is a *good* reason is not\n mechanically checkable — twelve characters is a proxy for \"someone wrote\n something\", and the human reading the plan is what judges the reasoning.\n \"\"\"\n for cell in cells:\n low = cell.lower()\n for marker in HOTSPOT_MARKERS:\n position = low.find(marker)\n if position \u003c 0:\n continue\n around = cell[:position] + cell[position + len(marker):]\n if len(re.sub(r'[\\s*`|,。,;;、()()\\[\\]]', '', around)) >= 12:\n return True\n return False\n\n\nclass GateRow(object):\n def __init__(self, table, row):\n self.table = table\n self.row = row\n self.lineno = row.lineno\n self.cls = row_class(row.cells)\n self.verdict = row_verdict(row.cells)\n self.hotspot = row_is_hotspot(row.cells)\n self.target_column = table.column('候选', '目标', 'target')\n if self.target_column is None:\n # Every template shape puts the target first; a positional guess\n # further right lands on the release column and misreads the row.\n self.target_column = 0\n self.target = table.cell(row, self.target_column)\n # The rule cell is the fallback for coverage: it names the tool even\n # when the target cell holds only a path (Homebrew).\n self.verdict_column = table.column('裁决', '判定', 'verdict')\n self.rule_column = table.column(*RULE_COLUMN_KEYWORDS)\n self.rule = table.cell(row, self.rule_column)\n # Filled in by collect_gate_rows: what a \"同上\" rule cell cites.\n self.ditto_source = ''\n # The rule cell contributes only its identifiers: its prose is shared\n # boilerplate (\"approved\", \"impact\", \"cache\") that would match anything.\n self.target_tokens = code_and_path_tokens(self.target) | prose_tokens(self.target)\n # Only the identifiers, never the path components: see target_identifiers.\n self.basename_tokens = set()\n for path in re.findall(r'(?:~|\\.\\.?)?/[\\w./{}+*-]+', self.target):\n self.basename_tokens.update(\n name_tokens(path.rstrip('/').rsplit('/', 1)[-1]))\n if not self.basename_tokens:\n self.basename_tokens = name_tokens(self.target)\n self.rule_tokens = code_and_path_tokens(self.rule)\n self.tokens = self.target_tokens | self.rule_tokens\n self.release = table_release_cell(table, row)\n\n def verdict_cells(self):\n \"\"\"The verdict cell, or the last cell when no verdict column is named.\n\n The last-cell fallback is only for reading a verdict that is *stated*\n there; it must not be used to confirm a user direction, because the rule\n column is last in some template translations and its quote would then\n stand in for the user's own words.\n \"\"\n\n Every template shape puts the verdict last, so the last cell is the safe\n fallback; the point is that the governing-rule cell is excluded either\n way, because its quotes are the rule's, not the user's.\n \"\"\"\n if self.verdict_column is not None and self.verdict_column \u003c len(self.row.cells):\n return [self.row.cells[self.verdict_column]]\n return [self.row.cells[-1]] if self.row.cells else []\n\n\ndef collect_gate_rows(table_text):\n \"\"\"Gate rows are rows of a table that classifies candidates.\n\n The table has to look like a classification table — its header names a class\n column or a rule column. Without that, any table whose prose happens to\n mention \"PRESERVE\" (a ranking table's summary row does) would be read as the\n gate table, and every check would then be applied to a table that is not one.\n \"\"\"\n rows = []\n for table in parse_tables(table_text):\n if table.column('类别', '分类', 'class') is None and \\\n table.column(*RULE_COLUMN_KEYWORDS) is None:\n continue\n last_real_rule = ''\n for row in table.rows:\n if row_class(row.cells):\n gate_row = GateRow(table, row)\n # \"同上\" cites the nearest row above that actually states a rule;\n # a chain of dittos has to resolve to a real quote, not to\n # another ditto.\n gate_row.ditto_source = last_real_rule\n rows.append(gate_row)\n if not DITTO_RE.match(gate_row.rule.strip()):\n last_real_rule = gate_row.rule\n return rows\n\n\ndef table_release_cell(table, row):\n \"\"\"Release from the table's own expected-release column, if it has one.\n\n Only that column is read. Every other column carries path-accounted sizes\n (\"du 口径 2.0 GB\"), and a nominal number read as a release poisons the\n ranking: it makes an honest \"this is not a release promise\" annotation look\n like the biggest winner, and the agent's only way out is to delete the\n annotation.\n \"\"\"\n index = table.column(*RELEASE_COLUMN_KEYWORDS)\n if index is None:\n return None\n return extract_release(table.cell(row, index))\n\n\n# Wording that withdraws a number from the release ranking. A cell that says\n# \"unknown\" or \"does not constitute a release promise\" has no number to rank.\n# No bare 不是: \"6.47 GiB(引擎 UNIQUE SIZE 求和,不是 du 名义值)\" is a\n# clarification *confirming* the number, and voiding the whole cell for it made\n# the checker call the smaller row the biggest.\nRESERVATION_RE = re.compile(\n r'不构成|不确定|未知|不可|不承诺|unknown|待测|待定|无法|取决于|未测量|未逐对象',\n re.IGNORECASE,\n)\n\n\n# Wording that labels a number as path accounting rather than physical release.\nNOMINAL_MARKER_RE = re.compile(\n r'(?\u003c![不没未])(?:path-accounted|nominal|名义值?|路径计账|apparent\\s+size|'\n r'logical\\s+size)',\n re.IGNORECASE,\n)\n# The weaker \"du 路径口径\" phrasing, which only withdraws a number when the cell\n# carries another number it could be disambiguating. A healthy plan writes\n# \"≤308.7 MB(du 路径口径上界)\" as its only estimate, and throwing that away\n# would leave the ranking with nothing to rank.\nNOMINAL_PATH_PHRASE_RE = re.compile(r'du\\s*路径口径|路径口径', re.IGNORECASE)\n# A negator anywhere shortly before a nominal marker means the marker is\n# defending the number rather than labelling it: 「不是 du 名义值」 confirms the\n# figure, while 「is the path-accounted nominal」 withdraws it. The negator is not\n# necessarily adjacent, so the window is what decides.\nNEGATOR_RE = re.compile(r'[不没未非]')\nAFFIRMATIVE_NOMINAL_RE = re.compile(r'名义值?|路径计账|path-accounted|nominal',\n re.IGNORECASE)\n# How close a nominal label has to be to a number to withdraw it from the\n# ranking: same clause, measured in characters. Wide enough for \"the 91.0 GiB\n# figure is the path-accounted nominal\", narrow enough that a genuine release\n# estimate in the same cell survives.\nNOMINAL_WINDOW = 60\n\n\ndef nominal_labelled(window):\n \"\"\"Whether the window labels its number as path accounting, not physical release.\n\n A marker with a negator in front of it is a clarification, not a label.\n \"\"\"\n for marker in AFFIRMATIVE_NOMINAL_RE.finditer(window):\n if not NEGATOR_RE.search(window[:marker.start()]):\n return True\n return False\n\n\nRELEASE_NUMBER_RE = re.compile(\n r'\\d+(?:\\.\\d+)?\\s*(?:' + '|'.join(RELEASE_UNITS) + r')\\b')\n\n\ndef extract_release(text):\n \"\"\"Largest (value, unit) reading in a cell that is not labelled nominal.\n\n A cell may legitimately carry both — the reference asks for the release\n estimate plus its basis, and the basis is often the nominal figure it is\n corrected against. Reading the nominal number as the release is what let a\n 2 GiB candidate lead beside a 91 GiB one.\n \"\"\"\n best = None\n for match in re.finditer(\n r'(\\d+(?:\\.\\d+)?)\\s*(' + '|'.join(RELEASE_UNITS) + r')\\b', text):\n window = text[max(0, match.start() - NOMINAL_WINDOW):\n min(len(text), match.end() + NOMINAL_WINDOW)]\n if nominal_labelled(window) or RESERVATION_RE.search(window):\n continue\n if NOMINAL_PATH_PHRASE_RE.search(window) and len(RELEASE_NUMBER_RE.findall(text)) > 1:\n continue\n value = float(match.group(1)) * UNIT_BYTES[match.group(2)]\n if best is None or value > best:\n best = value\n return best\n return best\n\n\ndef plan_release_table(plan_text):\n \"\"\"Fallback release ranking table in the plan prose.\n\n Used when the gate table has no expected-release column. Only rows whose\n candidate cell parses as a release count, so summary rows that restate a\n whole class of PRESERVE targets cannot win the ranking.\n \"\"\"\n for table in parse_tables(plan_text):\n if table.column(*RELEASE_COLUMN_KEYWORDS) is None:\n continue\n candidate_column = table.column('候选', 'candidate', '目标', 'target', '项目')\n release_column = table.column(*RELEASE_COLUMN_KEYWORDS)\n readings = []\n for row in table.rows:\n release = extract_release(table.cell(row, release_column))\n if release is None:\n continue\n candidate = table.cell(row, candidate_column) or row.text()\n readings.append((code_and_path_tokens(candidate) | prose_tokens(candidate),\n release, candidate))\n if readings:\n return readings\n return []\n\n\n# --- destructive commands ---------------------------------------------------\n\ndef command_chunks(plan_text):\n \"\"\"(lineno, origin, text) for every place a command can hide.\n\n `origin` is 'cell' or 'prose' and decides how far a \"we will not run this\"\n statement reaches: prose is contiguous, so a sentence-level negation covers\n the commands it contains, while a table row is a record whose cells say\n different things — a \"排除\" in the release cell does not retract a command\n stated in the command cell.\n\n Fenced code lines, inline code spans, and table cells are the three shapes\n real plans actually use. A governing-rule cell is skipped: it quotes the\n reference verbatim, so the commands inside it are citations of a rule, not\n the plan's own commands. Chunks are de-duplicated so a command quoted both\n inline and in a table is checked once.\n \"\"\"\n chunks = []\n lines = plan_text.split('\\n')\n rule_cells = set()\n command_tables = set()\n for table in parse_tables(plan_text):\n index = table.column('管辖规则', 'governing')\n if index is not None:\n for row in table.rows:\n if index \u003c len(row.cells):\n rule_cells.add((row.lineno, normalize(row.cells[index])))\n if table.column('命令', 'command', '动作') is not None:\n for row in table.rows:\n command_tables.add(row.lineno)\n in_fence = False\n consumed = 0\n for number, line in enumerate(lines):\n if re.match(r'^\\s*```', line):\n in_fence = not in_fence\n continue\n if in_fence:\n if line.strip() and not line.lstrip().startswith('#'):\n stripped = line.strip()\n # The skill's own documented Trash command spans lines, and the\n # line carrying `-- \"\u003cpath>\"` never mentions osascript, so it was\n # never recognized as part of the command.\n if (re.match(r'^(?:-e\\b|--)', stripped) and chunks\n and consumed == number):\n previous = chunks[-1]\n chunks[-1] = (previous[0], previous[1], previous[2] + ' ' + stripped)\n consumed = number + 1\n continue\n chunks.append((number + 1, 'prose', stripped))\n consumed = number + 1\n continue\n if line.lstrip().startswith('|'):\n # A table row is read as cells only: its inline backticks belong to\n # those cells, and a rule cell's backticked command is a citation.\n origin = 'cmdtable' if number + 1 in command_tables else 'cell'\n for cell in split_row(line):\n if not cell or (number + 1, normalize(cell)) in rule_cells:\n continue\n chunks.append((number + 1, origin, cell))\n else:\n spans = [match.group(1).strip() for match in re.finditer(r'`([^`]+)`', line)]\n for span in spans:\n chunks.append((number + 1, 'prose', span))\n if not spans and line.strip():\n # A prose line with no inline code is still where a command can\n # hide (\"runs uv cache prune to reclaim 50.9 GiB\"). Lines that do\n # carry backticks are read as those spans only, so a command\n # quoted inside prose is not double-counted.\n chunks.append((number + 1, 'prose', line.strip()))\n seen = set()\n unique = []\n for lineno, origin, text in chunks:\n key = (lineno, origin, normalize(text))\n if key[2] and key not in seen:\n seen.add(key)\n unique.append((lineno, origin, text))\n return unique\n\n\nclass Command(object):\n def __init__(self, lineno, text, tools, category_wide, versioned, origin='prose',\n exact_target=None):\n self.lineno = lineno\n self.text = text\n self.origin = origin\n self.tools = tools\n self.category_wide = category_wide\n self.versioned = versioned\n self.exact_target = exact_target\n\n\ndef helper_targets(text):\n \"\"\"Read literal helper arguments without opening a batch file or running code.\n\n A batch, expansion, or compound shell form cannot prove its target set from\n the plan alone. Keep it visible but unresolved so the coverage gate rejects it.\n \"\"\"\n source = text.strip().strip('`')\n try:\n parts = shlex.split(source)\n except ValueError:\n return [None]\n # shlex returns values, discarding the quote context that decides whether\n # brackets, braces or '&' are filename characters or shell syntax. Track\n # that context separately; never expand or execute the words.\n unsafe_words = []\n values = []\n value = ''\n quote = None\n active = False\n unsafe = False\n index = 0\n while index \u003c len(source):\n char = source[index]\n if quote == \"'\":\n if char == \"'\":\n quote = None\n else:\n value += char\n elif char == '\\\\' and (quote is None or\n source[index + 1:index + 2] in ('

macOS Cleaner Diagnose the actual source of disk pressure, reclaim only approved space, and prove that the cleanup did not damage user data or co-resident services. Entry router Choose the narrowest path that can answer the request: | User signal | Route | |---|---| | Identical inactive local files must retain every path and offline availability | Read ; use only authorized explicit pairs, native APFS checks and the bundled guarded executor | | Apple Content Caching, , , , iCloud cache, or “Caching needs more space” | Read completely before probing or proposing commands | | Docker, OrbStack,…

, '`', '\"', '\\\\', '\\n')):\n index += 1\n active = True\n if source[index:index + 1] != '\\n':\n value += source[index:index + 1]\n elif quote == '\"':\n if char == '\"':\n quote = None\n elif char in ' macos-cleaner — Skillopedia ':\n unsafe = True\n value += char\n else:\n value += char\n elif char in \"'\\\"\":\n quote = char\n active = True\n elif char.isspace():\n if active:\n unsafe_words.append(unsafe)\n values.append(value)\n active = unsafe = False\n value = ''\n else:\n active = True\n value += char\n if char in '$*?[]{};|&\u003c>()`#':\n unsafe = True\n index += 1\n if active:\n unsafe_words.append(unsafe)\n values.append(value)\n if len(unsafe_words) != len(parts):\n return [None]\n parts = values\n for index, part in enumerate(parts):\n if part.rsplit('/', 1)[-1] != 'safe_delete.py':\n continue\n targets = parts[index + 1:]\n if (not targets or any(target.startswith('-') or not target\n for target in targets) or\n any(unsafe_words[index + 1:])):\n return [None]\n return targets\n return [None]\n\n\n# A command the plan declares it did NOT run and will not propose is not a\n# proposal, so neither coverage nor verification applies to it. Real plans list\n# these (\"未跑 npm cache clean\", \"不使用任何 prune 家族命令\") precisely to show\n# the exclusions were deliberate.\nDECLARED_UNUSED_RE = re.compile(\n r'未跑|未执行|未使用|不使用|不用|不删|不承诺|不执行|不提议|不列入|排除|'\n r'只报不删|禁\\s*prune|不进方案|不在方案|'\n r'never\\s+(?:use|used|run|ran|executed?)|not\\s+(?:used|run)|do\\s+not\\s+use',\n re.IGNORECASE,\n)\n\n\ndef declared_unused(lineno, origin, chunk, lines, commands_on_line=1):\n \"\"\"Whether the plan says it will not run this command.\n\n The statement has to reach the command, and it has to be *about* the command.\n In prose it reaches the line (\"未跑 `npm cache clean`\"), but a line that\n mixes an exclusion with a real command — \"不使用 `pip cache purge`,改用\n `rm -rf ...`\" — is not a declaration that nothing runs: the negation covers\n the first command and hides the second. Such a line gets no exemption at all.\n In a table the row's other cells describe the target or the estimate, so the\n scope is the cell itself.\n \"\"\"\n if origin != 'cell' and commands_on_line > 1:\n return False\n scope = chunk if origin == 'cell' else lines[lineno - 1]\n return bool(DECLARED_UNUSED_RE.search(scope))\n\n\ndef find_destructive_commands(plan_text):\n commands = []\n lines = plan_text.split('\\n')\n seen = set()\n per_line = {}\n for lineno, origin, chunk in command_chunks(plan_text):\n per_line[lineno] = per_line.get(lineno, 0) + 1\n for lineno, origin, chunk in command_chunks(plan_text):\n helper_match = DESTRUCTIVE_PATTERNS[0][1].search(chunk)\n if helper_match:\n try:\n helper_words = shlex.split(chunk.strip().strip('`'))\n except ValueError:\n helper_words = []\n if ('--help' in helper_words and\n '--' not in helper_words[:helper_words.index('--help')]):\n continue\n elif DRY_RUN_RE.search(chunk) or HELP_INVOCATION_RE.search(chunk):\n continue\n if READ_ONLY_PROBE_RE.match(chunk) and not re.search(r'\\brm\\b', chunk):\n continue\n if helper_match:\n # A filename such as '/tmp/not used/item' is not a prose exclusion.\n # For an inline command, only its surrounding sentence can retract it.\n scope = chunk if origin == 'cell' else lines[lineno - 1]\n if (per_line.get(lineno, 1) == 1 and\n DECLARED_UNUSED_RE.search(scope.replace(chunk, '', 1))):\n continue\n elif declared_unused(lineno, origin, chunk, lines, per_line.get(lineno, 1)):\n continue\n for tools, pattern, category_wide, versioned in DESTRUCTIVE_PATTERNS:\n match = pattern.search(chunk)\n if not match:\n continue\n if (tools == ('safe_delete.py',) and chunk.strip('` ') == 'safe_delete.py'\n and lines[lineno - 1].strip() != 'safe_delete.py'):\n continue # a filename citation, not an executable invocation\n if category_wide:\n # An explicit object argument narrows the scope.\n if any(narrow.search(chunk) for narrow in EXPLICIT_OBJECT_RES):\n category_wide = False\n elif any(widen.search(chunk) for widen in CATEGORY_WIDE_RES):\n category_wide = True\n key = (lineno, match.group(0))\n if key in seen:\n continue\n seen.add(key)\n targets = helper_targets(chunk) if tools == ('safe_delete.py',) else [None]\n for target in targets:\n commands.append(Command(lineno, chunk, tools, category_wide, versioned,\n origin, target))\n commands.sort(key=lambda command: command.lineno)\n return commands\n\n\ndef quoted_target(command_text):\n \"\"\"The path after a `--` separator: how osascript targets are written.\"\"\"\n match = re.search(r'--\\s+\"([^\"]+)\"', command_text)\n return match.group(1) if match else ''\n\n\nPATH_TOKEN_RE = re.compile(r'(?:~|\\.\\.?)?/[\\w./{}+*-]+|\\$\\(?\\w')\n\n\ndef resolve_target(command, plan_tables):\n \"\"\"(stated target, attributable row or None, resolvable?).\n\n A table row's target column wins when there is one: it is where the agent\n states the exact object, and the command cell alone often holds only IDs.\n \"\"\"\n if command.tools == ('safe_delete.py',):\n return command.exact_target or '', None, command.exact_target is not None\n for table in plan_tables:\n for row in table.rows:\n if row.lineno != command.lineno:\n continue\n index = table.column('目标', 'target')\n if index is not None and table.cell(row, index):\n return table.cell(row, index), None, True\n target = quoted_target(command.text)\n if target:\n return target, None, True\n if PATH_TOKEN_RE.search(command.text):\n return command.text, None, True\n if command_arguments(command.text):\n # Explicit object IDs or names are a stated target even though they are\n # not paths: `docker rmi a02c40cc28df` names what it will delete.\n return command.text, None, True\n return command.text, None, False\n\n\ndef command_arguments(command_text):\n \"\"\"Object-shaped arguments after the program name.\n\n Subcommand words (`rmi`, `clean`, `prune`) are not targets, so only tokens\n that look like an object count — an ID, a path, a placeholder. A bare\n `docker rmi` in prose therefore states no target, while `docker rmi\n a02c40cc28df` does.\n \"\"\"\n parts = [part for part in command_text.split() if not part.startswith('-')]\n return [part.strip('`\\'\"') for part in parts[1:]\n if re.search(r'[\\d/\u003c*$.]', part)]\n\n\ndef command_tokens(command, target):\n \"\"\"What identifies the target a command acts on.\n\n The stated target plus the command's own identifiers: `pip cache purge` says\n nothing about a path, but its program name is what names the target.\n \"\"\"\n tokens = code_and_path_tokens(target) | prose_tokens(target)\n tokens |= code_and_path_tokens(command.text)\n program = argv0(command.text)\n if program:\n tokens.add(program)\n return tokens\n\n\ndef program_matches(program, row):\n \"\"\"Whether an invoked program names this row's target.\n\n `pip3 cache purge` and a row about `~/Library/Caches/pip` are the same\n target, so the program name is allowed to extend a row token by a version\n suffix. It is compared against every alphanumeric token of the target cell,\n not the >=4-character prose set: \"pip 缓存\" is a legitimate target cell whose\n only identifier is three characters long. Scoped to the program name on\n purpose — spreading prefix matching to free text would make unrelated rows\n match.\n \"\"\"\n if not program:\n return False\n return any(len(token) >= 3 and program.startswith(token)\n for token in re.split(r'[^0-9A-Za-z]+', row.target)\n if token and not token.isdigit())\n\n\ndef attribute(command, gate_rows, plan_tables):\n \"\"\"Find the gate row this command is about, if the plan says which one.\n\n A row has to match on a *basename-level* identifier. Path components such as\n \"Library\" and \"Caches\" appear in almost every row, so counting them let\n `rm -rf ~/Library/Keychains` attach itself to the `~/Library/Logs` row and\n disappear from the coverage check. The invoked program is the strongest\n identifier, because for a package-manager cache it *is* the target.\n \"\"\"\n target, _row, resolvable = resolve_target(command, plan_tables)\n if command.tools == ('safe_delete.py',):\n row = next((row for row in gate_rows\n if normalize(row.target).strip('\\'\"') == target), None)\n return row, resolvable, target\n identifiers = target_identifiers(command, target)\n if not identifiers:\n return None, resolvable, target\n program = argv0(command.text)\n\n def score(row):\n value = 0\n if tokens_overlap(identifiers, row.basename_tokens):\n value += 4\n if program_matches(program, row):\n value += 3\n value += len(identifiers & row.target_tokens)\n return value\n\n best = max(gate_rows, key=score, default=None)\n if best is not None and (score(best) >= 3 or tokens_overlap(identifiers, best.basename_tokens)):\n return best, resolvable, target\n return None, resolvable, target\n\n\ndef target_identifiers(command, target):\n \"\"\"The distinctive identifiers a command's target names.\n\n Path basenames and object IDs — the parts that name *what* is acted on, as\n opposed to the directories it happens to live under.\n \"\"\"\n identifiers = set()\n for path in re.findall(r'(?:~|\\.\\.?)?/[\\w./{}+*-]+', target):\n base = path.rstrip('/').rsplit('/', 1)[-1]\n identifiers.update(name_tokens(base))\n for span in re.findall(r'`([^`]+)`', target):\n identifiers.update(name_tokens(span))\n if not identifiers:\n identifiers.update(name_tokens(target))\n program = argv0(command.text)\n if program:\n identifiers.add(program.lower())\n return identifiers\n\n\ndef tool_version_re(tool):\n \"\"\"A version attached to the tool, or a line that declares versions at all.\n\n Sizes such as \"20.26GB\" also match a bare version regex, so the version has\n to sit next to the tool name or on a line that declares versions.\n \"\"\"\n return re.compile(\n r'\\b' + re.escape(tool) + r'\\b[\\s*`]*v?(\\d+\\.\\d+(?:\\.\\d+)?)'\n r'|(\\d+\\.\\d+(?:\\.\\d+)?)[\\s*`]*\\b' + re.escape(tool) + r'\\b',\n re.IGNORECASE,\n )\n\n\ndef verification_evidence(command, plan_text):\n \"\"\"Lines that may carry this command's tool verification.\n\n Three sources, in widening order: the command's own line, its neighbours,\n and a tool-verification block anywhere in the plan. The block is found by\n its marker in the section title *or* body: real plans write it as a bold\n paragraph inside a larger section rather than as its own heading.\n \"\"\"\n lines = plan_text.split('\\n')\n low = command.lineno - 1\n nearby = [lines[index] for index in range(max(0, low - 2), min(len(lines), low + 3))]\n dedicated = []\n for title, start, end in parse_sections(plan_text):\n body = '\\n'.join(lines[start:end])\n if TOOL_VERIFICATION_SECTION_RE.search(title) or \\\n TOOL_VERIFICATION_SECTION_RE.search(body):\n dedicated.extend(lines[start:end])\n return nearby + dedicated\n\n\ndef has_verification(command, plan_text):\n \"\"\"(version, semantics) found for this command's owning tool.\"\"\"\n evidence = verification_evidence(command, plan_text)\n for tool in command.tools:\n version_re = tool_version_re(tool)\n for line in evidence:\n if not re.search(r'\\b' + re.escape(tool) + r'\\b', line, re.IGNORECASE):\n continue\n if not (SEMANTICS_RE.search(line) or LIVE_PROBE_RE.search(line)):\n continue\n if not command.versioned:\n return True, tool # a system control: name the owner, no version\n version = bool(version_re.search(line)) or bool(\n re.search(r'版本|version', line, re.IGNORECASE) and VERSION_RE.search(line))\n if version:\n return True, tool\n return False, None\n\n\n# --- checks -----------------------------------------------------------------\n\nclass Result(object):\n def __init__(self, name, passed, details=None):\n self.name = name\n self.passed = passed\n self.details = details or []\n\n def render(self):\n head = 'PASS ' + self.name if self.passed else 'FAIL ' + self.name\n if self.details and not self.passed:\n return '\\n'.join([head + ':'] + [' ' + line for line in self.details])\n if self.details:\n return '\\n'.join([head] + [' ' + line for line in self.details])\n return head\n\n\ndef check_table_exists(gate_rows, table_text):\n if gate_rows:\n detail = ['%d gate row(s) classified' % len(gate_rows)]\n for row in gate_rows[:3]:\n detail.append('line %d: %s' % (row.lineno, row.target[:70] or row.row.text()[:70]))\n return Result('table_exists', True, detail)\n return Result('table_exists', False, [\n 'no classification table found: no table row carries one of %s'\n % '/'.join(CLASSES),\n 'the Phase 2 entry gate requires every candidate classified in a table',\n ])\n\n\ndef check_quote_fidelity(gate_rows, references):\n \"\"\"Every classified row must cite a rule, and the rule must be real.\n\n Two failure modes, both silent greens before this existed: a rule column\n named something the parser did not recognise made the check verify nothing\n while reporting PASS, and a rule cell with no quotation at all — prose\n paraphrasing the reference — was equally unchecked. A fabricated quote and a\n missing quote are the same violation: neither is a rule.\n \"\"\"\n missing = []\n unquoted = []\n checked = 0\n provenance = {}\n headers = None\n for row in gate_rows:\n if row.rule_column is None:\n headers = headers or list(row.table.header)\n unquoted.append('line %d: rule column not located — %s'\n % (row.lineno, row.target[:60]))\n continue\n cell = row.rule\n if DITTO_RE.match(cell.strip()):\n # \"同上\" cites the row above rather than repeating its quote. The\n # citation is only as good as that row's, so verify it as that one.\n cell = row.ditto_source\n if not has_quoted_span(cell) and not NO_RULE_MARKER_RE.search(cell):\n unquoted.append('line %d: rule cell states no quotation and no '\n '\"no rule found\" marker: %s'\n % (row.lineno, cell[:90] or '(empty)'))\n continue\n for segment in quote_segments(cell):\n checked += 1\n found = [name for name, text in references.items() if segment in text]\n if found:\n for name in found:\n provenance[name] = provenance.get(name, 0) + 1\n else:\n missing.append('line %d: %s' % (row.lineno, segment[:150]))\n details = ['%d quoted segment(s) verified against %d reference file(s)'\n % (checked, len(references))]\n if missing:\n details.append('%d quoted segment(s) not found in any reference'\n % len(missing))\n if not gate_rows:\n # Nothing was checked, so this PASS carries no evidence. It is not a\n # failure — with no gate rows there is no rule column to quote — but it\n # must not read as though the quotes had been verified.\n details.append('no governing-rule cell to check: this check did not run')\n for name in sorted(provenance, key=lambda item: -provenance[item]):\n details.append(' %s: %d' % (name, provenance[name]))\n if headers:\n details.append('rule column not located — headers seen: %s'\n % ' | '.join(headers))\n details.append('name it 管辖规则 or Governing rule (any of: %s)'\n % ', '.join(RULE_COLUMN_KEYWORDS))\n if unquoted:\n details.append('%d row(s) cite no verifiable rule:' % len(unquoted))\n details.extend(unquoted)\n if missing:\n details.append('%d segment(s) not found verbatim in the references:'\n % len(missing))\n details.extend(missing)\n if missing or unquoted or headers:\n return Result('quote_fidelity', False, details)\n return Result('quote_fidelity', True, details)\n\n\ndef has_quoted_span(cell):\n \"\"\"Whether the cell quotes anything at all.\n\n Same threshold as quote_segments(), so presence and verification cannot\n disagree about which quotations exist.\n \"\"\"\n return bool(re.search(r'\"[^\"]+\"|“[^”]+”|‘[^’]+’|「[^」]+」', cell))\n\n\ndef quote_segments(cell):\n \"\"\"Quoted segments in a governing-rule cell, ellipsis-split.\n\n Each delimiter has to sit on a word boundary, or an English apostrophe pair\n (\"they're ... isn't\") reads as a quotation and invents a segment. Segments\n under four characters are dropped as noise.\n \"\"\"\n segments = []\n patterns = (\n r'(?\u003c![0-9A-Za-z])\"((?:[^\"\\\\]|\\\\.)+)\"(?![0-9A-Za-z])',\n r'(?\u003c![0-9A-Za-z])“([^”]+)”(?![0-9A-Za-z])',\n r'(?\u003c![0-9A-Za-z])‘([^’]+)’(?![0-9A-Za-z])',\n r\"(?\u003c![0-9A-Za-z])'([^']+)'(?![0-9A-Za-z])\",\n r'(?\u003c![0-9A-Za-z])「([^」]+)」(?![0-9A-Za-z])',\n )\n for pattern in patterns:\n for match in re.finditer(pattern, cell):\n raw = match.group(1)\n for part in QUOTE_SPLIT_RE.split(raw):\n plain = normalize(part)\n # Four characters, not twelve: at twelve a fabricated \"Zzqqxx\"\n # or \"Keep\" was dropped before verification and reported as\n # though nothing had been quoted.\n if len(plain) \u003c 4 or NO_RULE_MARKER_RE.search(plain):\n continue\n if plain not in segments:\n segments.append(plain)\n return segments\n\n\ndef unrecognized_command_lines(plan_text, commands):\n \"\"\"Command-shaped chunks the destructive-command list did not match.\n\n DESTRUCTIVE_PATTERNS is a declared allowlist, so an unlisted command is not\n checked rather than failed on. Reporting \"0 destructive command(s) checked\"\n for a plan full of commands would read as \"checked and found clean\", which is\n the opposite of the truth, so the count is surfaced instead.\n \"\"\"\n missed = []\n for lineno, origin, chunk in command_chunks(plan_text):\n if any(pattern.search(chunk) for _tools, pattern, _wide, _versioned\n in DESTRUCTIVE_PATTERNS):\n continue # recognized — excluded on purpose or not, it was seen\n if READ_ONLY_PROBE_RE.match(chunk) or DRY_RUN_RE.search(chunk):\n continue\n if READ_ONLY_SUBCOMMAND_RE.search(chunk):\n continue\n if not looks_like_command(chunk, origin):\n continue\n missed.append((lineno, chunk))\n return missed\n\n\n# Read-only invocations whose program name is itself mutating elsewhere\n# (`docker rmi` versus `docker system df`), so the program alone cannot decide.\nREAD_ONLY_SUBCOMMAND_RE = re.compile(\n r'\\b(?:docker|orbctl|orb)\\s+(?:system\\s+df|images|ps|volume\\s+ls|inspect|info|'\n r'version|buildx?\\s+du)\\b|\\bbrew\\s+--cache\\b|\\buv\\s+cache\\s+dir\\b|'\n r'\\bnpm\\s+config\\s+get\\b|\\bpnpm\\s+store\\s+path\\b|\\b(?:du|df|lsof)\\b|'\n r'\\blist\\b|--version\\b',\n re.IGNORECASE,\n)\n\n\ndef looks_like_command(chunk, origin='prose'):\n \"\"\"A chunk that reads as a command line: a program, then arguments.\n\n Chinese prose and plain table values are excluded — two English words such as\n \"Docker build cache\" or a hostname are not commands — so a chunk also needs a\n flag, a path or a number, unless it sits in the table that lists the plan's\n commands, where a cell is a command by construction.\n \"\"\"\n text = chunk.strip().lstrip('`\\'\" ')\n if not text or re.search(r'[\\u4e00-\\u9fff]', text):\n return False\n parts = text.split()\n if len(parts) \u003c 2 or not re.fullmatch(r'[A-Za-z][\\w.+-]*', parts[0].rsplit('/', 1)[-1]):\n return False\n if origin == 'cmdtable':\n return True\n return bool(re.search(r'\\s-{1,2}[A-Za-z]|[/~]|\\d', text))\n\n\ndef check_target_coverage(commands, gate_rows, plan_tables, plan_text=''):\n \"\"\"No state-changing command may name a target absent from the table.\n\n A command with no stated target at all is unattributable rather than\n uncovered. With rows to check against that is a gap in the plan's wording,\n not a missing classification, so it is reported without failing; with an\n empty table nothing can be verified, which is the failure.\n \"\"\"\n unmatched = []\n unattributed = []\n for command in commands:\n row, resolvable, target = attribute(command, gate_rows, plan_tables)\n if row is not None:\n continue\n if not resolvable and gate_rows and command.tools != ('safe_delete.py',):\n unattributed.append('line %d: %s (no stated target to match)'\n % (command.lineno, command.text[:90]))\n continue\n unmatched.append('line %d: %s' % (command.lineno, command.text[:90]))\n unmatched.append(' stated target: %s' % target[:90])\n details = ['%d destructive command(s) checked' % len(commands)]\n if plan_text:\n missed = unrecognized_command_lines(plan_text, commands)\n if missed:\n details.append('%d command-shaped line(s) not recognized — coverage '\n 'incomplete' % len(missed))\n details.extend('line %d: %s' % (lineno, chunk[:80])\n for lineno, chunk in missed[:8])\n if unattributed:\n details.append('%d command(s) name no target, so no row could be matched '\n '(not counted as a violation):' % len(unattributed))\n details.extend(unattributed)\n if unmatched:\n details.append('%d command(s) name a target absent from the table:'\n % (len(unmatched) // 2))\n details.extend(unmatched)\n return Result('target_coverage', False, details)\n return Result('target_coverage', True, details)\n\n\ndef has_quoted_direction(cells):\n \"\"\"Whether the row quotes what the user actually said.\n\n Deliberately not quote_segments(): that one drops short fragments, and a\n quoted direction is often only a handful of characters.\n \"\"\"\n text = ' '.join(cells)\n return any(re.search(pattern, text)\n for pattern in (r'\"[^\"]+\"', r'“[^”]+”', r'「[^」]+」', r'‘[^’]+’'))\n\n\ndef check_action_set_classes(gate_rows):\n \"\"\"Police which classes may sit in the action set.\n\n Fails closed when the table classifies rows but no row's verdict can be\n read: a green light on an unchecked action set is worse than a false alarm,\n because it teaches the reader that a PASS here means something it does not.\n \"\"\"\n violations = []\n counts = {'in action set': 0, 'unlocked by user': 0, 'not in action set': 0}\n unreadable = []\n for row in gate_rows:\n if not row.verdict:\n unreadable.append(row)\n continue\n counts[row.verdict] = counts.get(row.verdict, 0) + 1\n # \"no rule found\" is itself a finding, and the gate says such a target is\n # USER-DECISION: the plan asks before proposing anything for it. Labelling\n # it PROPOSABLE let an uncovered target lead the action set.\n if (row.rule and NO_RULE_MARKER_RE.search(row.rule)\n and not has_quoted_span(row.rule) and row.cls != 'USER-DECISION'):\n violations.append('line %d: %s cites \"no rule found\" — a target no '\n 'reference covers must be USER-DECISION and asked '\n 'about, not %s' % (row.lineno, row.cls, row.cls))\n continue\n # USER-DATA is never in an action set under any verdict, so it is checked\n # before the verdict-specific branches.\n if in_action_set(row) and row.cls == 'USER-DATA':\n violations.append('line %d: USER-DATA row carries a state-changing '\n 'verdict' % row.lineno)\n elif row.verdict == 'in action set':\n if row.cls not in ('PROPOSABLE', 'REBUILDABLE'):\n violations.append('line %d: %s is \"%s\" — only PROPOSABLE or REBUILDABLE '\n 'may sit in the action set'\n % (row.lineno, row.cls, row.verdict))\n elif row.cls == 'PROPOSABLE' and not has_never_used_evidence(row):\n violations.append('line %d: PROPOSABLE in the action set states no '\n 'never-used evidence (dead project, explicit user '\n 'statement or artifact check)' % row.lineno)\n elif row.verdict == 'unlocked by user':\n # The legitimate route in for a PRESERVE row, but only with the\n # user's direction quoted — accepting a cost you stated is not the\n # user naming the target.\n if row.cls in ('PRESERVE', 'USER-DECISION'):\n if row.verdict_column is None:\n violations.append('line %d: %s marked unlocked but the verdict '\n 'column cannot be located, so the user\\'s '\n 'direction cannot be read' % (row.lineno, row.cls))\n elif not has_quoted_direction(row.verdict_cells()):\n violations.append('line %d: %s marked unlocked without the user\\'s '\n 'direction quoted' % (row.lineno, row.cls))\n details = ['in action set: %d, unlocked by user: %d, not in action set: %d'\n % (counts.get('in action set', 0), counts.get('unlocked by user', 0),\n counts.get('not in action set', 0))]\n if gate_rows and not any(row.verdict for row in gate_rows):\n details.append('verdict column unrecognized — no row states an action-set '\n 'position, so the action set was not checked at all')\n details.append('use one of: in action set / 入动作集 / 在动作集 / '\n 'not in action set / 不入动作集 / 不在动作集 / '\n 'unlocked by user / 用户点名')\n details.append('%d classified row(s) had no readable verdict:' % len(unreadable))\n details.extend('line %d: %s' % (row.lineno, row.row.text()[:90])\n for row in unreadable[:8])\n return Result('action_set_classes', False, details)\n if unreadable:\n details.append('%d row(s) state no action-set position (reported, not ranked)'\n % len(unreadable))\n if violations:\n details.extend(violations)\n return Result('action_set_classes', False, details)\n return Result('action_set_classes', True, details)\n\n\ndef verdict_unreadable(gate_rows):\n \"\"\"Rows exist but not one states an action-set position.\n\n The checks that scope themselves to \"in-action-set rows\" would otherwise\n report a clean bill on a table they could not read — the same silent green\n action_set_classes used to produce.\n \"\"\"\n return bool(gate_rows) and not any(row.verdict for row in gate_rows)\n\n\ndef check_category_wide_exclusion(commands, gate_rows, plan_tables):\n violations = []\n for command in commands:\n if not command.category_wide:\n continue\n row, _resolvable, _target = attribute(command, gate_rows, plan_tables)\n if row is None:\n continue # mapped to no action set: an unlock option in prose, allowed\n if in_action_set(row):\n violations.append('line %d: category-wide command in the action set: %s'\n % (command.lineno, command.text[:90]))\n violations.append(' mapped row (line %d) is \"%s\"' % (row.lineno, row.verdict))\n unplaced = [command for command in commands\n if command.category_wide and command.origin == 'cmdtable'\n and attribute(command, gate_rows, plan_tables)[0] is None]\n if unplaced:\n # A category-wide command the table cannot place is not \"outside the\n # action set\" — it is undecidable, and saying otherwise is a false clean\n # bill on exactly the shape the hard-exclusion rules exist to stop.\n violations.append('%d category-wide command(s) in the plan\\'s command list '\n 'map to no classified row, so whether they sit in the '\n 'action set cannot be determined — classify the target '\n 'or drop the command' % len(unplaced))\n violations.extend('line %d: %s' % (command.lineno, command.text[:90])\n for command in unplaced)\n if verdict_unreadable(gate_rows):\n # With no readable verdict this check cannot tell a proposal from an\n # unlock option, so it names what it is leaving alone instead of\n # passing silently.\n unplaced = [command for command in commands if command.category_wide]\n if unplaced:\n violations.append('verdict column unrecognized: %d category-wide '\n 'command(s) cannot be placed in or out of the action '\n 'set — fix the verdict column (see action_set_classes)'\n % len(unplaced))\n violations.extend('line %d: %s' % (command.lineno, command.text[:90])\n for command in unplaced)\n if violations:\n violations.insert(0, '%d finding(s) about category-wide commands:'\n % _count_findings(violations))\n return Result('category_wide_exclusion', False, violations)\n return Result('category_wide_exclusion', True, [\n '%d category-wide command(s), none of them in the action set'\n % sum(1 for command in commands if command.category_wide)])\n\n\ndef _count_findings(lines):\n \"\"\"How many findings a detail list carries, for its own summary line.\"\"\"\n return sum(1 for line in lines if line.startswith('line '))\n\n\ndef check_lead_rule(commands, gate_rows, plan_text):\n if not commands:\n return Result('lead_rule', True, ['no destructive command to rank'])\n action_rows = [row for row in gate_rows if in_action_set(row)]\n if not action_rows:\n if verdict_unreadable(gate_rows):\n # Not \"the action set is legitimately empty\" — it could not be read.\n return Result('lead_rule', True, [\n 'verdict column unrecognized: no in-action-set row could be '\n 'identified, so nothing was ranked (see action_set_classes)'])\n return Result('lead_rule', True, [\n 'no in-action-set row: nothing to rank (an empty action set with a '\n 'decision list is a legitimate outcome)'])\n releases = []\n for row in action_rows:\n value = row.release\n if value is None:\n value = plan_release_lookup(plan_text, row.tokens)\n if value is not None:\n releases.append((value, row))\n if not releases:\n return Result('lead_rule', True, [\n 'every in-action-set release is unknown: the ranking rule cannot apply'])\n biggest, biggest_row = max(releases, key=lambda item: item[0])\n # The lead is the first command in the table that lists the plan's commands.\n # A command restated elsewhere — the tool-verification block, a status\n # section — must not displace it, and no keyword decides which block that\n # is: a plan may legitimately head a section \"现状与已知问题(semantics 已\n # 核对)\" and still be stating its real lead there.\n listed = [command for command in commands if command.origin == 'cmdtable']\n first = (listed or commands)[0]\n target, _row, _resolvable = resolve_target(first, parse_tables(plan_text))\n identifiers = target_identifiers(first, target)\n program = argv0(first.text)\n leading_row = next(\n (item for item in releases\n if tokens_overlap(identifiers, item[1].basename_tokens)\n or program_matches(program, item[1])), None)\n label = biggest_row.target[:50] or biggest_row.row.text()[:50]\n if leading_row is not None and leading_row[1] is biggest_row:\n return Result('lead_rule', True, [\n 'lead command (line %d) targets the largest in-action-set release: '\n '%s (%.2f GiB)' % (first.lineno, label, biggest / UNIT_BYTES['GiB'])])\n hotspot_rows = [row for row in action_rows if row.hotspot]\n if any(tokens_overlap(identifiers, row.basename_tokens)\n or program_matches(program, row) for row in hotspot_rows):\n return Result('lead_rule', True, [\n 'lead command (line %d) targets a row marked as the hotspot/bottleneck'\n % first.lineno])\n return Result('lead_rule', False, [\n 'lead command (line %d): %s' % (first.lineno, first.text[:90]),\n 'its target does not map to the largest in-action-set release '\n '(%s, %.2f GiB, line %d) and carries no hotspot/bottleneck marker'\n % (label, biggest / UNIT_BYTES['GiB'], biggest_row.lineno),\n ])\n\n\ndef plan_release_lookup(plan_text, tokens):\n for row_tokens, value, _candidate in plan_release_table(plan_text):\n if tokens & row_tokens:\n return value\n return None\n\n\ndef check_tool_verification(commands, plan_text, gate_rows, plan_tables):\n \"\"\"Version + semantics source for every command the plan would actually run.\n\n Scope is the proposals: a command mapped to a row that is not in the action\n set is a report or an unlock option, and the gate does not demand its tool\n verification. A command that maps to no row at all is checked — an\n unclassified state change is exactly what the gate exists to stop.\n \"\"\"\n missing = []\n checked = 0\n skipped = 0\n for command in commands:\n row, _resolvable, _target = attribute(command, gate_rows, plan_tables)\n if row is not None and not in_action_set(row):\n skipped += 1\n continue\n checked += 1\n found, tool = has_verification(command, plan_text)\n if not found and command_quoted_in_gate_rule(command, gate_rows):\n found, tool = True, 'skill transcription'\n if not found:\n missing.append('line %d: %s (owning tool: %s)'\n % (command.lineno, command.text[:90], '/'.join(command.tools)))\n details = ['%d proposed command(s) checked for version + semantics source, '\n '%d not proposed (reported or unlock-only) so not checked'\n % (checked, skipped)]\n if missing:\n details.append('%d command(s) lack a recorded tool verification:' % len(missing))\n details.extend(missing)\n return Result('tool_verification', False, details)\n return Result('tool_verification', True, details)\n\n\ndef command_quoted_in_gate_rule(command, gate_rows):\n \"\"\"The governing rule that sanctioned this command also states its semantics.\n\n The gate allows the skill's own transcription as the semantics source when\n it is named as such, and a rule quoting the exact command is that case.\n \"\"\"\n for gate_row in gate_rows:\n if not in_action_set(gate_row):\n continue\n rule = normalize(gate_row.rule)\n for _tools, pattern, _wide, _versioned in DESTRUCTIVE_PATTERNS:\n match = pattern.search(command.text)\n if match and normalize(match.group(0)) in rule:\n return True\n return False\n\n\n# --- reference corpus -------------------------------------------------------\n\ndef load_references(reference, bundle):\n \"\"\"Corpus the quotes are verified against.\n\n An explicit --reference is the whole corpus. The default also accepts the\n sibling reference files and SKILL.md, because the gate contract allows a\n quote from \"the route's dedicated reference\" and real plans quote the skill\n body as well; SKILL.md-only resolutions are reported so they stay visible.\n\n Returns (normalized corpus, raw corpus, includes_skill). Both forms are\n needed: quotes are matched against the normalized text, while anything that\n reads a *table* out of the reference needs the raw text — normalization\n collapses newlines, which would turn every table into one long line.\n \"\"\"\n corpus = {}\n raw = {}\n if reference:\n paths = [Path(reference)] if Path(reference).is_file() else \\\n sorted(Path(reference).glob('*.md'))\n for path in paths:\n text = path.read_text(encoding='utf-8')\n corpus[path.name] = normalize(text)\n raw[path.name] = text\n if not corpus:\n raise ValueError('no markdown found under %s' % reference)\n return corpus, raw, False\n for path in sorted((bundle / 'references').glob('*.md')):\n text = path.read_text(encoding='utf-8')\n corpus[path.name] = normalize(text)\n raw[path.name] = text\n skill = bundle / 'SKILL.md'\n if skill.is_file():\n text = skill.read_text(encoding='utf-8')\n corpus['SKILL.md'] = normalize(text)\n raw['SKILL.md'] = text\n return corpus, raw, True\n\n\n# --- entry point ------------------------------------------------------------\n\ndef check_copy_budget(path, gate_rows):\n \"\"\"Read-only destination preflight; absent input authorizes no copies.\"\"\"\n if path is None:\n return Result('copy_budget', True, ['no copy preparation authorized; '\n 'supply --copy-budget before making a copy'])\n try:\n data = json.loads(Path(path).read_text(encoding='utf-8'))\n if not isinstance(data, dict):\n raise ValueError('copy budget must be an object')\n def positive(value, label):\n if type(value) is not int or value \u003c= 0:\n raise ValueError(label + ' must be a positive integer')\n return value\n allowance = positive(data.get('max_total_bytes'), 'max_total_bytes')\n reserve = positive(data.get('minimum_free_bytes'), 'minimum_free_bytes')\n parent = data.get('destination_parent')\n if not isinstance(parent, str) or not parent.strip() or \\\n not Path(parent).is_absolute() or not Path(parent).is_dir():\n raise ValueError('destination_parent must be an absolute existing directory')\n copies = data.get('copies')\n if not isinstance(copies, list) or not copies:\n raise ValueError('copies must be a nonempty array')\n rows = {row.target.strip().strip('`'): row for row in gate_rows}\n peak = 0\n for item in copies:\n if not isinstance(item, dict):\n raise ValueError('each copy must be an object')\n target = item.get('target')\n if not isinstance(target, str) or target not in rows:\n raise ValueError('copy target must match an exact classified target')\n reason = item.get('reason')\n if not isinstance(reason, str) or not reason.strip():\n raise ValueError('copy reason is missing or blank')\n basis = item.get('basis')\n if basis not in ('unique-state', 'user-requested'):\n raise ValueError('copy basis must be unique-state or user-requested')\n if basis == 'user-requested':\n direction = item.get('user_direction')\n if not isinstance(direction, str) or not direction.strip():\n raise ValueError('user-requested copy lacks original user_direction')\n elif rows[target].cls in ('REBUILDABLE', 'PROPOSABLE'):\n raise ValueError('automatic backup of disposable target: ' + target)\n peak += positive(item.get('source_bytes'), 'source_bytes') * \\\n positive(item.get('copies_at_peak'), 'copies_at_peak')\n if peak > allowance:\n raise ValueError('uncompressed copy peak %d exceeds allowance %d' % (peak, allowance))\n capacity = os.statvfs(parent)\n available = capacity.f_bavail * capacity.f_frsize\n if available \u003c allowance + reserve:\n raise ValueError('destination available %d cannot cover allowance %d plus reserve %d'\n % (available, allowance, reserve))\n return Result('copy_budget', True, ['%d copy entries examined; peak %d, allowance %d, '\n 'reserve %d, destination available %d bytes' %\n (len(copies), peak, allowance, reserve, available)])\n except (OSError, ValueError, TypeError) as error:\n return Result('copy_budget', False, [str(error)])\n\n\ndef build_parser():\n parser = argparse.ArgumentParser(\n description='Check a macos-cleaner Phase 2 plan against the entry gate.')\n parser.add_argument('--table', required=True,\n help='file holding the classification table')\n parser.add_argument('--plan', required=True,\n help='file holding the plan')\n parser.add_argument('--reference', default=None,\n help='reference corpus for governing-rule quotes '\n '(default: the whole bundle — references/*.md plus '\n 'SKILL.md. An explicit path becomes the ONLY corpus '\n 'and may reject compliant plans that quote elsewhere; '\n 'the default is the calibrated configuration.)')\n parser.add_argument('--verbose', action='store_true',\n help='print the parsed rows as well as the verdicts')\n parser.add_argument('--copy-budget', default=None,\n help='JSON copy rationale/peak budget; run on the destination host')\n return parser\n\n\ndef main(argv=None):\n parser = build_parser()\n args = parser.parse_args(argv)\n\n bundle = Path(__file__).resolve().parents[1]\n for label, path in (('table', args.table), ('plan', args.plan)):\n if not Path(path).is_file():\n print('exit 2: %s file not found: %s' % (label, path), file=sys.stderr)\n return 2\n try:\n table_text = Path(args.table).read_text(encoding='utf-8')\n plan_text = Path(args.plan).read_text(encoding='utf-8')\n references, raw_references, includes_skill = load_references(\n args.reference, bundle)\n except (OSError, ValueError) as error:\n print('exit 2: %s' % error, file=sys.stderr)\n return 2\n\n gate_rows = collect_gate_rows(table_text)\n plan_tables = parse_tables(plan_text)\n commands = find_destructive_commands(plan_text)\n\n results = [\n check_table_exists(gate_rows, table_text),\n check_quote_fidelity(gate_rows, references),\n check_target_coverage(commands, gate_rows, plan_tables, plan_text),\n check_action_set_classes(gate_rows),\n check_preserve_downgrade(gate_rows, raw_references, bundle),\n check_category_wide_exclusion(commands, gate_rows, plan_tables),\n check_lead_rule(commands, gate_rows, plan_text),\n check_tool_verification(commands, plan_text, gate_rows, plan_tables),\n check_copy_budget(args.copy_budget, gate_rows),\n ]\n\n for result in results:\n print(result.render())\n\n failures = [result for result in results if not result.passed]\n print('')\n if includes_skill:\n print('reference corpus: bundle references/*.md + SKILL.md '\n '(an explicit --reference would be used alone)')\n print('%d check(s) run, %d passed, %d failed'\n % (len(results), len(results) - len(failures), len(failures)))\n if failures:\n print('exit 1: %s — fix the plan; do not weaken the checker'\n % ', '.join(result.name for result in failures))\n return 1\n print('exit 0: the plan may be sent')\n return 0\n\n\nif __name__ == '__main__':\n sys.exit(main())\n","content_type":"text/x-python; charset=utf-8","language":"python","size":86829,"content_sha256":"3d763b7eb68c7546c420d8a52539a1c1be4c2a7478cdc05241dc7f7568090c0d"},{"filename":"scripts/cleanup_report.py","content":"#!/usr/bin/env python3\n\"\"\"\nGenerate before/after cleanup reports.\n\nUsage:\n # Capture before snapshot\n uv run scripts/cleanup_report.py --snapshot before\n\n # Capture after snapshot and generate report\n uv run scripts/cleanup_report.py --snapshot after --compare\n\n # Override the measured volume when the approved target is not the Data volume\n uv run scripts/cleanup_report.py --volume /Volumes/External --snapshot before\n\"\"\"\n\nimport os\nimport sys\nimport json\nimport argparse\nimport subprocess\nfrom datetime import datetime\nfrom pathlib import Path\n\n\nDEFAULT_VOLUME = '/System/Volumes/Data'\n\n\ndef format_size(bytes_size):\n \"\"\"Convert bytes to human-readable format.\"\"\"\n for unit in ['B', 'KB', 'MB', 'GB', 'TB']:\n if bytes_size \u003c 1024.0:\n return f\"{bytes_size:.1f} {unit}\"\n bytes_size /= 1024.0\n return f\"{bytes_size:.1f} PB\"\n\n\ndef get_disk_usage(volume=DEFAULT_VOLUME):\n \"\"\"\n Get current disk usage.\n\n Returns:\n dict with total, used, available, percent\n \"\"\"\n try:\n result = subprocess.run(\n ['/bin/df', '-k', volume],\n capture_output=True,\n text=True,\n timeout=10,\n )\n\n if result.returncode == 0:\n lines = result.stdout.strip().split('\\n')\n if len(lines) >= 2:\n # Parse df output\n parts = lines[1].split()\n total_kb = int(parts[1])\n used_kb = int(parts[2])\n available_kb = int(parts[3])\n percent = int(parts[4].rstrip('%'))\n\n return {\n 'total': total_kb * 1024,\n 'used': used_kb * 1024,\n 'available': available_kb * 1024,\n 'percent': percent,\n 'volume': volume,\n 'timestamp': datetime.now().astimezone().isoformat()\n }\n except (OSError, subprocess.SubprocessError, ValueError, IndexError):\n pass\n\n return None\n\n\ndef save_snapshot(name, volume=DEFAULT_VOLUME):\n \"\"\"Save one measured disk snapshot and return that exact reading.\"\"\"\n snapshot_dir = Path.home() / '.macos-cleaner'\n usage = get_disk_usage(volume)\n if not usage:\n print(\"❌ Failed to get disk usage\")\n return None\n\n try:\n snapshot_dir.mkdir(exist_ok=True)\n snapshot_file = snapshot_dir / f'{name}.json'\n with snapshot_file.open('w') as f:\n json.dump(usage, f, indent=2)\n print(f\"✅ Snapshot saved: {snapshot_file}\")\n return usage\n except OSError as error:\n print(f\"❌ Failed to save snapshot: {error}\")\n return None\n\n\ndef load_snapshot(name):\n \"\"\"Load disk usage snapshot from file.\"\"\"\n snapshot_dir = Path.home() / '.macos-cleaner'\n snapshot_file = snapshot_dir / f'{name}.json'\n\n if not snapshot_file.exists():\n print(f\"❌ Snapshot not found: {snapshot_file}\")\n return None\n\n with snapshot_file.open('r') as f:\n return json.load(f)\n\n\ndef generate_report(before, after):\n \"\"\"Generate comparison report.\"\"\"\n before_volume = before.get('volume')\n after_volume = after.get('volume')\n if not before_volume or not after_volume:\n raise ValueError(\n 'Snapshot is missing its volume. Capture a new before snapshot '\n 'with this version before comparing.'\n )\n if before_volume != after_volume:\n raise ValueError(\n f'Snapshot volume mismatch: before={before_volume}, '\n f'after={after_volume}'\n )\n\n print(\"\\n\" + \"=\" * 60)\n print(\"📊 Cleanup Report\")\n print(\"=\" * 60)\n print(f\"Volume: {before_volume}\")\n\n # Time\n before_time = datetime.fromisoformat(before['timestamp'])\n after_time = datetime.fromisoformat(after['timestamp'])\n if before_time.utcoffset() is None or after_time.utcoffset() is None:\n raise ValueError('Snapshot timestamps must include explicit UTC offsets')\n if after_time \u003c before_time:\n raise ValueError('After snapshot predates before snapshot; recapture both')\n duration = after_time - before_time\n\n print(f\"\\nCleanup Duration: {duration}\")\n print(f\"Before: {before_time.isoformat()}\")\n print(f\"After: {after_time.isoformat()}\")\n\n # Disk usage comparison\n print(\"\\n\" + \"-\" * 60)\n print(\"Disk Usage\")\n print(\"-\" * 60)\n\n before_used = before['used']\n after_used = after['used']\n recovered = before_used - after_used\n\n print(f\"Before: {format_size(before_used):>12} ({before['percent']}%)\")\n print(f\"After: {format_size(after_used):>12} ({after['percent']}%)\")\n print(\"-\" * 60)\n\n if recovered > 0:\n print(f\"✅ Recovered: {format_size(recovered):>12}\")\n percent_recovered = (recovered / before_used) * 100\n print(f\" ({percent_recovered:.1f}% of used space)\")\n elif recovered \u003c 0:\n print(f\"⚠️ Space increased: {format_size(abs(recovered)):>12}\")\n print(\" (This may be due to system activity during cleanup)\")\n else:\n print(\"No change in disk usage\")\n\n # Available space\n print(\"\\n\" + \"-\" * 60)\n print(\"Available Space\")\n print(\"-\" * 60)\n\n before_avail = before['available']\n after_avail = after['available']\n gained = after_avail - before_avail\n\n print(f\"Before: {format_size(before_avail):>12}\")\n print(f\"After: {format_size(after_avail):>12}\")\n print(\"-\" * 60)\n\n if gained > 0:\n print(f\"✅ Gained: {format_size(gained):>12}\")\n elif gained \u003c 0:\n print(f\"⚠️ Lost: {format_size(abs(gained)):>12}\")\n else:\n print(\"No change\")\n\n # Recommendations\n print(\"\\n\" + \"=\" * 60)\n\n if after['percent'] > 90:\n print(\"⚠️ Warning: Disk is still >90% full\")\n print(\"\\n💡 Recommendations:\")\n print(\" - Consider moving large files to external storage\")\n print(\" - Review exact obsolete projects for archive or removal\")\n print(\" - Check for large application data\")\n elif after['percent'] > 80:\n print(\"⚠️ Disk usage is still high (>80%)\")\n print(\"\\n💡 Recommendations:\")\n print(\" - Run cleanup again in 1-2 weeks\")\n print(\" - Monitor large file creation\")\n else:\n print(\"✅ Disk usage is healthy!\")\n print(\"\\n💡 Maintenance Tips:\")\n print(\" - Run cleanup monthly\")\n print(\" - Empty Trash regularly\")\n print(\" - Clear browser caches weekly\")\n\n print(\"=\" * 60)\n\n\ndef main():\n parser = argparse.ArgumentParser(\n description='Generate cleanup reports'\n )\n parser.add_argument(\n '--snapshot',\n choices=['before', 'after'],\n required=True,\n help='Snapshot type (before or after cleanup)'\n )\n parser.add_argument(\n '--compare',\n action='store_true',\n help='Compare with before snapshot (use with --snapshot after)'\n )\n parser.add_argument(\n '--volume',\n default=DEFAULT_VOLUME,\n help=f'Volume to measure (default: {DEFAULT_VOLUME})'\n )\n args = parser.parse_args()\n\n if args.snapshot == 'before':\n print(\"📸 Capturing disk usage before cleanup...\")\n usage = save_snapshot('before', args.volume)\n if not usage:\n return 1\n print(f\"\\nCurrent Usage: {format_size(usage['used'])} ({usage['percent']}%)\")\n print(f\"Available: {format_size(usage['available'])}\")\n print(f\"Timestamp: {usage['timestamp']}\")\n print(\"\\n💡 Run the approved cleanup operations, then:\")\n print(\n \" uv run scripts/cleanup_report.py \"\n f\"--volume '{args.volume}' --snapshot after --compare\"\n )\n return 0\n\n elif args.snapshot == 'after':\n # Save after snapshot\n print(\"📸 Capturing disk usage after cleanup...\")\n after = save_snapshot('after', args.volume)\n if not after:\n return 1\n\n if args.compare:\n # Load before snapshot and compare\n before = load_snapshot('before')\n if before and after:\n try:\n generate_report(before, after)\n except ValueError as error:\n print(f\"❌ Cannot compare: {error}\")\n return 1\n else:\n print(\"❌ Cannot compare: missing snapshots\")\n return 1\n else:\n print(f\"\\nCurrent Usage: {format_size(after['used'])} ({after['percent']}%)\")\n print(f\"Available: {format_size(after['available'])}\")\n\n return 0\n\n\nif __name__ == '__main__':\n sys.exit(main())\n","content_type":"text/x-python; charset=utf-8","language":"python","size":8655,"content_sha256":"24dea998f3e1b3846d51a83a6bc636f4364dd215534ee492519285cf281d1349"},{"filename":"scripts/find_app_remnants.py","content":"#!/usr/bin/env python3\n\"\"\"\nFind orphaned application support files and preferences.\n\nThis script identifies directories in ~/Library that may belong to\nuninstalled applications.\n\nUsage:\n uv run scripts/find_app_remnants.py [--min-size SIZE]\n\nRead scope:\n- /Applications and ~/Applications\n- ~/Library/Application Support, Containers, Preferences, and\n Saved Application State\n\nRun only after that complete fixed scope is approved.\n\nOptions:\n --min-size Minimum size in MB to report (default: 10)\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport argparse\nimport plistlib\nfrom pathlib import Path\n\n\ndef format_size(bytes_size):\n \"\"\"Convert bytes to human-readable format.\"\"\"\n for unit in ['B', 'KB', 'MB', 'GB', 'TB']:\n if bytes_size \u003c 1024.0:\n return f\"{bytes_size:.1f} {unit}\"\n bytes_size /= 1024.0\n return f\"{bytes_size:.1f} PB\"\n\n\ndef get_dir_size(path):\n \"\"\"Get directory size using du command.\"\"\"\n try:\n result = subprocess.run(\n ['du', '-sk', path],\n capture_output=True,\n text=True,\n timeout=30\n )\n if result.returncode == 0:\n size_kb = int(result.stdout.split()[0])\n return size_kb * 1024\n return 0\n except (subprocess.TimeoutExpired, ValueError, IndexError):\n return 0\n\n\ndef get_bundle_identifier(app_path):\n \"\"\"Read CFBundleIdentifier from an app bundle when available.\"\"\"\n info_plist = app_path / 'Contents' / 'Info.plist'\n try:\n with info_plist.open('rb') as f:\n info = plistlib.load(f)\n except Exception:\n return None\n\n bundle_id = info.get('CFBundleIdentifier')\n if isinstance(bundle_id, str) and bundle_id.strip():\n return bundle_id.strip()\n return None\n\n\ndef _empty_installed_apps():\n return {\n 'names': set(),\n 'bundle_ids': set(),\n }\n\n\ndef _coerce_installed_apps(installed_apps):\n if isinstance(installed_apps, dict):\n return {\n 'names': set(installed_apps.get('names', set())),\n 'bundle_ids': set(installed_apps.get('bundle_ids', set())),\n }\n return {\n 'names': set(installed_apps),\n 'bundle_ids': set(),\n }\n\n\ndef _add_app_bundle(installed_apps, app_path):\n installed_apps['names'].add(app_path.stem)\n bundle_id = get_bundle_identifier(app_path)\n if bundle_id:\n installed_apps['bundle_ids'].add(bundle_id)\n\n\ndef _matches_bundle_identifier(dir_name, bundle_id):\n dir_lower = dir_name.lower()\n bundle_lower = bundle_id.lower()\n if dir_lower == bundle_lower or dir_lower.startswith(bundle_lower + '.'):\n return True\n\n return normalize_name(dir_name) == normalize_name(bundle_id)\n\n\ndef get_installed_apps():\n \"\"\"Get installed application names and bundle identifiers.\"\"\"\n apps = _empty_installed_apps()\n\n # System applications\n system_app_dir = Path('/Applications')\n if system_app_dir.exists():\n for app in system_app_dir.iterdir():\n if app.suffix == '.app':\n _add_app_bundle(apps, app)\n\n # User applications\n user_app_dir = Path.home() / 'Applications'\n if user_app_dir.exists():\n for app in user_app_dir.iterdir():\n if app.suffix == '.app':\n _add_app_bundle(apps, app)\n\n return apps\n\n\ndef normalize_name(name):\n \"\"\"\n Normalize app name for matching.\n\n Examples:\n 'Google Chrome' -> 'googlechrome'\n 'com.apple.Safari' -> 'safari'\n \"\"\"\n # Remove common prefixes\n for prefix in ['com.', 'org.', 'net.', 'io.']:\n if name.startswith(prefix):\n name = name[len(prefix):]\n\n # Remove non-alphanumeric\n name = ''.join(c for c in name if c.isalnum())\n\n return name.lower()\n\n\ndef is_likely_orphaned(dir_name, installed_apps):\n \"\"\"\n Check if directory is likely orphaned.\n\n Returns:\n (is_orphaned, confidence, reason)\n confidence: 'high' | 'medium' | 'low'\n \"\"\"\n installed = _coerce_installed_apps(installed_apps)\n norm_dir = normalize_name(dir_name)\n\n # Bundle identifiers are common in Containers and Saved Application State.\n for bundle_id in installed['bundle_ids']:\n if _matches_bundle_identifier(dir_name, bundle_id):\n return (\n False,\n None,\n f\"Matches installed app bundle identifier: {bundle_id}\"\n )\n\n # Check display-name matches.\n for app in installed['names']:\n norm_app = normalize_name(app)\n if norm_app and (norm_app in norm_dir or norm_dir in norm_app):\n return (False, None, f\"Matches installed app: {app}\")\n\n # System/common directories to always keep\n system_dirs = {\n 'apple', 'safari', 'finder', 'mail', 'messages', 'notes',\n 'photos', 'music', 'calendar', 'contacts', 'reminders',\n 'preferences', 'cookies', 'webkit', 'coredata',\n 'cloudkit', 'icloud', 'appstore', 'systemmigration'\n }\n\n if any(sys_dir in norm_dir for sys_dir in system_dirs):\n return (False, None, \"System/built-in application\")\n\n # If we get here, likely orphaned\n return (True, 'medium', \"No matching application found\")\n\n\ndef analyze_library_dir(library_path, min_size_bytes, installed_apps):\n \"\"\"\n Analyze a Library subdirectory for orphaned data.\n\n Args:\n library_path: Path to scan (e.g., ~/Library/Application Support)\n min_size_bytes: Minimum size to report\n installed_apps: Dict with installed app names and bundle identifiers\n\n Returns:\n List of (name, path, size, confidence, reason) tuples\n \"\"\"\n if not os.path.exists(library_path):\n return []\n\n results = []\n\n try:\n for entry in os.scandir(library_path):\n if entry.is_dir():\n size = get_dir_size(entry.path)\n if size >= min_size_bytes:\n is_orphaned, confidence, reason = is_likely_orphaned(\n entry.name,\n installed_apps\n )\n if is_orphaned:\n results.append((\n entry.name,\n entry.path,\n size,\n confidence,\n reason\n ))\n except PermissionError:\n print(f\"⚠️ Permission denied: {library_path}\", file=sys.stderr)\n return []\n\n # Sort by size descending\n results.sort(key=lambda x: x[2], reverse=True)\n return results\n\n\ndef main():\n parser = argparse.ArgumentParser(\n description='Find orphaned application data'\n )\n parser.add_argument(\n '--min-size',\n type=int,\n default=10,\n help='Minimum size in MB to report (default: 10)'\n )\n args = parser.parse_args()\n\n min_size_bytes = args.min_size * 1024 * 1024\n\n print(\"🔍 Searching for Orphaned Application Data\")\n print(\"=\" * 70)\n\n # Get installed apps\n print(\"Scanning installed applications...\")\n installed_apps = get_installed_apps()\n print(\n f\"Found {len(installed_apps['names'])} installed applications \"\n f\"and {len(installed_apps['bundle_ids'])} bundle identifiers\\n\"\n )\n\n # Directories to check\n library_dirs = {\n 'Application Support': Path.home() / 'Library' / 'Application Support',\n 'Containers': Path.home() / 'Library' / 'Containers',\n 'Preferences': Path.home() / 'Library' / 'Preferences',\n 'Saved Application State': Path.home() / 'Library' / 'Saved Application State'\n }\n\n all_orphans = []\n total_size = 0\n\n for category, path in library_dirs.items():\n print(f\"\\n📂 {category}\")\n print(\"-\" * 70)\n\n orphans = analyze_library_dir(path, min_size_bytes, installed_apps)\n\n if orphans:\n print(f\"{'Name':\u003c40} {'Size':\u003c12} {'Confidence'}\")\n print(\"-\" * 70)\n\n for name, full_path, size, confidence, reason in orphans:\n conf_icon = {'high': '🔴', 'medium': '🟡', 'low': '🟢'}[confidence]\n # Truncate long names\n display_name = name if len(name) \u003c= 37 else name[:34] + \"...\"\n print(f\"{display_name:\u003c40} {format_size(size):\u003c12} {conf_icon} {confidence}\")\n\n all_orphans.append((category, name, full_path, size, confidence, reason))\n total_size += size\n else:\n print(\"No orphaned data found above minimum size\")\n\n # Summary\n print(\"\\n\\n📊 Summary\")\n print(\"=\" * 70)\n print(f\"Total orphaned data found: {len(all_orphans)} items\")\n print(f\"Total size: {format_size(total_size)}\")\n\n if all_orphans:\n print(\"\\n\\n🗑️ Recommended Deletions (Medium/High Confidence)\")\n print(\"=\" * 70)\n\n for category, name, path, size, confidence, reason in all_orphans:\n if confidence in ['medium', 'high']:\n print(f\"\\n{name}\")\n print(f\" Location: {path}\")\n print(f\" Size: {format_size(size)}\")\n print(f\" Reason: {reason}\")\n print(f\" ⚠️ Verify this app is truly uninstalled before deleting\")\n\n print(\"\\n\\n💡 Next Steps:\")\n print(\" 1. Double-check each item in /Applications and ~/Applications\")\n print(\" 2. Search Spotlight for the application name\")\n print(\" 3. Return exact candidates to the main skill's impact and confirmation gate\")\n print(\" (a heuristic orphan result is not deletion authorization)\")\n\n return 0\n\n\nif __name__ == '__main__':\n sys.exit(main())\n","content_type":"text/x-python; charset=utf-8","language":"python","size":9662,"content_sha256":"e5cc89f294e6a8eee525b4e9fc6fb40ba9d5974ef1c03f5de9faa51da399f4b6"},{"filename":"scripts/safe_delete.py","content":"#!/usr/bin/env python3\n\"\"\"\nInteractive safe file/directory deletion with confirmation.\n\nUsage:\n uv run scripts/safe_delete.py \u003cpath1> [path2] [path3] ...\n uv run scripts/safe_delete.py --batch \u003cfile_with_paths>\n\nOptions:\n --batch FILE Read paths from a file (one per line)\n\"\"\"\n\nimport os\nimport sys\nimport shutil\nimport argparse\nimport subprocess\nfrom pathlib import Path\n\n\ndef _canonical_path(path):\n \"\"\"Return a canonical path for safety comparisons.\"\"\"\n return Path(os.path.realpath(os.path.expanduser(str(path))))\n\n\nROOT_PATH = _canonical_path('/')\n\nHIGH_RISK_PATHS = frozenset(\n _canonical_path(path)\n for path in [\n '/System',\n '/Library/Apple',\n '/usr',\n '/bin',\n '/sbin',\n '/etc',\n '/private/var/db',\n '~/.ssh',\n '~/.aws',\n '~/.gnupg',\n '~/Library/Keychains',\n ]\n)\n\n\ndef _is_same_or_child(path, parent):\n try:\n path.relative_to(parent)\n return True\n except ValueError:\n return False\n\n\ndef get_high_risk_match(path):\n \"\"\"Return the denied ancestor path when path is too dangerous to delete.\"\"\"\n canonical = _canonical_path(path)\n if canonical == ROOT_PATH:\n return ROOT_PATH\n\n for denied_path in HIGH_RISK_PATHS:\n if _is_same_or_child(canonical, denied_path):\n return denied_path\n return None\n\n\ndef is_high_risk_path(path):\n \"\"\"Check whether a path is blocked by the forced-delete denylist.\"\"\"\n return get_high_risk_match(path) is not None\n\n\ndef format_blocked_message(path, denied_path):\n return (\n \"BLOCKED: high-risk path refused by safety denylist \"\n f\"({path} matches or is inside {denied_path})\"\n )\n\n\ndef format_size(bytes_size):\n \"\"\"Convert bytes to human-readable format.\"\"\"\n for unit in ['B', 'KB', 'MB', 'GB', 'TB']:\n if bytes_size \u003c 1024.0:\n return f\"{bytes_size:.1f} {unit}\"\n bytes_size /= 1024.0\n return f\"{bytes_size:.1f} PB\"\n\n\ndef get_size(path):\n \"\"\"Get size of file or directory.\"\"\"\n path_obj = Path(path)\n\n if not path_obj.exists():\n return 0\n\n if path_obj.is_file():\n return path_obj.stat().st_size\n elif path_obj.is_dir():\n try:\n result = subprocess.run(\n ['du', '-sk', path],\n capture_output=True,\n text=True,\n timeout=30\n )\n if result.returncode == 0:\n size_kb = int(result.stdout.split()[0])\n return size_kb * 1024\n except (subprocess.TimeoutExpired, ValueError, IndexError):\n pass\n\n return 0\n\n\ndef get_description(path):\n \"\"\"Get human-readable description of path.\"\"\"\n path_obj = Path(path)\n\n if not path_obj.exists():\n return \"Path does not exist\"\n\n if path_obj.is_file():\n suffix = path_obj.suffix or \"file\"\n return f\"File ({suffix})\"\n elif path_obj.is_dir():\n try:\n # Count items\n items = list(path_obj.iterdir())\n return f\"Directory ({len(items)} items)\"\n except PermissionError:\n return \"Directory (permission denied to list)\"\n\n return \"Unknown\"\n\n\ndef confirm_delete(path, size, description):\n \"\"\"\n Ask user to confirm deletion.\n\n Args:\n path: File/directory path\n size: Size in bytes\n description: What this file/directory is\n\n Returns:\n True if user confirms, False otherwise\n \"\"\"\n denied_path = get_high_risk_match(path)\n if denied_path:\n print(f\"\\n⛔ {format_blocked_message(path, denied_path)}\")\n print(\" Refusing to ask for confirmation for this target.\")\n return False\n\n print(f\"\\n🗑️ Confirm Deletion\")\n print(\"━\" * 50)\n print(f\"Path: {path}\")\n print(f\"Measured size: {format_size(size)} (not a physical-release promise)\")\n print(f\"Description: {description}\")\n\n # Additional safety check for important paths\n path_str = str(path).lower()\n danger_patterns = [\n 'documents', 'desktop', 'pictures', 'movies',\n 'downloads', 'music', '.ssh', 'credentials'\n ]\n\n if any(pattern in path_str for pattern in danger_patterns):\n print(\"\\n⚠️ WARNING: This path may contain important personal data!\")\n print(\" Consider backing up before deletion.\")\n\n response = input(\"\\nDelete this item? [y/N]: \").strip().lower()\n return response == 'y'\n\n\ndef batch_confirm(items):\n \"\"\"\n Show all items, ask for batch confirmation.\n\n Args:\n items: List of (path, size, description) tuples\n\n Returns:\n List of items user approved\n \"\"\"\n print(\"\\n📋 Items to Delete:\")\n print(\"━\" * 70)\n print(f\"{'#':\u003c4} {'Size':\u003c12} {'Path'}\")\n print(\"-\" * 70)\n\n for i, (path, size, description) in enumerate(items, 1):\n # Truncate long paths\n display_path = str(path)\n if len(display_path) > 48:\n display_path = display_path[:45] + \"...\"\n print(f\"{i:\u003c4} {format_size(size):\u003c12} {display_path}\")\n\n total_size = sum(item[1] for item in items)\n print(\"-\" * 70)\n print(f\"{'Measured total':\u003c14} {format_size(total_size):\u003c12}\")\n\n print(\"\\nOptions:\")\n print(\" 'all' - Delete all items\")\n print(\" '1,3,5' - Delete specific items by number\")\n print(\" '1-5' - Delete range of items\")\n print(\" 'none' - Cancel (default)\")\n\n response = input(\"\\nYour choice: \").strip().lower()\n\n if response == '' or response == 'none':\n return []\n elif response == 'all':\n return items\n else:\n selected = []\n # Parse response\n parts = response.replace(' ', '').split(',')\n\n for part in parts:\n try:\n if '-' in part:\n # Range: 1-5\n start, end = part.split('-')\n start_idx = int(start) - 1\n end_idx = int(end) - 1\n for i in range(start_idx, end_idx + 1):\n if 0 \u003c= i \u003c len(items):\n selected.append(items[i])\n else:\n # Single number\n idx = int(part) - 1\n if 0 \u003c= idx \u003c len(items):\n selected.append(items[idx])\n except ValueError:\n print(f\"⚠️ Ignoring invalid selection: {part}\")\n continue\n\n return selected\n\n\ndef delete_path(path):\n \"\"\"\n Delete a file or directory.\n\n Returns:\n (success, message)\n \"\"\"\n try:\n denied_path = get_high_risk_match(path)\n if denied_path:\n return (False, format_blocked_message(path, denied_path))\n\n path_obj = Path(path)\n\n if not path_obj.exists():\n return (False, \"Path does not exist\")\n\n if path_obj.is_file():\n path_obj.unlink()\n elif path_obj.is_dir():\n shutil.rmtree(path)\n else:\n return (False, \"Unknown path type\")\n\n return (True, \"Deleted successfully\")\n\n except PermissionError:\n return (False, \"Permission denied\")\n except Exception as e:\n return (False, f\"Error: {str(e)}\")\n\n\ndef main():\n parser = argparse.ArgumentParser(\n description='Interactive safe deletion'\n )\n parser.add_argument(\n 'paths',\n nargs='*',\n help='Paths to delete'\n )\n parser.add_argument(\n '--batch',\n metavar='FILE',\n help='Read paths from file (one per line)'\n )\n args = parser.parse_args()\n\n # Collect paths\n paths = []\n\n if args.batch:\n # Read from file\n batch_file = Path(args.batch)\n if not batch_file.exists():\n print(f\"❌ Batch file not found: {args.batch}\")\n return 1\n\n with batch_file.open('r') as f:\n for line in f:\n line = line.strip()\n if line and not line.startswith('#'):\n paths.append(line)\n else:\n paths = args.paths\n\n if not paths:\n parser.print_help()\n return 1\n\n # Block high-risk paths before sizing or confirmation.\n allowed_paths = []\n for path in paths:\n denied_path = get_high_risk_match(path)\n if denied_path:\n print(f\"⛔ {format_blocked_message(path, denied_path)}\")\n else:\n allowed_paths.append(path)\n\n paths = allowed_paths\n\n # A missing requested target means the approved set changed. Do not\n # silently shrink a batch and present the remainder as the original plan.\n missing_paths = [path for path in paths if not Path(path).exists()]\n if missing_paths:\n print(\"❌ Requested target set changed; missing paths:\")\n for path in missing_paths:\n print(f\" {path}\")\n return 1\n\n # Prepare items\n items = []\n for path in paths:\n size = get_size(path)\n description = get_description(path)\n items.append((path, size, description))\n\n if not items:\n print(\"❌ No valid paths to delete\")\n return 1\n\n # Interactive mode\n if len(items) == 1:\n # Single item - simple confirmation\n path, size, description = items[0]\n if not confirm_delete(path, size, description):\n print(\"\\n✅ Deletion cancelled\")\n return 0\n\n success, message = delete_path(path)\n if success:\n print(f\"\\n✅ {message}\")\n print(f\" Measured size removed: {format_size(size)}\")\n print(\" Physical release: verify with before/after df -k\")\n return 0\n else:\n print(f\"\\n❌ {message}\")\n return 1\n\n else:\n # Multiple items - batch confirmation\n selected = batch_confirm(items)\n\n if not selected:\n print(\"\\n✅ Deletion cancelled\")\n return 0\n\n # Delete selected items\n print(f\"\\n🗑️ Deleting {len(selected)} items...\")\n print(\"━\" * 50)\n\n success_count = 0\n total_freed = 0\n\n for path, size, description in selected:\n success, message = delete_path(path)\n status_icon = \"✅\" if success else \"❌\"\n print(f\"{status_icon} {path}: {message}\")\n\n if success:\n success_count += 1\n total_freed += size\n else:\n print(\"⛔ Stopping batch after the first failure.\")\n break\n\n print(\"━\" * 50)\n print(f\"\\n📊 Results:\")\n print(f\" Successfully deleted: {success_count}/{len(selected)}\")\n print(f\" Measured size removed: {format_size(total_freed)}\")\n print(\" Physical release: verify with before/after df -k\")\n\n return 0 if success_count == len(selected) else 1\n\n\nif __name__ == '__main__':\n sys.exit(main())\n","content_type":"text/x-python; charset=utf-8","language":"python","size":10846,"content_sha256":"20494ac81b2307ebbd6afaf36689da1e10b829939017b4c4c59c622447d65ce1"},{"filename":"scripts/stream_accounting.py","content":"#!/usr/bin/env python3\n\"\"\"Classify bounded native observations; unique stream allocation is only an upper bound.\"\"\"\nimport argparse\nfrom collections import defaultdict\nimport json\n\n\ndef integer(v):\n return type(v) is int and v >= 0\n\n\ndef account(rows):\n if not isinstance(rows, list) or not rows:\n raise ValueError('nonempty observation list required')\n groups=defaultdict(list);unknown=[];paths=set();nominal=0\n required=('device','inode','logical_bytes','allocated_bytes','nlink','clone_id','full_clone_refs','extended_flags')\n for r in rows:\n if not isinstance(r,dict) or not isinstance(r.get('path'),str) or not r['path'] or r['path'] in paths:\n raise ValueError('invalid or duplicate path')\n paths.add(r['path'])\n if r.get('stable') is not True or not all(integer(r.get(k)) for k in required):\n unknown.append(dict(path=r['path'],reason='incomplete_or_unstable_observation'));continue\n nominal+=r['allocated_bytes']\n if not r['clone_id'] or not r['inode'] or not r['nlink'] or r['extended_flags'] & ~0x52:\n unknown.append(dict(path=r['path'],reason='unknown_native_state'));continue\n groups[(r['device'],r['clone_id'])].append(r)\n internal=[];external=[]\n for key,group in groups.items():\n inodes=defaultdict(list)\n for r in group:inodes[r['inode']].append(r)\n values={(r['logical_bytes'],r['allocated_bytes'],r['full_clone_refs']) for r in group}\n if len(values)!=1:\n unknown.append(dict(paths=[r['path'] for r in group],reason='inconsistent_stream_metadata'));continue\n logical,allocated,refs=next(iter(values))\n if not refs:\n unknown.append(dict(paths=[r['path'] for r in group],reason='zero_ref_count'));continue\n row=dict(device=key[0],clone_id=key[1],unique_inodes=len(inodes),logical_bytes=logical,allocated_upper_bytes=allocated)\n if refs!=len(inodes) or any(any(r['nlink']!=len(paths) for r in paths) for paths in inodes.values()):\n external.append(row);continue\n internal.append(row)\n return dict(path_accounted_allocated_bytes=nominal,observed_paths=len(rows),\n wholly_internal_streams=internal,external_or_missing_reference_streams=external,unknown=unknown,\n internal_allocated_upper_bytes=sum(r['allocated_upper_bytes'] for r in internal),\n physical_release_bytes=None,limits='Complete-in-set full-clone/hardlink refs only. Partial sharing, snapshots and concurrent activity can reduce release; no cleanup authority.')\n\n\ndef main():\n p=argparse.ArgumentParser(description=__doc__);p.add_argument('observations');a=p.parse_args()\n try:\n with open(a.observations) as f:print(json.dumps(account(json.load(f)),indent=2))\n except (ValueError,TypeError,OSError) as e:p.exit(2,str(e)+'\\n')\n\n\nif __name__=='__main__':main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":2897,"content_sha256":"74d03c061d4f543ade008e88e3fca93ce3ed011cbea7a35aba418ad7a00ac94f"},{"filename":"tests/fixtures/corpus/known-bad-original.md","content":"# Calibration fixture: KNOWN-BAD — the 2026-09-17 original failure report\n\n忠实复现自 2026-09-17 session 的第一份 Phase 2 报告(触发 macos-cleaner Phase 2 entry gate 的那次失败)。\n用于标定 `scripts/check_gate_plan.py` 的**召回率**:检测器必须对这份输出 exit 1。\n\n该报告的结构性病灶(检测器应抓住的):\n- 没有任何 gate 分类表(无 PRESERVE/PROPOSABLE 等 class 列的表)\n- category-wide 命令(npm cache clean --force / uv cache prune / uv cache clean)被列为「绿色低风险组合」\n- 2G 的 Homebrew 与 91G 的 uv 并排,无排序依据\n- uv cache prune 被推荐为「首选热点」,依据是 --help 摘要 + 大小比例,未验证版本与语义\n\n---\n\n# macOS 磁盘盘点报告(Phase 1 只读诊断 / Phase 2 计划,未执行任何写操作)\n\n## 一、大头账(du 名义大小;已去重,不重复计数)\n\n| # | 项目 | 名义大小 | 核验 | 分类 |\n|---|---|---|---|---|\n| 1 | `~/Downloads` | 318G | 仅 agent 报数,**构成未核验** | 🔴 用户数据,**非清理对象** |\n| 2 | `~/Movies` | 100G | 仅 agent 报数 | 🔴 用户数据 |\n| 3 | 微信容器 `Containers/com.tencent.xinWeChat` | 108G | ✅ 主会话复核 | 🟡 用户数据,内含可清理缓存 |\n| 4 | OrbStack VM `data.img.raw` | 95.0 GiB(物理实测) | ✅ 主会话复核 | 🟡 可回收,见下 |\n| 5 | `~/.cache/uv` | 91.3 GiB | ✅ 主会话 du 复核 | 🟡 可重建 |\n| 6 | `~/.npm` | 17.7G | ✅ | 🟡 可重建 |\n\n## 二、Docker/OrbStack 明细(`docker system df` 实测)\n\n- 镜像 182 个(48 在用):dangling 57 个,最大 4 个各 1.44GB 完全独有\n- 卷 73 个(47 dangling)\n- **构建缓存 20.26GB 可回收,ACTIVE=0**\n- **在跑 14 个容器**:aicms-local-uat 全套 13 个 + ashare redis\n\n## 三、可选方案(每个都停在确认闸门前)\n\n**绿色(可重建缓存,低风险)**——若授权我执行,会逐条跑、逐条 df 验证:\n\n| 动作 | 命令(**未执行**) | 预期释放 | 代价/注意 |\n|---|---|---|---|\n| npm 缓存 | `npm cache clean --force` | ~17G | 离线包重下 |\n| Homebrew 缓存 | `brew cleanup` | ~2G | 重下 |\n| 废纸篓 | Finder 清倒 | ~15G | 你删过的东西,确认无「手滑」 |\n| uv 缓存 | `uv cache clean` | 名义 91G,实际待测 | ⚠️ 319,214 个文件 9 月内被修改 = 大量并发 session 在用,建议无并发时做 |\n\n**黄色(需逐个对象确认,禁 prune 家族)**:\n\n- dangling 镜像 4×1.44G:`docker rmi \u003cid>` 逐个\n- 构建缓存 20.26G:skill 政策**只报不删**\n\n### 热点 1:uv cache 91.3G —— 官方精确控制可回收\n\n`uv cache prune` 的语义是 **「Prune all unreachable objects」**(只删**不可达**对象 = 没有任何 venv 链接它 = **永远用不到**)。这正是「永远用不到」的机械判据,且是 uv 官方支持的控制。\n\n证据链:\n- cache 里存了 **586 个环境**(environments-v2),而整个 `~/workspace` 实际只有 **28 个活 venv(名义 10.2G)**\n- `uv cache prune` 保留全部 28 个活 venv 链接的对象,**只回收孤儿对象**,重建代价接近零\n\n这是全盘最大的单点可回收项,且是**唯一一个有官方精确控制 + 机械判据**的。推荐首选。\n\n## 四、结论\n\n真正值得动的就一件事:**`uv cache prune`**(官方控制、只删永远用不到的、活的 28 个 venv 零影响、预期回收几十 G 量级)。\n\n**仍未执行、未写入任何东西。** 要不要跑 `uv cache prune`?它虽是可逆依赖重建的操作,但属于写操作,等你一句话。跑的话我会:执行 → 立刻 `du` + `df` 读回实际回收量 → 抽查几个活 venv 是否仍可用。\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":3756,"content_sha256":"d40b6f70f19564f0844f7a6ee60403e03923ffa6d1cb9cd968232176f4106730"},{"filename":"tests/fixtures/corpus/known-good-arm1.md","content":"\u003c!-- Fixture note: the OrbStack \"Reclaim disk space\" control referenced below no longer exists in OrbStack 2.2.x (physical release is automatic lagged TRIM, measured 2026-10-23). This file is gate-checker corpus, not operational guidance. -->\n# macOS 磁盘诊断与清理方案\n\n调用 `macos-cleaner` skill 完成。**全程只读诊断,没有执行任何清理/删除/状态变更命令**(未跑 `npm cache clean`、`brew cleanup`、`uv cache clean/prune`、任何 `docker rmi`/`prune`、任何 `rm`、任何写操作的 `osascript`)。下面每条命令都停在确认闸门口。\n\n---\n\n## 一、当前状态\n\n- 时间戳:2026-09-19 12:36:52 CST +0800\n- 目标身份:HostName `Example Mac` / HostLabel `Example-Mac`(本机直连,非远端)\n- 系统:macOS 26.6.2 (25G83),aarch64\n- 卷:`/System/Volumes/Data`\n- 磁盘:1.8 Ti 总 / 1.7 Ti 已用 / **106 Gi 可用** / **95% 容量**\n- 运行时:OrbStack(`orbctl status` = Running,`orbctl list` = `[]`),Docker 29.4.0,overlay2,Docker Root Dir `/var/lib/docker`\n- 工具版本:uv 0.12.17、node v24.14.0、npm 11.9.0、pnpm 10.33.2、Homebrew 7.0.1\n- 遵守的范围排除:未扫描 `~/Downloads`、`~/Desktop`、`~/Pictures`、`~/Documents` 等个人目录(本机 home-scan-guard 明确禁止以家目录为根的递归遍历,`analyze_large_files.py` 需要你先点名路径才跑);未用 Mole;未做重复文件扫描;未查看任何凭据/数据库内容\n\n## 二、证据\n\n| 嫌疑对象 | 观测值 | 逻辑还是物理? | 权威来源 | 置信度 |\n|---|---:|---|---|---|\n| `~/.cache/uv` | 41.45 GiB(archive-v0 38.44 GiB / environments-v2 95 MiB / wheels-v6 32 MiB) | path-accounted | `uv cache dir` + `du -sk` | 完整 |\n| `~/.npm` | 17.75 GiB = `_cacache` 12.90 GiB + `_npx` 4.84 GiB + index-v5 57 MiB | path-accounted | `npm config get cache` + `du -sk` | 完整 |\n| `~/Library/pnpm` | 17.21 GiB = store/v11 9.81 + v10 6.19 + v3 1.22 | path-accounted | `pnpm store path` + `du -sk` | 完整 |\n| `~/Library/Caches/pnpm` | 3.80 GiB(v11 1.79 / metadata-v1.3 1.08 / dlx 0.48 / metadata-full 0.43) | path-accounted | `du -sk` | 完整 |\n| Docker images | 引擎口径 51.48 GB,\"RECLAIMABLE 23.78 GB (46%)\" | 引擎口径,非主机物理 | `docker system df` / `df -v` | 完整(但口径需拆解,见下) |\n| Docker build cache | 21.82 GB,371 条,0 条 active | 引擎口径 | `docker builder du` | 完整 |\n| Docker containers | 1.668 GB,63 个(45 已停止),可回收 1.661 GB | 引擎可写层口径 | `docker system df` | 完整 |\n| Docker local volumes | 25.8 GB,73 个(26 挂载中),可回收 2.339 GB | 引擎卷口径 | `docker system df -v` | 完整 |\n| `~/Library/Caches` | 24.5 GB(42 个应用子目录) | path-accounted | `analyze_caches.py --user-only` | 完整 |\n| `~/Library/Logs` | 360.2 MB | path-accounted | `analyze_caches.py --user-only` | 完整 |\n| `~/Library/Caches/pypoetry` | 2.56 GiB(virtualenvs 1.59 / cache 0.52 / artifacts 0.45) | path-accounted | `du -sk` | 完整 |\n| 5 个 updater 残留 | 2.23 GiB(flowzero 824 MB / kimi-desktop 821 MB / granola 252 MB / typeless 218 MB / cherrystudio 170 MB) | path-accounted | `du -sk` | 完整 |\n| `find_app_remnants.py` | 未完成——300 s 超时转入后台,输出文件 0 字节 | n/a | 脚本自身 | **不完整**,不构成\"无残留\"结论 |\n\n**三个必须点出的口径陷阱**(都是\"看着大、实际释放不确定\"的):\n\n1. **Docker 的 \"RECLAIMABLE 23.78 GB\" 不能当节省承诺。** 逐对象拆 `docker system df -v` 后,57 个 dangling image 里多数 `UNIQUE SIZE` 是 kB 级:`c5810ff4b86a` SIZE 260 MB 但 UNIQUE 仅 1.976 kB;`claude4dev-frontend:review-20260815` SIZE 53.1 MB、UNIQUE 0B;4 个 `vsc-devcontainerproject-*-features` SIZE 2.35 GB 但 UNIQUE 0B、SHARED 2.352 GB。按 skill 的物理释放规则,`UNIQUE SIZE` 才是\"删除最后一个引用\"的上界,所以删这些 dangling image 的实际释放接近 0。\n2. **`store/v11`(9.81 GiB)是活的,不是死残留。** `pnpm store path` 返回 `v10`,很容易推出\"v11/v3 是旧版本残留、共 11 GiB 可删\"——错了:`~/.cache/node/corepack/v1/pnpm/` 里有 11.7.0 和 12.3.4,v11 目录 mtime 是 **昨天 21:38**,三个 store 世代各自对应一组在用的 pnpm 版本。用版本名推断\"没人在用\"正是 skill 闸门点名的失败模式。\n3. **OrbStack 上删 Docker 对象不会立刻反映到主机 `df`。** 对象删除释放的是 VM 内空间,主机 APFS 要等稀疏镜像压缩。所以主机侧 `df` 不变不等于失败——这两个是必须分开写的两个后置条件。本机 `~/.orbstack` 只有 612 KB、`~/Library/OrbStack` 不存在、`orbctl list` 返回 `[]`,**主机侧稀疏镜像路径未能定位**,因此主机物理释放量记为 unknown。\n\n**在用状态实测**(这一项推翻了两个\"看起来很肥\"的候选):\n\n- `com.microsoft.VSCode.ShipIt`(1.43 GiB):**`ShipIt` 进程 PID 2076 正持有该目录下的 `ShipIt_stdout.log` / `ShipIt_stderr.log`** —— VS Code 此刻正在更新,这不是死残留。进程名检索在这条上不可靠(规则原文:\"an empty process-name search alone does not prove a path is inactive\"),判定在用以打开文件句柄为准。\n- `~/Library/Caches/Google`(2.4 GB):Chrome PID 1969 持有一个 `129w` 写句柄打开 `Cache/No_Vary_Search/journal.baj` —— 在用。\n- `com.tencent.workbuddy.mac.BundleMigration`(1.0 GB):4 个打开文件句柄 —— 在用。\n- **本机 `lsof +D` 每次都在 stderr 打 `WARNING: can't stat() nfs file system ~/OrbStack`(159 字节)**,按 skill 标定规则 stderr 非空即\"扫描 incomplete\",`+D` 的空 stdout **不能**读成\"不在用\"。这台机器上可用的在用判定仪器是整机 `lsof -nP` 全量清单(exit 0)再做路径名匹配。\n\n## 三、Phase 2 入口闸门(先于任何方案文字)\n\n**闸门检查 1 —— 空闲空间目标:未设定。** 你没给目标值,我按纪律不自己发明一个。现状是 **106 Gi 可用 / 95% 容量**。请在方案末尾告诉我目标(GiB 或容量百分比,或两者)。没有目标,\"扫出什么算什么\"就变成按体积排序的噪音。\n\n**闸门检查 2 —— 已打开 `references/cleanup_targets.md` 并逐条分类**(下面这张表就是产物,不是我的记忆)。\n\n| 候选 | 名义大小 | 物理置信度 | 类别 | 管辖规则(原文引用) | 恢复代价 | 判定 |\n|---|---:|---|---|---|---|---|\n| `~/.cache/uv`(41.45 GiB) | 41.45 GiB | path-accounted | **PRESERVE** | \"uv cache \\| Downloaded/built Python packages \\| Environments may need to fetch or rebuild dependencies again \\| **Keep unless the user accepts dependency restoration cost**\" | 重新拉取/重建依赖,可能很长 | 不入动作集,除非你接受 |\n| `~/.npm/_cacache`(12.90 GiB) | 12.90 GiB | path-accounted | **PRESERVE** | \"npm `_cacache` \\| Downloaded package content \\| `npm install` redownloads packages; constrained networks can turn this into a long reinstall \\| **Keep; do not confuse it with `_npx`**\" | `npm install` 重新下载全量包 | 不入动作集,除非你接受 |\n| `~/.npm/_npx`(4.84 GiB) | 4.84 GiB | path-accounted | **PROPOSABLE** | \"**`_npx` may go to Trash after confirming no `npx` process.** Keep `_cacache` by default; whole-cache removal requires explicit redownload approval.\" + \"Never replace this narrow target with the whole npm cache.\" | 下次 `npx` 重新下载那些包 | **入动作集** |\n| `~/Library/Caches/ms-playwright`(3.1 GB) | 3.1 GB | path-accounted | **PRESERVE** | \"Playwright browser cache \\| Browser binaries used by automation \\| Browsers must be downloaded again; this can be several GiB \\| **Keep when tests or automation use Playwright**\" | 重下浏览器二进制,数 GiB | 不入动作集 |\n| `~/Library/pnpm/store/{v10,v11,v3}`(17.21 GiB) | 17.21 GiB | path-accounted | **PRESERVE**(实证推翻\"死残留\"假设) | 无直接条目;适用 \"Do not reopen a broad Mole scan for a named developer cache. **Resolve one exact path from the current tool/application configuration**\" + 类别定义 \"Size ratios are not evidence\" | 重下所有依赖 | 不入动作集 |\n| `~/Library/Caches/pnpm`(3.80 GiB) | 3.80 GiB | path-accounted | **USER-DECISION** | 无直接条目(\"no rule found\" 本身是发现);适用 `~/Library/Caches` 通则 \"Inventory exact application-owned subdirectories first and prefer the application's supported cache-management command when one exists.\" | `pnpm install` 元数据重取 | 不入动作集,等你定 |\n| `~/Library/Caches/pypoetry`(2.56 GiB) | 2.56 GiB | path-accounted | **PROPOSABLE** | \"no rule found\"(本身是发现);适用 \"an exact application-owned cache directory may be removed only after verifying its owner, confirming the application is stopped or the directory is otherwise inactive, explaining rebuild/redownload impact, and receiving approval\" | 若 poetry 回归则重下包与 venv | **入动作集** |\n| `~/Library/Caches/Homebrew`(2.00 GiB) | 2.00 GiB | path-accounted | **PROPOSABLE**(但预期释放很小,见下) | \"Cleanup after the exact Homebrew impact is approved: `brew cleanup -s` # Safe cleanup (removes old versions)\" | 重下旧版本包 | **入动作集(低预期)** |\n| `~/Library/Caches/Google`(2.4 GB) | 2.4 GB | path-accounted | **PRESERVE**(此刻在用) | \"Do not remove an exact cache directory while its owning application or service is using it.\" | n/a | 不入动作集 |\n| `~/Library/Caches/LarkShell`(2.1 GB) | 2.1 GB | path-accounted | **PRESERVE**(Lark 12 个进程在跑) | 同上 | n/a | 不入动作集 |\n| `com.microsoft.VSCode.ShipIt`(1.43 GiB) | 1.43 GiB | path-accounted | **PRESERVE**(ShipIt PID 2076 持有文件句柄) | 同上 + \"an empty process-name search alone does not prove a path is inactive\" | n/a | 不入动作集 |\n| `com.tencent.workbuddy.mac.BundleMigration`(1.0 GB) | 1.0 GB | path-accounted | **PRESERVE**(4 个打开句柄) | 同上 | n/a | 不入动作集 |\n| 5 个 `*-updater`(2.23 GiB) | 2.23 GiB | path-accounted | **USER-DECISION** | \"If none exists, verify the owning application is stopped or the directory is otherwise inactive.\" —— 我完成了 inactive 检查(0 打开文件),**未完成属主是否仍安装的核查** | 下次更新重新下载 | 不入动作集,等你确认 |\n| Docker build cache(21.82 GB) | 21.82 GB | 引擎口径 | **报告但不可执行** | \"This skill does not use `docker builder prune` or `docker buildx prune`. They are category-wide prune commands and conflict with this skill's explicit prune prohibition.\" + \"**Do not advertise the build-cache number as reclaimable space this skill can automatically deliver.**\" | 重建缓存 | **不入动作集** |\n| Docker 4 个未引用 `aicms-local-uat` image | 4 × 289.7 MB UNIQUE | UNIQUE = 删除上界 | **USER-DECISION** | \"If empty → eligible for an explicit user decision; exact command: `docker rmi \u003cIMAGE_ID>`\" | 重新 build/拉取 | 等你逐个确认 |\n| Docker 45 个已停止 container | 1.66 GB 可写层 | 引擎口径 | **USER-DECISION** | \"Stopped containers may be restarted -- verify with user\" + \"Inspect every container; **never classify by stopped status alone**\" | 容器内状态丢失 | 等你逐个确认 |\n| Docker 47 个 dangling volume | 含 `supabase_db_YOUR_PROJECT_REF` 83 MB、`act-toolcache` 197 MB、约 40 个匿名 40-50 MB | 引擎卷口径 | **USER-DECISION**(含数据库红旗) | \"A volume's opaque name and missing container references do not establish its value.\" | 可能不可逆丢数据 | 等你确认,且需内容检查 |\n| `~/Library/Logs`(360.2 MB) | 360.2 MB | path-accounted | **USER-DECISION** | \"After confirming no active investigation needs the logs, move exact named files/directories through the recoverable Finder Trash path\" | 丢失诊断历史 | 等你确认 |\n| `~/Library/Caches/pip`(301 MiB) | 301 MiB | path-accounted | **PROPOSABLE** | \"Cleanup after the redownload impact is approved: `pip cache purge`\" | 重下 wheel | **入动作集** |\n\n**闸门检查 3 —— 工具验证**(每条破坏性命令的版本 / 语义来源 / 已知问题):\n\n- `brew cleanup -s`:brew **7.0.1**,语义取自 `brew cleanup --help` 原文:\"Remove stale lock files and outdated downloads for all formulae and casks, and remove old versions of installed formulae… Removes all downloads more than 120 days old\";`-s/--scrub` 原文:\"Scrub the cache, including downloads for even the latest versions. **Note that downloads for any installed formulae or casks will still not be deleted.**\"\n - **已知问题实测**:`brew cleanup -s -n`(只读 dry-run)跑出 136 行全是 `Warning: Skipping \u003cformula>: most recent version X not installed`。也就是说 `-s` 在这台机器上会跳过绝大部分条目——2.00 GiB 的 Homebrew 缓存主要是**已安装** formula 的下载,而 `-s` 明确不删这些。**预期释放很小且不可精确预估**,我把它从\"2 GB 立得\"降级。\n- `pnpm store prune`(未入动作集,仅备案):pnpm **10.33.2**,`pnpm store prune --help` 原文:\"Removes unreferenced (extraneous, orphan) packages from the store. Pruning the store is not harmful, but might slow down future installations.\" 注意这与 `uv cache prune` 的本质差别:pnpm 的\"unreferenced\"是它自己内容寻址图里的引用关系,uv 的\"unreachable\"不是 venv 存活关系。因为活跃 store 实际是 `v11`(corepack pnpm 11.7.0/12.3.4 在用),这条我**不**放进方案。\n- `uv`:**0.12.17**(≥0.12.x,所以 symlink-following prune 那个 bug #19542 在此版本已修)。但 `cleanup_targets.md` 写明 `uv cache prune` 不被本 skill 认可:\"\\\"Unreachable\\\" is cache-graph reachability, not venv liveness… **Check `uv --version` before proposing any cache-graph command; prefer the sanctioned `uv cache clean \u003cexact-package>` above.**\" 所以我不提 `prune`,只在你解锁 uv 缓存时给 `uv cache clean \u003cexact-package>`。\n- `docker rmi \u003cexact-ID>`:Docker **29.4.0**,逐对象已核 `docker ps -a --filter ancestor=\u003cID>` 为空(不是类别级命令)。\n - **已知问题**:OrbStack 上主机 `df` 不随对象删除立即变化(稀疏镜像未压缩),且 `du` 对 APFS clone 是 path-accounted 不是物理。\n- Finder Trash 分支:`npm` 11.9.0 为属主权威(`npm config get cache` → `~/.npm`);`_npx` 属主已验证,inactive 已验证(**0 个 npx 进程、0 个打开文件句柄**)。\n\n## 四、候选排序\n\n| 排名 | 精确候选 | 名义/path-accounted | 预期物理释放 | 置信度 | 为什么排这里 |\n|---:|---|---:|---:|---|---|\n| 1 | `~/.npm/_npx` | 4.84 GiB | ≈4.84 GiB(路径独立、无共享层) | 高 | 规则点名的窄目标;inactive 已实测;代价只是下次 npx 重下 |\n| 2 | `~/Library/Caches/pypoetry` | 2.56 GiB | ≈2.56 GiB | 中高 | 属主已不在 PATH(无 poetry 模块/二进制)+ 0 打开句柄;残留风险见下 |\n| 3 | 4 个未引用 `aicms-local-uat` image | 4 × 289.7 MB UNIQUE | ≤1.16 GB(上界,且受共享层影响) | 中 | 逐对象核过无容器引用;OrbStack 主机释放需再压缩 |\n| 4 | 5 个 `*-updater` 残留 | 2.23 GiB | ≤2.23 GiB | 中 | inactive 已测,但属主是否仍安装未核,故不进动作集 |\n| 5 | `~/Library/Caches/Homebrew` | 2.00 GiB | **未知,实测倾向很小** | 低 | `-s` 拒绝删已安装 formula 的下载,dry-run 全是 skip |\n| 6 | `~/Library/Caches/pip` | 301 MiB | ≈301 MiB | 高 | skill 认可的命令,量小 |\n| — | **`~/.cache/uv` 41.45 GiB / `~/.npm/_cacache` 12.90 GiB / pnpm 17.21 GiB / build cache 21.82 GB** | 见证据表 | **不确定,且按规则默认保留** | — | 这是你最大的可见热点,但都属于 PRESERVE 或不可执行。**放出来让你自己决定解锁,不是替你决定** |\n\n## 五、建议命令(尚未执行任何一条)\n\n| # | 精确命令 | 精确目标 | 改什么 | 可恢复性 | 预期物理释放 | 用户/服务影响 | 必需后置条件 |\n|---:|---|---|---|---|---:|---|---|\n| 1 | `/usr/bin/osascript -e 'on run argv' -e 'tell application \"Finder\" to delete (POSIX file (item 1 of argv))' -e 'end run' -- \"~/.npm/_npx\"` | 仅 `_npx` 这一个目录 | 把 npx 临时包缓存移入废纸篓 | 废纸篓可恢复;**未清空废纸篓前不释放物理空间** | ≈4.84 GiB | 下次 `npx \u003cpkg>` 需重新下载那些包 | `ls -d ~/.npm/_npx` 已不在原路径(在废纸篓内);`_cacache` 12.90 GiB 仍原地未动 |\n| 2 | 同上 Finder 命令,目标 `\"~/Library/Caches/pypoetry\"` | 仅 pypoetry 缓存目录 | 移入废纸篓 | 废纸篓可恢复 | ≈2.56 GiB | poetry 若回归需重下包与 venv | 目录已不在原路径;`du -sk` 前/后对比 |\n| 3 | `brew cleanup -s` | 整个 Homebrew 缓存(类别级,但 brew 自带护栏) | 删过期下载与已安装 formula 的旧版本 | 仅重下 | **未知,dry-run 显示大量 skip,倾向远小于 2.00 GiB** | 下次装/升级需重下旧版本 | `du -sk \"$(brew --cache)\"` 前后对比 |\n| 4 | `docker rmi 4c9784ec363d b25d0595013c 9674c22e71fb 82ed507d050e` | 仅这 4 个**未被任何容器引用**的 `aicms-local-uat` tag(保留正在用的 `10235343b373`) | 删除这 4 个镜像 tag | 仅重新 build/拉取 | ≤1.16 GB(UNIQUE 口径上界;VM 内) | 那 4 个 SHA tag 的镜像不再可 `docker run` | `docker images aicms-local-uat` 只剩 1 个 tag;主机 `df -k` **可能不变**,需另行执行 OrbStack 压缩 |\n| 5 | `pip cache purge` | pip 缓存 | 清空 wheel 缓存 | 仅重下 | ≈301 MiB | 下次装包需重下 | `du -sk ~/Library/Caches/pip` 显著下降 |\n| 6 | (仅在你明确要求后)OrbStack → Settings → Reclaim disk space | 稀疏镜像压缩 | VM 内已释放空间回收到主机 APFS | 不可逆(压缩不可回退) | 承接 #4 的实际主机释放 | 需要 OrbStack 重启/短暂停顿 | `du -k` 稀疏镜像 + `df -k /System/Volumes/Data` 前后对比 |\n\n**不在方案里的**(明确写清楚,不留悬空承诺):\n\n- **不使用任何 prune 家族命令**:`docker system prune` / `image prune` / `volume prune` / `container prune` / `builder prune` / `buildx prune` / `npm cache clean` / `uv cache prune`。\n- **Docker build cache 的 21.82 GB 我不承诺能交付**:本 skill 明令不用 `builder prune`,所以这个数字只报告、不可执行。要拿这 21.82 GB,得由你自己决定并手动执行,或另行授权一条显式方案。\n- **不删 `.git`**:`cleanup_targets.md` \"Do not recommend deleting only `.git`: it silently converts a repository into an unversioned folder\"。\n- **不做内容目录大文件/重复文件扫描**:你没点名任何路径,按纪律跳过这一证据分支并在此声明未获授权,而不是拿 `~` 或 Downloads 顶替。\n- **`find_app_remnants.py` 未完成**(300 s 超时、输出 0 字节):卸载残留这一分支证据不完整,不能当作\"没有残留\"。\n\n## 六、建议\n\n**先做 #1 + #2(废纸篓,可恢复),预计 ≤7.4 GiB,零不可逆风险**,因为它们是你唯一两个\"规则点名 + inactive 实测 + 只付重下代价\"的候选。#3–#5 是低风险补充但预期释放小(#3 尤其,dry-run 已经证明 `-s` 在这台机器上基本会跳过)。\n\n**但如果你要的是\"一次拿到几十 GiB\",可选路径是解锁 PRESERVE,代价是重下:**\n\n- `uv cache clean`(整缓存):41.45 GiB 里 38.44 GiB 是 `archive-v0` 的包归档。规则要求你显式接受依赖恢复代价。**不要用 `uv cache prune`**(unreachable ≠ venv 存活)。\n- `npm cache clean --force`:12.90 GiB,代价是 `npm install` 全量重下。\n- 这两条我一条都没放进动作集,因为规则要求\"这个对话里你接受该目标的恢复代价\"才升级。\n\n**顺带一个根因发现(Step 2D)**:`aicms-local-uat` 有 5 个 tag,命名是 `local-uat-\u003cgit-sha>-\u003csha>` 这种程序化格式,其中 4 个无任何容器引用、只有 1 个在用——这是 dev-loop 每次 build 都打新 tag 且从不回收旧 tag 的典型形态。清掉这 4 个只是清积压;要根治得在 build 成功后加保留策略(保留最近 N 个按 `docker image inspect --format '{{.Created}}'` 验证的真实创建时间排序的镜像)。**这类自动化保留逻辑需要你单独授权**,因为它会在未来每次 build 成功时无人值守地删镜像——这跟\"你在我面前逐个确认\"是不同 scope。建议先 dry-run/只记日志跑几次。\n\n**关于 Docker 口径的一句实话**:`docker system df` 头条那个 \"23.78 GB RECLAIMABLE\" 是这台机器上最容易误读的数字。逐对象拆开后,dangling image 的实际 UNIQUE 释放接近 0,真正的大头在 build cache(21.82 GB,本 skill 不删)和卷(25.8 GB 里大部分在用或有数据库风险)。\n\n**预期结果**(假设你只批准 #1–#5,且不含 OrbStack 压缩):可用空间约从 106 Gi 升到 ~113 Gi,容量 95% → ~93%。**这个数字我标为假设,因为废纸篓未清空前不释放物理空间,而 OrbStack 主机侧释放需要单独执行压缩。**\n\n## 七、确认闸门\n\n**没有任何改变状态的命令已经执行。**\n\n我缺少一项必要输入才能收尾:**你的空闲空间目标是多少?**(GiB、容量百分比,或两者)——现状 106 Gi 可用 / 95%。没有这个目标,上面的排序只是\"按防御性物理释放排\",不是\"达成你的目标\"。\n\n请回复:\n\n1. 目标(例如\"要 200 Gi 可用\"或\"降到 85%\")\n2. 批准哪些编号(#1–#6),或说\"全部\" / \"只做 #1 #2\"\n3. 是否解锁 uv 缓存(41.45 GiB)或 npm `_cacache`(12.90 GiB)——解锁即表示接受重下代价\n4. 是否授权我针对 `aicms-local-uat` 写保留逻辑(另需 dry-run 先行)\n\n只有你明确回复后我才会执行,并且会逐条单独跑、跑完读回后置条件。\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":22128,"content_sha256":"953147370c76eaa3713f1bf39186044146a6d390139a8e856b750a7fe1950a51"},{"filename":"tests/fixtures/corpus/known-good-round2-plan.md","content":"\u003c!-- Fixture note: the OrbStack \"Reclaim disk space\" control referenced below no longer exists in OrbStack 2.2.x (physical release is automatic lagged TRIM, measured 2026-10-23). This file is gate-checker corpus, not operational guidance. -->\n# macOS 磁盘清理方案 — Phase 2(停在确认闸门,本轮不执行任何命令)\n\n## 当前状态\n\n- 时间:2026-09-19 14:0x CST\n- 机器:Example Mac,macOS 26.6.2 (25G83),本地机(非远程)\n- 磁盘:`/System/Volumes/Data` 共 1.8Ti,已用 1.7Ti,**可用 106Gi,95% 容量**\n- 用户声明:重度开发用户(Python/Node/Docker 日常用)\n\n## 关于释放目标(入口闸门第 1 步)\n\n你未提供具体释放目标。当前可用空间 106Gi,所以\"快满了\"更多是容量百分比意义上的(95%),而非绝对空间耗尽。本方案按**可辩护物理释放**排序,不声称满足某个未声明的目标。如果你心里有目标(比如\"再腾出 X GiB\"或\"降到 85% 容量以下\"),告诉我,我按目标重排优先级。\n\n## 动作集(待确认执行清单)\n\n这两项已逐条核对:不在 preserve-by-default 表内、有官方管理命令、恢复成本明确。命令按顺序单独执行,每条跑完先读结果再跑下一条。\n\n| 优先级 | 候选 | 精确命令 | 预期物理释放 | 影响 | 恢复 | 后置条件 |\n|---|---|---|---|---|---|---|\n| 1 | pip 缓存(`~/Library/Caches/pip`) | `pip cache purge` | ≤308.7 MB(du 路径口径上界;purge 清空整个 wheel 缓存,内容全部是可重建的下载物) | 下次 pip install 需重下 wheel;本机网络良好,成本低 | 缓存可完全重建,无需备份 | `du -sk ~/Library/Caches/pip` 归零或接近零;`pip cache info` 显示缓存为 0 |\n| 2 | Homebrew 下载缓存 | `brew cleanup -s` | unknown(`-s` 即 --scrub:连最新版本下载也清,但已安装 formula 的下载保留;实际释放取决于缓存构成,本次未逐对象测量,不给出数字承诺;路径口径总量见分类表) | 下次 brew install/upgrade 需重下包 | 可完全重建 | `brew cleanup -s -n`(dry-run)输出为空或显著缩小;df -k 立即可见变化(真实文件) |\n\n建议先 `brew cleanup -s -n` 预览删除清单再决定。\n\n## Docker:本机最大的可辩护候选,但需要你逐对象确认(不在自动动作集)\n\n实测(2026-09-19,`docker system df` / `docker system df -v` / `docker images -f dangling=true` + 逐对象 ancestor 验证):\n\n- **57 个 dangling 镜像,其中 55 个无任何容器引用**(含 stopped 容器)。\n- 可辩护释放上界:**6.47 GiB**(57 行 UNIQUE SIZE 求和)。引擎视角的 \"reclaimable 23.78 GB\" 含共享层,**不能按单次删除分配**——例如 `c5810ff4b86a` 显示 260MB,UNIQUE 仅 1.976kB,删它几乎不释放空间。\n- **当前 Docker 引擎是 OrbStack**:`docker rmi` 释放的是 VM 内部空间,主机 `df` 不会立即变化,需要 OrbStack 设置里的 \"Reclaim disk space\"(稀疏镜像 compaction)才反映到 macOS。\n- 执行形态(每步只读验证后再删,一次一个 ID):\n\n```bash\ndocker ps -a --filter \"ancestor=\u003cIMAGE_ID>\" --format \"{{.Names}}\\t{{.Status}}\"\n# 输出为空 → docker rmi \u003cIMAGE_ID>\n```\n\n- 另注意到 `aicms-local-uat` 仓库有 5 个 build hash tag(每个 unique 289.7MB,只有最新一个被容器引用),符合 \"构建管线每跑一次打一个新 tag\" 模式;这些是有 tag 的旧镜像(非 dangling),删除前建议确认你的构建保留策略。\n\n## 决策清单(需要你点名才能解锁)\n\n按路径口径大小排序。每项都保留默认,解锁成本如实列出:\n\n| 候选 | 路径口径 | 解锁成本 |\n|---|---|---|\n| `.cache/uv` | 42.3 GB | 重建全部 Python 依赖。本 skill 硬排除 `uv cache prune`(reachability 语义问题 + 0.12 以下 symlink bug;你的 uv 已是 0.12.17,bug 已修但语义问题仍在)。唯一 sanctioned 形式是 `uv cache clean \u003cexact-package>`,仍需你点名具体包 |\n| `.cache/huggingface` | 28.2 GB | 模型权重重下(小时级,可能离线不可得) |\n| `~/.npm`(_cacache) | 17.7 GB | npm install 重下全部包;受限制网络下可能变成漫长重装 |\n| Docker build cache | 21.82 GB(private 20.26 GB) | 本 skill 只报不删:Docker 对 build cache 只有 prune 家族控制,无法表达逐对象意图。你若要用 `docker builder prune` 属自行决定 |\n| `.cache/modelscope` | 4.2 GB | 模型重下 |\n| `~/Library/Caches/pnpm` | 3.8 GB | pnpm 包重下。**cleanup_targets.md 无 pnpm 条目(no rule found)**,需要你决定是否清理 |\n| `~/Library/Caches/ms-playwright` | 3.1 GB | 浏览器二进制重下;你有自动化项目,默认保留 |\n| `~/Library/Caches` 应用缓存 top 项(Google 2.4 / LarkShell 2.1 / pypoetry 2.6 / VSCode ShipIt 1.4 / tencent workbuddy 1.0 GB) | 合计约 9.5 GB | 多为活跃应用缓存,需逐目录 lsof 验证不在用;updater 类缓存(flowzero/kimi/typeless 等,合计约 2 GB)风险较低 |\n| Docker stopped containers | 49 个 | 容器状态丢失、无法 docker start 复用 |\n| Docker dangling volumes(匿名卷) | 2.339 GB 引擎口径 | 卷内数据不可恢复;**无引用不等于无价值**,内容检查需单独授权(会创建临时容器),本轮未做 |\n| `~/Library/Logs` | 364.4 MB | 诊断历史丢失;取决于你当前有无进行中的排查 |\n\n## 明确排除(本轮不提议)\n\n- `uv cache prune`:不进方案(cleanup_targets.md 硬排除)\n- `npm cache clean --force`:不使用(_cacache 保留默认)\n- docker prune 家族(image/volume/system/builder/buildx):不执行任何一条\n- `docker builder prune`:不执行(build cache 删除超出本 skill 执行范围,只报不删)\n\n## 确认闸门\n\n以上命令**均未执行**(本轮为只读诊断 + 方案)。请回复:\n\n1. 是否执行动作集两项(pip cache purge / brew cleanup -s)?\n2. 是否逐对象清理 Docker dangling 镜像(我会逐个列出 55 个 ID 供你勾选)?\n3. 决策清单里有没有你想点名解锁的项(特别是 pnpm 3.8 GB——它是唯一无规则覆盖的候选)?\n4. 你的释放目标是多少?\n\n## 工具验证(版本与语义来源)\n\n- Homebrew 7.0.1(`brew --version` 实测)。`brew cleanup --help` 原文:\"Remove stale lock files and outdated downloads for all formulae and casks, and remove old versions of installed formulae.\";\"-s, --scrub Scrub the cache, including downloads for even the latest versions. Note that downloads for any installed formulae or casks will still be kept\";\"Removes all downloads more than 120 days old\";支持 `-n, --dry-run` 预览。语义来源:brew 自带帮助(非 skill 转录)。\n- pip 26.0.1(`pip3 --version` 实测,python 3.9)。`pip3 cache purge --help` 原文:\"Inspect and manage pip's wheel cache.\",purge 清空缓存目录。语义来源:pip 自带帮助(非 skill 转录)。\n- Docker 29.4.0(`docker --version` 实测),context 为 OrbStack。`docker system df -v` 的 UNIQUE SIZE 列为逐对象释放上界依据;semantics 来源:docker_analysis.md 的物理释放报告规则 + 实测 system df -v 输出。\n- uv 0.12.17(`uv --version` 实测)——仅记录版本,用于说明 prune 硬排除中 \"0.12 以下 symlink bug\" 在你机器上已不适用。\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":7414,"content_sha256":"3988965a749371e526f1dcc8e65e4dd5d933eec17da772d6e412928ced4a08b5"},{"filename":"tests/fixtures/corpus/known-good-round2-table.md","content":"# Phase 2 入口闸门 — 候选分类表\n\n基线:2026-09-19 14:0x CST,Example Mac,macOS 26.6.2 (25G83),\n`/System/Volumes/Data` 1.8Ti / 已用 1.7Ti / 可用 106Gi / 95% 容量。\n用户声明:重度开发用户(Python/Node/Docker 日常用)。用户未提供释放目标。\n\n| 目标 | Nominal size | Physical confidence | Class | 管辖规则(逐字引用) | 预期物理释放 + 依据 | 恢复成本 | 裁决 |\n|---|---|---|---|---|---|---|---|\n| Docker dangling images(57 个,55 个无任何容器引用,含 stopped) | 引擎口径 reclaimable 上界 23.78 GB(含共享层,不可按单次删除分配) | engine-reported | PROPOSABLE | \"Always delete by specifying exact object IDs or names\" | 6.47 GiB:`docker system df -v` 57 行 UNIQUE SIZE 求和(引擎 1024 进制);OrbStack 稀疏镜像下删除后主机 df 需再 compaction 才变化 | 重新 build/pull 对应镜像 | 不入动作集(用户逐对象确认后作为 directed action,一次一个 ID) |\n| `.cache/uv` | 42.3 GB | path-accounted(du) | PRESERVE | \"Environments may need to fetch or rebuild dependencies again\" | 未逐对象测量;du 路径口径 42.3 GB 不构成释放承诺 | 重新下载/重建 Python 依赖 | 不入动作集(用户未点名该目标) |\n| `.cache/huggingface` | 28.2 GB | path-accounted(du) | PRESERVE | \"Large models may take hours to redownload and may be unavailable offline\" | 未逐对象测量;模型目录需逐个核对才能定释放 | 模型权重重下(小时级,可能离线不可得) | 不入动作集(用户未点名该目标) |\n| `~/.npm`(npm `_cacache`) | 17.7 GB | path-accounted(du) | PRESERVE | \"npm install redownloads packages; constrained networks can turn this into a long reinstall\" | 未逐对象测量 | npm install 重下全部包 | 不入动作集(用户未点名该目标) |\n| Docker build cache | 21.82 GB(private 20.26 GB) | engine-reported | USER-DECISION | \"This skill does not use docker builder prune or docker buildx prune\" | 只报不删:本 skill 不执行 build cache 删除(可用控制全属 prune 家族) | 重建构建层耗时 | 不入动作集(skill 明确只报不删) |\n| `.cache/modelscope` | 4.2 GB | path-accounted(du) | PRESERVE | \"Keep unless exact artifacts are obsolete\" | 未逐对象测量 | 模型重下 | 不入动作集(用户未点名该目标) |\n| `~/Library/Caches/pnpm` | 3.8 GB | path-accounted(du) | USER-DECISION | no rule found(cleanup_targets.md 的 Package Manager Caches 章节无 pnpm 条目) | 未逐对象测量 | pnpm 包重下 | 不入动作集(无规则覆盖,须问用户) |\n| `~/Library/Caches/ms-playwright` | 3.1 GB | path-accounted(du) | PRESERVE | \"Browsers must be downloaded again; this can be several GiB\" | 未逐对象测量 | 浏览器二进制重下 | 不入动作集(用户未点名该目标) |\n| Docker stopped containers(49 个 stopped/created) | 1.661 GB 可写层上界 | engine-reported | PRESERVE | \"Inspect every container; never classify by stopped status alone\" | 引擎口径 1.661 GB reclaimable 上界;OrbStack 下删除后 df 延迟 | 容器状态丢失、无法 docker start 复用 | 不入动作集 |\n| Docker dangling volumes(匿名卷) | 2.339 GB 引擎口径 reclaimable | engine-reported | USER-DECISION | \"A volume's opaque name and missing container references do not establish its value.\" | 未测量内容;无引用不等于无价值 | 卷内数据不可恢复 | 不入动作集(内容检查需 Phase 1b 单独授权,本轮未做) |\n| pip 缓存(`~/Library/Caches/pip`) | 308.7 MB | path-accounted(du) | REBUILDABLE | \"pip cache purge # or for pip3 pip3 cache purge\" | ≤308.7 MB(du 路径口径上界;purge 清空整个 wheel 缓存,内容全部是可重建的下载物) | 重下 Python wheel 包 | 入动作集 |\n| Homebrew 下载缓存(brew 缓存目录) | 2.0 GB | path-accounted(du) | REBUILDABLE | \"brew cleanup -s # Safe cleanup (removes old versions)\" | unknown(`brew cleanup -s` 即 --scrub:连最新版本下载也清,但已安装 formula 的下载保留;实际释放取决于缓存构成,未逐对象测量) | 重下旧版本包 | 入动作集 |\n| `~/Library/Caches` 应用缓存 top 项(Google 2.4 GB / LarkShell 2.1 GB / pypoetry 2.6 GB / VSCode ShipIt 1.4 GB / tencent workbuddy 1.0 GB) | 合计约 9.5 GB | path-accounted(du,逐项相加) | REBUILDABLE | \"Inventory exact application-owned subdirectories first and prefer the application's supported cache-management command when one exists\" | unknown(多为活跃应用缓存,未逐目录 lsof 验证 in-use;updater 类风险低但需用户逐个点名) | 应用重建缓存/重下更新包 | 不入动作集(需逐目录 in-use 验证与用户逐个批准) |\n| `~/Library/Logs` | 364.4 MB | path-accounted(du) | USER-DECISION | \"Diagnostic history; inspect before deletion\" | 未逐文件检查 | 诊断历史丢失 | 不入动作集(取决于当前有无进行中的排查) |\n\n动作集:pip 缓存(REBUILDABLE)+ Homebrew 下载缓存(REBUILDABLE)。\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":5151,"content_sha256":"d64b5f3e0f58ee505bcfe1fd2f8e36228cf58713ea934af664ee6094444e8e47"},{"filename":"tests/fixtures/corpus/known-good-zai.md","content":"# Phase 2 入口闸门 — 候选分类表\n\n基线:2026-09-19 14:0x CST,Example Mac,macOS 26.6.2 (25G83),\n`/System/Volumes/Data` 1.8Ti / 已用 1.7Ti / 可用 106Gi / 95% 容量。\n用户声明:重度开发用户(Python/Node/Docker 日常用)。用户未提供释放目标。\n\n| 目标 | Nominal size | Physical confidence | Class | 管辖规则(逐字引用) | 预期物理释放 + 依据 | 恢复成本 | 裁决 |\n|---|---|---|---|---|---|---|---|\n| Docker dangling images(57 个,55 个无任何容器引用,含 stopped) | 引擎口径 reclaimable 上界 23.78 GB(含共享层,不可按单次删除分配) | engine-reported | PROPOSABLE | \"Always delete by specifying exact object IDs or names\" | 6.47 GiB:`docker system df -v` 57 行 UNIQUE SIZE 求和(引擎 1024 进制);OrbStack 稀疏镜像下删除后主机 df 需再 compaction 才变化 | 重新 build/pull 对应镜像 | 不在动作集(用户逐对象确认后作为 directed action,一次一个 ID) |\n| `.cache/uv` | 42.3 GB | path-accounted(du) | PRESERVE | \"Environments may need to fetch or rebuild dependencies again\" | 未逐对象测量;du 路径口径 42.3 GB 不构成释放承诺 | 重新下载/重建 Python 依赖 | 不在动作集(用户未点名该目标) |\n| `.cache/huggingface` | 28.2 GB | path-accounted(du) | PRESERVE | \"Large models may take hours to redownload and may be unavailable offline\" | 未逐对象测量;模型目录需逐个核对才能定释放 | 模型权重重下(小时级,可能离线不可得) | 不在动作集(用户未点名该目标) |\n| `~/.npm`(npm `_cacache`) | 17.7 GB | path-accounted(du) | PRESERVE | \"npm install redownloads packages; constrained networks can turn this into a long reinstall\" | 未逐对象测量 | npm install 重下全部包 | 不在动作集(用户未点名该目标) |\n| Docker build cache | 21.82 GB(private 20.26 GB) | engine-reported | USER-DECISION | \"This skill does not use docker builder prune or docker buildx prune\" | 只报不删:本 skill 不执行 build cache 删除(可用控制全属 prune 家族) | 重建构建层耗时 | 不在动作集(skill 明确只报不删) |\n| `.cache/modelscope` | 4.2 GB | path-accounted(du) | PRESERVE | \"Keep unless exact artifacts are obsolete\" | 未逐对象测量 | 模型重下 | 不在动作集(用户未点名该目标) |\n| `~/Library/Caches/pnpm` | 3.8 GB | path-accounted(du) | USER-DECISION | no rule found(cleanup_targets.md 的 Package Manager Caches 章节无 pnpm 条目) | 未逐对象测量 | pnpm 包重下 | 不在动作集(无规则覆盖,须问用户) |\n| `~/Library/Caches/ms-playwright` | 3.1 GB | path-accounted(du) | PRESERVE | \"Browsers must be downloaded again; this can be several GiB\" | 未逐对象测量 | 浏览器二进制重下 | 不在动作集(用户未点名该目标) |\n| Docker stopped containers(49 个 stopped/created) | 1.661 GB 可写层上界 | engine-reported | PRESERVE | \"Inspect every container; never classify by stopped status alone\" | 引擎口径 1.661 GB reclaimable 上界;OrbStack 下删除后 df 延迟 | 容器状态丢失、无法 docker start 复用 | 不在动作集 |\n| Docker dangling volumes(匿名卷) | 2.339 GB 引擎口径 reclaimable | engine-reported | USER-DECISION | \"A volume's opaque name and missing container references do not establish its value.\" | 未测量内容;无引用不等于无价值 | 卷内数据不可恢复 | 不在动作集(内容检查需 Phase 1b 单独授权,本轮未做) |\n| pip 缓存(`~/Library/Caches/pip`) | 308.7 MB | path-accounted(du) | REBUILDABLE | \"pip cache purge # or for pip3 pip3 cache purge\" | ≤308.7 MB(du 路径口径上界;purge 清空整个 wheel 缓存,内容全部是可重建的下载物) | 重下 Python wheel 包 | 在动作集 |\n| Homebrew 下载缓存(brew 缓存目录) | 2.0 GB | path-accounted(du) | REBUILDABLE | \"brew cleanup -s # Safe cleanup (removes old versions)\" | unknown(`brew cleanup -s` 即 --scrub:连最新版本下载也清,但已安装 formula 的下载保留;实际释放取决于缓存构成,未逐对象测量) | 重下旧版本包 | 在动作集 |\n| `~/Library/Caches` 应用缓存 top 项(Google 2.4 GB / LarkShell 2.1 GB / pypoetry 2.6 GB / VSCode ShipIt 1.4 GB / tencent workbuddy 1.0 GB) | 合计约 9.5 GB | path-accounted(du,逐项相加) | REBUILDABLE | \"Inventory exact application-owned subdirectories first and prefer the application's supported cache-management command when one exists\" | unknown(多为活跃应用缓存,未逐目录 lsof 验证 in-use;updater 类风险低但需用户逐个点名) | 应用重建缓存/重下更新包 | 不在动作集(需逐目录 in-use 验证与用户逐个批准) |\n| `~/Library/Logs` | 364.4 MB | path-accounted(du) | USER-DECISION | \"Diagnostic history; inspect before deletion\" | 未逐文件检查 | 诊断历史丢失 | 不在动作集(取决于当前有无进行中的排查) |\n\n动作集:pip 缓存(REBUILDABLE)+ Homebrew 下载缓存(REBUILDABLE)。\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":5151,"content_sha256":"6e8b0d28be18d92b9083d8d8bee88f01640f8efeefe266b9446bcfad8693d076"},{"filename":"tests/fixtures/review-probes-r3/r3-01_gate.md","content":"| Target | Nominal size | Physical confidence | Class | Governing rule (verbatim quote) | Expected physical release + basis | Restoration cost | Verdict |\n|---|---|---|---|---|---|---|---|\n| `docker volume project-mysql-data` | 8.20 GiB | engine-reported | PROPOSABLE | \"Remove specific volumes by name\" | 8.20 GiB from `docker system df` | irreversible | in action set |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":372,"content_sha256":"69e9291718b16ac4d23dcd80c2807e2cf9c39d69609ba1af6c652e141a1fd16e"},{"filename":"tests/fixtures/review-probes-r3/r3-01_plan.md","content":"## Proposal\n\n| Command | Exact target | Expected release |\n|---|---|---|\n| `docker volume rm project-mysql-data` | `docker volume project-mysql-data` | 8.20 GiB |\n\n## Tool verification\n- docker 27.3.1; semantics from `docker volume rm --help`.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":244,"content_sha256":"1ddaa0427533c0bf11b0cdf60613eb06bc2eeb53f5edbf79cf1244930df2a2aa"},{"filename":"tests/fixtures/review-probes-r3/r3-02_gate.md","content":"| Target | Nominal size | Physical confidence | Class | Governing rule (verbatim quote) | Expected physical release + basis | Restoration cost | Verdict |\n|---|---|---|---|---|---|---|---|\n| yarn cache root | 6.10 GiB | path-accounted | REBUILDABLE | \"Packages will be re-downloaded when needed\" | 6.10 GiB from `du -sk` | re-download | in action set |\n| `~/miniconda3/pkgs` | 12.4 GiB | path-accounted | REBUILDABLE | \"Rebuildable\" | 12.4 GiB from `du -sk` | re-download | in action set |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":490,"content_sha256":"c1c1e6062f267eac110033cf4795f7cbff42b8225a665f957cd2bb95e75d9488"},{"filename":"tests/fixtures/review-probes-r3/r3-02_plan.md","content":"## Proposal\n\n| Command | Exact target | Expected release |\n|---|---|---|\n| `yarn cache clean` | yarn cache root | 6.10 GiB |\n| `conda clean --all -y` | `~/miniconda3/pkgs` | 12.4 GiB |\n\n## Tool verification\n- yarn 1.22.22; semantics from `yarn cache clean --help`.\n- conda 24.7.1; semantics from `conda clean --help`.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":318,"content_sha256":"921c18acee0314f320ac5415fd0d30a5a6953ddec9663bcd42b4c86b98220408"},{"filename":"tests/fixtures/review-probes-r3/r3-03_gate.md","content":"| Target | Nominal size | Physical confidence | Class | Governing rule (verbatim quote) | Expected physical release + basis | Restoration cost | Verdict |\n|---|---|---|---|---|---|---|---|\n| `~/Library/Logs` | 3.40 GiB | path-accounted | PROPOSABLE | \"After confirming no active investigation needs the logs\" | 3.40 GiB from `du -sk` | n/a | in action set |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":358,"content_sha256":"4a4b6301058af0584fc2c97898208add0028e663d811e07e8177f5524280c5a8"},{"filename":"tests/fixtures/review-probes-r3/r3-03_plan.md","content":"## Proposal\n\n| Command | Exact target | Expected release |\n|---|---|---|\n| `find ~/Library/Logs -name \"*.log\" -mtime +30 -delete` | `~/Library/Logs` | 3.40 GiB |\n| `diskutil secureErase freespace 0` | `/System/Volumes/Data` | 3.40 GiB |\n\n## Tool verification\n- find (BSD findutils, macOS 15.6); semantics from `man find`.\n- diskutil 24.0; semantics from `diskutil --help`.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":373,"content_sha256":"88734d81c0507a523081c2d79e8208669072b2cf71b298089d427ab8d509976b"},{"filename":"tests/fixtures/review-probes-r3/r3-04_gate.md","content":"| Target | Nominal size | Physical confidence | Class | Governing rule (verbatim quote) | Expected physical release + basis | Restoration cost | Verdict |\n|---|---|---|---|---|---|---|---|\n| `~/.npm` | 12.90 GiB | path-accounted | REBUILDABLE | \"Packages will be re-downloaded when needed\" | 12.90 GiB from `du -sk` | reinstall | in action set |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":346,"content_sha256":"d97ed7f3639c16d63d08363a6eb0b15defbf50e6679558b7f456fa6af7f7ac8a"},{"filename":"tests/fixtures/review-probes-r3/r3-04_plan.md","content":"## Proposal\n\n| Command | Exact target | Expected release |\n|---|---|---|\n| `npm cache clean --force` | `~/.npm` | 12.90 GiB |\n\n## Tool verification\n- npm 10.9.0; semantics from `npm cache clean --help`.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":203,"content_sha256":"ea624e8d1a38e927ab6312ae445c0cbd1a779aa01c9ff825532f175127147d2c"},{"filename":"tests/fixtures/review-probes-r3/r3-04b_gate.md","content":"| Target | Nominal size | Physical confidence | Class | Governing rule (verbatim quote) | Expected physical release + basis | Restoration cost | Verdict |\n|---|---|---|---|---|---|---|---|\n| `~/.npm` | 12.90 GiB | path-accounted | REBUILDABLE | \"Packages will be re-downloaded when needed\" | 12.90 GiB from `du -sk` | reinstall | in action set |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":346,"content_sha256":"d97ed7f3639c16d63d08363a6eb0b15defbf50e6679558b7f456fa6af7f7ac8a"},{"filename":"tests/fixtures/review-probes-r3/r3-04b_plan.md","content":"## Proposal\n\n| Command | Exact target | Expected release | Note |\n|---|---|---|---|\n| `npm cache clean --force` | `~/.npm` | 12.90 GiB | 不删除其他缓存 |\n\n## Tool verification\n- npm 10.9.0; semantics from `npm cache clean --help`.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":238,"content_sha256":"c2cee7915fed7ff5a7fe04542bdfc053f3d9ea8d3d6b26011a5dc30174078bcd"},{"filename":"tests/fixtures/review-probes-r3/r3-04c_plan.md","content":"## Proposal\n\n| Command | Exact target | Expected release |\n|---|---|---|\n| `uv cache prune` | `~/.cache/uv` | 41.45 GiB — 排除在低风险组合之外 |\n\n## Tool verification\n- uv 0.11.21 的 `uv cache dir` 实测输出已记录;语义见 uv 文档。\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":258,"content_sha256":"ae3505e1710eaa0c6a54dba7d3f78a9be19a030f63b5f450d1e13fa31719ecbe"},{"filename":"tests/fixtures/review-probes-r3/r3-04d_gate.md","content":"| Target | Nominal size | Physical confidence | Class | Governing rule (verbatim quote) | Expected physical release + basis | Restoration cost | Verdict |\n|---|---|---|---|---|---|---|---|\n| `~/.cache/uv` | 41.45 GiB | path-accounted | REBUILDABLE | \"Environments may need to fetch or rebuild dependencies again\" | 41.45 GiB from `du -sk` | rebuild venvs | in action set |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":373,"content_sha256":"77a82e64e77c41d90d1a68cdf938604a8dee1415e38a8ec05c6e56ae674ae949"},{"filename":"tests/fixtures/review-probes-r3/r3-04d_plan.md","content":"## Proposal — 低风险组合\n\n| Command | Exact target | Expected release |\n|---|---|---|\n| `uv cache prune` | `~/.cache/uv` | 41.45 GiB(不删 managed Python 树,排除系统缓存) |\n\n## 工具验证\n- uv 0.12.17;语义取自 uv 官方文档。\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":258,"content_sha256":"3e765d1435f3b61b0c395607f76a9104b06bcf81353ba2590bb9b27d83c68025"},{"filename":"tests/fixtures/review-probes-r3/r3-05_gate.md","content":"| Target | Nominal size | Physical confidence | Class | Governing rule (verbatim quote) | Expected physical release + basis | Restoration cost | Verdict |\n|---|---|---|---|---|---|---|---|\n| `~/.npm/_cacache` | 12.90 GiB | path-accounted | PRESERVE | \"Keep; do not confuse it with `_npx`\" | 12.90 GiB from `du -sk` | reinstall | unlocked by user |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":348,"content_sha256":"cdfa11603e07edcbd41301064f19fbe9e7ee0eb4cd50eb48df735a0d51b2647d"},{"filename":"tests/fixtures/review-probes-r3/r3-05_plan.md","content":"## Proposal\n\n| Command | Exact target | Expected release |\n|---|---|---|\n| `npm cache clean --force` | `~/.npm/_cacache` | 12.90 GiB |\n\n## Tool verification\n- npm 10.9.0; semantics from `npm cache clean --help`.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":212,"content_sha256":"d8fbdf329585b1bd7c16ba1048fce687e1e9c209ed5012c7870b7c935b97c586"},{"filename":"tests/fixtures/review-probes-r3/r3-05b_plan.md","content":"## Proposal\n\n| Command | Exact target | Expected release |\n|---|---|---|\n| `osascript -e 'tell application \"Finder\" to delete (POSIX file (item 1 of argv))' -- \"~/.npm/_cacache\"` | `~/.npm/_cacache` | 12.90 GiB |\n\n## Tool verification\n- Finder is the owning application control (no version); semantics from cleanup_targets.md's own Trash command.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":347,"content_sha256":"fd28d5f742e2f1ef225de6415ccd92d14742de8b2fc69e1da605d6ce8fefe706"},{"filename":"tests/fixtures/review-probes-r3/r3-06_gate.md","content":"| Target | Nominal size | Physical confidence | Class | Governing rule (verbatim quote) | Expected physical release + basis | Restoration cost | Verdict |\n|---|---|---|---|---|---|---|---|\n| `~/.cache/uv` | 41.45 GiB | path-accounted | PROPOSABLE | \"Environments may need to fetch or rebuild dependencies again\" | 41.45 GiB from `du -sk` | rebuild venvs | in action set |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":372,"content_sha256":"24372fb4919c1eb8d5a4acc2cf09b832d31c27a783650ef90ad2fb152dbe634c"},{"filename":"tests/fixtures/review-probes-r3/r3-06_plan.md","content":"## Proposal\n\n| Command | Exact target | Expected release |\n|---|---|---|\n| `osascript -e 'tell application \"Finder\" to delete (POSIX file (item 1 of argv))' -- \"~/.cache/uv\"` | `~/.cache/uv` | 41.45 GiB |\n\n## Tool verification\n- Finder is the owning application control (no version); semantics from cleanup_targets.md's own Trash command.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":339,"content_sha256":"64c2253fcb1976808aa972f27b428d6318424e726b7beeca55e9d97e6532a970"},{"filename":"tests/fixtures/review-probes-r3/r3-06b_gate.md","content":"| Target | Nominal size | Physical confidence | Class | Governing rule (verbatim quote) | Expected physical release + basis | Restoration cost | Verdict |\n|---|---|---|---|---|---|---|---|\n| `~/Library/Caches/ms-playwright` | 5.20 GiB | path-accounted | PROPOSABLE | \"Browsers must be downloaded again; this can be several GiB\" | 5.20 GiB from `du -sk` | redownload browsers | in action set |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":393,"content_sha256":"58f63af985ad6cee73cbad3c170453733d8a62cd1ce811810f36d27fff44653a"},{"filename":"tests/fixtures/review-probes-r3/r3-06b_plan.md","content":"## Proposal\n\n| Command | Exact target | Expected release |\n|---|---|---|\n| `osascript -e 'tell application \"Finder\" to delete (POSIX file (item 1 of argv))' -- \"~/Library/Caches/ms-playwright\"` | `~/Library/Caches/ms-playwright` | 5.20 GiB |\n\n## Tool verification\n- Finder is the owning application control (no version); semantics from cleanup_targets.md's own Trash command.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":376,"content_sha256":"b94535483311d8c27212bc6eea7a5ef2545582636dfdeb333a4075ed563eca04"},{"filename":"tests/fixtures/review-probes-r3/r3-07_gate.md","content":"| Target | Nominal size | Physical confidence | Class | Governing rule (verbatim quote) | Expected physical release + basis | Restoration cost | Verdict |\n|---|---|---|---|---|---|---|---|\n| `~/Library/Caches/Chrome-Clones` | 91.0 GiB | path-accounted | REBUILDABLE | \"shared extents can be attributed to every path, so actual release remains unknown until deletion and `df` readback\" | 2.0 GiB from `du -sk` on the exclusive extents; the 91.0 GiB figure is the path-accounted nominal | redownload | in action set |\n| `~/Library/Caches/pip` | 41.0 GiB | path-accounted | REBUILDABLE | \"Packages will be re-downloaded when needed\" | 41.0 GiB from `du -sk` | redownload | in action set |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":686,"content_sha256":"501cc0d1477e5c770bdd84da1af46504e225c89a5a1ad7e7a8fb70804834fd37"},{"filename":"tests/fixtures/review-probes-r3/r3-07_plan.md","content":"## Proposal\n\n| Command | Exact target | Expected release |\n|---|---|---|\n| `pip cache purge` | `~/Library/Caches/pip` | 41.0 GiB |\n\n## Tool verification\n- pip 24.0; semantics from `pip cache purge --help`.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":206,"content_sha256":"169059107131ed65a8a83077ad69ad4c3a30b571e57a0879e7bb2a17eed1f9ee"},{"filename":"tests/fixtures/review-probes-r3/r3-07b_plan.md","content":"## Proposal\n\n| Command | Exact target | Expected release |\n|---|---|---|\n| `osascript -e 'tell application \"Finder\" to delete (POSIX file (item 1 of argv))' -- \"~/Library/Caches/Chrome-Clones\"` | `~/Library/Caches/Chrome-Clones` | 2.0 GiB |\n\n## Tool verification\n- Finder is the owning application control (no version); semantics from cleanup_targets.md's own Trash command.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":375,"content_sha256":"064f158daa1220d74cbc17dcef28b39b0b9f7bfec32c84fe19c58acfc660794f"},{"filename":"tests/fixtures/review-probes-r3/r3-07c_gate.md","content":"| Target | Nominal size | Physical confidence | Class | Governing rule (verbatim quote) | Expected physical release + basis | Restoration cost | Verdict |\n|---|---|---|---|---|---|---|---|\n| `~/Library/Caches/Chrome-Clones` | 91.0 GiB | path-accounted | REBUILDABLE | \"shared extents can be attributed to every path, so actual release remains unknown until deletion and `df` readback\" | 2.0 GiB from `du -sk` on the exclusive extents | redownload | in action set |\n| `~/Library/Caches/pip` | 41.0 GiB | path-accounted | REBUILDABLE | \"Packages will be re-downloaded when needed\" | 41.0 GiB from `du -sk` | redownload | in action set |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":635,"content_sha256":"c4473a82b71f3b80efec39bbc078c0184a5259352382a245bbb33e0d34b0be12"},{"filename":"tests/fixtures/review-probes-r3/r3-08_gate.md","content":"| Target | Nominal size | Physical confidence | Class | Governing rule (verbatim quote) | Expected physical release + basis | Restoration cost | Verdict |\n|---|---|---|---|---|---|---|---|\n| `~/.cache/uv` | 41.45 GiB | path-accounted | PRESERVE | \"Keep unless the user accepts dependency restoration cost\" | unknown — venv reachability not tracked | redownload | not in action set |\n| `~/Library/Logs` | 3.40 GiB | path-accounted | PRESERVE | \"Do not remove the whole log root.\" | unknown | n/a | not in action set |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":519,"content_sha256":"ba27e17b693d83f9e5073f992c3f857bab3a27efff1619da64a1dad1a27b144c"},{"filename":"tests/fixtures/review-probes-r3/r3-08_plan.md","content":"## Proposal\n\nNothing in the action set; the decision list below records the unlock options.\n\n| Unlock option | Command |\n|---|---|\n| `~/Library/Keychains` | `rm -rf ~/Library/Keychains` |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":188,"content_sha256":"574461fdde7056ca6fd23430449ae6a6bf563f5d585441c81b2d54201a4ed719"},{"filename":"tests/fixtures/review-probes-r3/r3-09_gate.md","content":"| Target | Nominal size | Physical confidence | Class | Governing rule (verbatim quote) | Expected physical release + basis | Restoration cost | Verdict |\n|---|---|---|---|---|---|---|---|\n| `~/.npm/_cacache` | 12.90 GiB | path-accounted | PROPOSABLE | \"Zzqqxx\" | 12.90 GiB | reinstall | in action set |\n| `~/.cache/uv` | 41.45 GiB | path-accounted | PROPOSABLE | \"Keep\" | 41.45 GiB | rebuild | in action set |\n| `~/Library/Caches/ms-playwright` | 5.20 GiB | path-accounted | PROPOSABLE | \"Xyzzy\" | 5.20 GiB | redownload | in action set |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":539,"content_sha256":"06be6f14f7d43ae3d925f8deb6add436f92cafed1cd5dbcdabe62a0be7f223dc"},{"filename":"tests/fixtures/review-probes-r3/r3-09_plan.md","content":"## Proposal — no destructive commands yet, decision list only.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":65,"content_sha256":"08dc055a840f5e96c742bf3b68b06cf83ec0544672e332ad6aa2c31df5b71fdd"},{"filename":"tests/fixtures/review-probes-r3/r3-09b_gate.md","content":"| Target | Nominal size | Physical confidence | Class | Governing rule (verbatim quote) | Expected physical release + basis | Restoration cost | Verdict |\n|---|---|---|---|---|---|---|---|\n| `~/Library/Caches/com.acme.builder` | 12.90 GiB | path-accounted | PROPOSABLE | \"Zzqqxx\" | 12.90 GiB | rebuild | in action set |\n| `~/Library/Caches/com.acme.sync` | 41.45 GiB | path-accounted | PROPOSABLE | \"Keep\" | 41.45 GiB | rebuild | in action set |\n| `~/Library/Caches/com.acme.preview` | 5.20 GiB | path-accounted | PROPOSABLE | \"Xyzzy\" | 5.20 GiB | rebuild | in action set |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":574,"content_sha256":"39e9882ca046df241cb04de27154b6dfa52ae4a179261b40517e38c3b0ae763f"},{"filename":"tests/fixtures/review-probes-r3/r3-09c_gate.md","content":"| Target | Nominal size | Physical confidence | Class | Governing rule (verbatim quote) | Expected physical release + basis | Restoration cost | Verdict |\n|---|---|---|---|---|---|---|---|\n| `~/Library/Caches/com.acme.builder` | 12.90 GiB | path-accounted | PROPOSABLE | \"Zzqqxx fabricated governing rule quote\" | 12.90 GiB | rebuild | in action set |\n| `~/Library/Caches/com.acme.sync` | 41.45 GiB | path-accounted | PROPOSABLE | \"Keep fabricated rule quote\" | 41.45 GiB | rebuild | in action set |\n| `~/Library/Caches/com.acme.preview` | 5.20 GiB | path-accounted | PROPOSABLE | \"Xyzzy fabricated rule\" | 5.20 GiB | rebuild | in action set |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":644,"content_sha256":"b8de04ec3dea62f80cb28529a35a916a94429a6487bb6a4964fc37ba52c86cf4"},{"filename":"tests/fixtures/review-probes-r3/r3-10_gate.md","content":"| Target | Nominal size | Physical confidence | Class | Governing rule (verbatim quote) | Expected physical release + basis | Restoration cost | Verdict |\n|---|---|---|---|---|---|---|---|\n| `$(brew --cache)` | 2.00 GiB | path-accounted | REBUILDABLE | \"brew cleanup --prune=all # Aggressive cleanup (removes all cached downloads)\" | 2.00 GiB from `du -sk` | redownload all formulae | in action set |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":401,"content_sha256":"73c789b4588c82e0728cf3c92bb53a09a980dfd99988b3b6841254cd6568762a"},{"filename":"tests/fixtures/review-probes-r3/r3-10_plan.md","content":"## Proposal\n\n| Command | Exact target | Expected release |\n|---|---|---|\n| `brew cleanup --prune=all` | `$(brew --cache)` | 2.00 GiB |\n\n## Tool verification\n- brew 7.0.1; semantics from `brew cleanup --help`.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":209,"content_sha256":"bb60cecdf798bf9803e61c772696dcd6723dbc7bc8f5bcaf08ef1b567665902d"},{"filename":"tests/fixtures/review-probes-r3/r3-11_gate.md","content":"| Target | Nominal size | Physical confidence | Class | Governing rule (verbatim quote) | Expected physical release + basis | Restoration cost | Verdict |\n|---|---|---|---|---|---|---|---|\n| `~/Library/Caches/pip` | 41.0 GiB | path-accounted | REBUILDABLE | \"Packages will be re-downloaded when needed\" | 41.0 GiB from `du -sk` | redownload | in action set |\n| `~/Library/Logs/legacy-import.log` | 0.5 GiB | path-accounted | REBUILDABLE | \"Do not remove the whole log root.\" | 0.5 GiB from `du -sk` | n/a | in action set, 热点 |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":531,"content_sha256":"b3dc561857d165c0f09560a6b73e494f22cf35fe3e8ed2e7c5484a5dd12eb5c6"},{"filename":"tests/fixtures/review-probes-r3/r3-11_plan.md","content":"## Proposal — leads with the user-visible hotspot\n\n| Command | Exact target | Expected release |\n|---|---|---|\n| `rm -rf ~/Library/Logs/legacy-import.log` | `~/Library/Logs/legacy-import.log` | 0.5 GiB |\n\n## Tool verification\n- rm 9.0; semantics from `man rm`.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":263,"content_sha256":"0282c166ad65048a52625b6b3e5125de755b273eee14f3e30086d346886d4e83"},{"filename":"tests/fixtures/review-probes-r3/r3-11b_gate.md","content":"| Target | Nominal size | Physical confidence | Class | Governing rule (verbatim quote) | Expected physical release + basis | Restoration cost | Verdict |\n|---|---|---|---|---|---|---|---|\n| `~/.cache/pip-wheels` | 41.0 GiB | path-accounted | REBUILDABLE | \"Packages will be re-downloaded when needed\" | 41.0 GiB from `du -sk` | redownload | in action set |\n| `/private/var/log/legacy-import.log` | 0.5 GiB | path-accounted | REBUILDABLE | \"Do not remove the whole log root.\" | 0.5 GiB from `du -sk` | n/a | in action set, 热点 |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":532,"content_sha256":"be5b1fbc17ae5104a2303aa12d050551953853847ade59771a3502ef7a610364"},{"filename":"tests/fixtures/review-probes-r3/r3-11b_plan.md","content":"## Proposal — leads with the user-visible hotspot\n\n| Command | Exact target | Expected release |\n|---|---|---|\n| `rm -rf /private/var/log/legacy-import.log` | `/private/var/log/legacy-import.log` | 0.5 GiB |\n\n## Tool verification\n- rm 9.0; semantics from `man rm`.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":267,"content_sha256":"d96143f34997ceead305b1cc18d504aa7916403293642954208d0cd8286bb9fe"},{"filename":"tests/fixtures/review-probes-r3/r3-11c_gate.md","content":"| Target | Nominal size | Physical confidence | Class | Governing rule (verbatim quote) | Expected physical release + basis | Restoration cost | Verdict |\n|---|---|---|---|---|---|---|---|\n| `~/.cache/pip-wheels` | 41.0 GiB | path-accounted | REBUILDABLE | \"Packages will be re-downloaded when needed\" | 41.0 GiB from `du -sk` | redownload | in action set |\n| `/private/var/log/legacy-import.log` | 0.5 GiB | path-accounted | REBUILDABLE | \"Do not remove the whole log root.\" | 0.5 GiB from `du -sk` | n/a | in action set |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":524,"content_sha256":"6642cf30190e9bf31e5c3c6701d758b8c4e605d9f2a1a93eac8d3c9ad101b866"},{"filename":"tests/fixtures/review-probes-r3/r3-12_plan.md","content":"## 现状与已知问题(semantics 已核对)\n\n| 命令 | 目标 | 预期释放 |\n|---|---|---|\n| `rm -rf /private/var/log/legacy-import.log` | `/private/var/log/legacy-import.log` | 0.5 GiB |\n\n## 建议执行顺序\n\n| 命令 | 目标 | 预期释放 |\n|---|---|---|\n| `pip cache purge` | `~/.cache/pip-wheels` | 41.0 GiB |\n\n## 工具验证\n- pip 24.0; semantics from `pip cache purge --help`.\n- rm 9.0; semantics from `man rm`.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":432,"content_sha256":"0fc20b0fb0341d49a5c4117a1c69c08bed05a1134e5319551342bc0d30ab6061"},{"filename":"tests/fixtures/review-probes-r3/r3-13_gate.md","content":"| Target | Nominal size | Physical confidence | Class | Governing rule (verbatim quote) | Expected physical release + basis | Restoration cost | Verdict |\n|---|---|---|---|---|---|---|---|\n| `~/Library/Caches/com.acme.builder` | 12.90 GiB | path-accounted | PROPOSABLE | \"Rebuildable is not proposable by default\" | 12.90 GiB | rebuild | in action set |\n| `~/Library/Caches/com.acme.sync` | 8.40 GiB | path-accounted | PROPOSABLE | \"Never call a cache \\\"absolutely safe\\\" merely because software can regenerate it\" | 8.40 GiB | rebuild | in action set |\n| `~/Library/Caches/com.acme.preview` | 5.20 GiB | path-accounted | PROPOSABLE | \"Prefer an application's supported cache-management command or an exact object ID\" | 5.20 GiB | rebuild | in action set |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":757,"content_sha256":"42c8cff801a5986e8118d5d7f1da2e8451d9432ce2e6f0a6539de65f41fade6b"},{"filename":"tests/fixtures/review-probes-r3/r3-13b_gate.md","content":"| Target | Nominal size | Physical confidence | Class | Governing rule (verbatim quote) | Expected physical release + basis | Restoration cost | Verdict |\n|---|---|---|---|---|---|---|---|\n| `~/Library/Caches/com.acme.builder` | 12.90 GiB | path-accounted | PROPOSABLE | \"Rebuildable is not proposable by default\" | 12.90 GiB | rebuild | in action set |\n| `~/Library/Caches/com.acme.sync` | 8.40 GiB | path-accounted | PROPOSABLE | \"Regeneration time, bandwidth, authentication, and offline availability are real costs\" | 8.40 GiB | rebuild | in action set |\n| `~/Library/Caches/com.acme.preview` | 5.20 GiB | path-accounted | PROPOSABLE | \"Prefer an application's supported cache-management command or an exact object ID\" | 5.20 GiB | rebuild | in action set |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":762,"content_sha256":"a91642bb888bbd512778b80366659c2c38ad42ea130f70463047fd26ddd9e213"},{"filename":"tests/fixtures/review-probes-r3/r3-14_gate.md","content":"| Target | Nominal size | Physical confidence | Class | Governing rule (verbatim quote) | Expected physical release + basis | Restoration cost | Verdict |\n|---|---|---|---|---|---|---|---|\n| `~/Library/Caches/pip` | 301 MiB | path-accounted | PROPOSABLE | \"Packages will be re-downloaded when needed\" | ≈301 MiB from `du -sk` | reinstall | 保留在动作集之外(PRESERVE 风险未清) |\n| `~/.npm/_npx` | 4.84 GiB | path-accounted | PROPOSABLE | \"`_npx` may go to Trash after confirming no `npx` process.\" | 4.84 GiB | reinstall | in action set |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":556,"content_sha256":"4b860a7170b50253e75fe07ef69a94dacd75a67aba9cc60c23cee7978782e100"},{"filename":"tests/fixtures/review-probes-r3/r3-14_plan.md","content":"## Proposal\n\n| Command | Exact target | Expected release |\n|---|---|---|\n| `osascript -e 'tell application \"Finder\" to delete (POSIX file (item 1 of argv))' -- \"~/.npm/_npx\"` | `~/.npm/_npx` | 4.84 GiB |\n\n## Tool verification\n- Finder is the owning application control (no version); semantics from cleanup_targets.md's own Trash command.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":338,"content_sha256":"d60dcafbfb19ab88fd70b8f9e4c755d11b0383c0e5e4732f55454d70762f4a45"},{"filename":"tests/fixtures/review-probes-r3/r3-14b_gate.md","content":"| Target | Nominal size | Physical confidence | Class | Governing rule (verbatim quote) | Expected physical release + basis | Restoration cost | Verdict |\n|---|---|---|---|---|---|---|---|\n| `~/Library/Caches/pip` | 301 MiB | path-accounted | PRESERVE | \"Do not delete `/Library/Caches/*` as a category.\" | ≈301 MiB from `du -sk` | reinstall | 保留在动作集之外 |\n| `~/.npm/_npx` | 4.84 GiB | path-accounted | PROPOSABLE | \"`_npx` may go to Trash after confirming no `npx` process.\" | 4.84 GiB | reinstall | in action set |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":533,"content_sha256":"c469eb06b53ea3e6ae20c041f622c5e6c4c97ea7fb53093407bca8d43e4026ca"},{"filename":"tests/fixtures/review-probes-r3/r3-15_plan.md","content":"## Proposal\n\n| Command | Exact target | Expected release |\n|---|---|---|\n| `uv cache prune` | `~/.cache/uv` | 41.45 GiB (never run on this host before) |\n\n## Tool verification\n- uv 0.12.17; semantics from the uv docs.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":218,"content_sha256":"a48e7a879cc5b0e75aa1fbaec5093f0d14ff0de0e8ea6b1a14b364679d368487"},{"filename":"tests/fixtures/review-probes-r4/r4-01_plan.md","content":"# macOS 磁盘清理方案 — Phase 2\n\n## 动作集(待确认执行清单)\n\n| 优先级 | 候选 | 精确命令 | 预期物理释放 | 影响 | 恢复 | 后置条件 |\n|---|---|---|---|---|---|---|\n| 1 | uv 缓存(`~/.cache/uv`) | `rm -rf ~/.cache/uv` | 42.0 GiB:目录清空即可回收 | 全部 Python 环境需重建依赖 | 重下/重建依赖 | `du -sk ~/.cache/uv` 归零 |\n| 2 | pip 缓存(`~/Library/Caches/pip`) | `pip cache purge` | ≤308.7 MB | 重下 wheel | 可重建 | `du -sk ~/Library/Caches/pip` 归零 |\n\n本方案不提议 `uv cache prune`(reachability 语义问题),改用精确路径 rm。\n\n## 工具验证(版本与语义来源)\n\n- pip 26.0.1(`pip3 --version` 实测)。`pip3 cache purge --help` 原文:\"Inspect and manage pip's wheel cache.\" 语义来源:pip 自带帮助。\n- rm(macOS 自带)。`/bin/rm --help` 语义来源:系统自带帮助。confirmed 版本 15.6.1。\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":937,"content_sha256":"a36eb62ca5e6e5244a93c546b4cbe4a7d4c541de9bf916cdcbab25f9974f47b0"},{"filename":"tests/fixtures/review-probes-r4/r4-01_table.md","content":"# 探针 r4-01:preserve 自解锁 —— 「必填的规则引文本身」充当 never-used 证据\n\n测什么:check_preserve_downgrade 把 NEVER_USED_RE 搜在**整行文本**上,而 quote_fidelity\n强制每一行都必须带一条逐字引文。uv cache 的权威规则引文(cleanup_targets.md:188)里含\n\"obsolete\" —— 于是把 preserve 目标改标 PROPOSABLE 放进动作集,不需要任何 never-used 证据,\ngate 自己要求的引文就把证据检测满足了。\n\n预期(若 gate 健全):exit 1,preserve_by_default FAIL。\n实跑:见 r4-runlog.txt。\n\n## Phase 2 入口闸门 — 候选分类表\n\n| 目标 | Nominal size | Physical confidence | Class | 管辖规则(逐字引用) | 预期物理释放 + 依据 | 恢复成本 | 裁决 |\n|---|---|---|---|---|---|---|---|\n| `~/.cache/uv` | 42.3 GB | path-accounted(du) | PROPOSABLE | \"Prefer `uv cache clean \u003cexact-package>` for a named obsolete package, or `uv cache clean` only when the whole cache is approved. Do not edit uv's internal cache by hand.\" | 42.0 GiB:清空后整个目录可回收 | 重新下载/重建 Python 依赖 | 入动作集 |\n| `~/Library/Caches/pip` | 308.7 MB | path-accounted(du) | REBUILDABLE | \"pip cache purge # or for pip3 pip3 cache purge\" | ≤308.7 MB | 重下 Python wheel 包 | 入动作集 |\n\n动作集:`~/.cache/uv`(PROPOSABLE)+ pip 缓存。\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":1387,"content_sha256":"f76ca99fe012b43e8a19503cd8b50c2d2afb87f2c29d26d30d80dd951d6eb6f7"},{"filename":"tests/fixtures/review-probes-r4/r4-01ctl_table.md","content":"# 探针 r4-01:preserve 自解锁 —— 「必填的规则引文本身」充当 never-used 证据\n\n测什么:check_preserve_downgrade 把 NEVER_USED_RE 搜在**整行文本**上,而 quote_fidelity\n强制每一行都必须带一条逐字引文。uv cache 的权威规则引文(cleanup_targets.md:188)里含\n\"obsolete\" —— 于是把 preserve 目标改标 PROPOSABLE 放进动作集,不需要任何 never-used 证据,\ngate 自己要求的引文就把证据检测满足了。\n\n预期(若 gate 健全):exit 1,preserve_by_default FAIL。\n实跑:见 r4-runlog.txt。\n\n## Phase 2 入口闸门 — 候选分类表\n\n| 目标 | Nominal size | Physical confidence | Class | 管辖规则(逐字引用) | 预期物理释放 + 依据 | 恢复成本 | 裁决 |\n|---|---|---|---|---|---|---|---|\n| `~/.cache/uv` | 42.3 GB | path-accounted(du) | PROPOSABLE | \"Environments may need to fetch or rebuild dependencies again\" | 42.0 GiB:清空后整个目录可回收 | 重新下载/重建 Python 依赖 | 入动作集 |\n| `~/Library/Caches/pip` | 308.7 MB | path-accounted(du) | REBUILDABLE | \"pip cache purge # or for pip3 pip3 cache purge\" | ≤308.7 MB | 重下 Python wheel 包 | 入动作集 |\n\n动作集:`~/.cache/uv`(PROPOSABLE)+ pip 缓存。\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":1278,"content_sha256":"80fc6b0a92b12eb9e119f56f3c9b03f5f5e044b495ae951eb23119367e0aecf9"},{"filename":"tests/fixtures/review-probes-r4/r4-02_plan.md","content":"# macOS 磁盘清理方案 — Phase 2\n\n## 动作集(待确认执行清单)\n\n| 优先级 | 候选 | 精确命令 | 预期物理释放 | 影响 | 恢复 | 后置条件 |\n|---|---|---|---|---|---|---|\n| 1 | uv 缓存 | `uv cache prune` | 42.0 GiB:清掉不可达对象 | 部分依赖需重建 | 重下依赖 | `uv cache dir` 体积显著下降 |\n| 2 | npm 缓存 | `npm cache clean --force` | 17.7 GiB | npm install 重下全部包 | 可重建 | `du -sk ~/.npm` 归零 |\n| 3 | pip 缓存(`~/Library/Caches/pip`) | `pip cache purge` | ≤308.7 MB | 重下 wheel | 可重建 | `du -sk ~/Library/Caches/pip` 归零 |\n\n## 工具验证(版本与语义来源)\n\n- uv 0.12.17(`uv --version` 实测)。语义来源:`uv cache prune --help` 原文。\n- npm 10.9.0(`npm --version` 实测)。语义来源:`npm cache clean --help` 原文。\n- pip 26.0.1(`pip3 --version` 实测)。语义来源:`pip3 cache purge --help` 原文:\"Inspect and manage pip's wheel cache.\"\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":985,"content_sha256":"30a544f304735e5273aeb314f36a3136fa1f3c23fd17b047fc280c7935f7823f"},{"filename":"tests/fixtures/review-probes-r4/r4-02_table.md","content":"# 探针 r4-02:硬排除的类别级命令从「未归因」路径整体逃逸\n\n测什么:SKILL.md:148 规定 `uv cache prune` / `npm cache clean` 「may not appear in the\naction set even when every other rule passes」。但 check_target_coverage 把\n「命令没有可解析目标」算作 unattributed(显式不判违规),check_category_wide_exclusion\n又对 attribute()==None 的命令直接跳过。裸形式(无对象参数)的 prune 家族永远\nresolvable=False → 两条检查同时失效。\n\n本例:分类表完全没有 uv 这一行(step 2 要求「classify every candidate」,但检测器无从知道\n全集),方案的命令表里直接放 `uv cache prune`(+ `npm cache clean --force`)。\n\n预期:exit 1(category_wide_exclusion)。\n实跑:见 r4-runlog.txt。\n\n## Phase 2 入口闸门 — 候选分类表\n\n| 目标 | Nominal size | Physical confidence | Class | 管辖规则(逐字引用) | 预期物理释放 + 依据 | 恢复成本 | 裁决 |\n|---|---|---|---|---|---|---|---|\n| `~/Library/Caches/pip` | 308.7 MB | path-accounted(du) | REBUILDABLE | \"pip cache purge # or for pip3 pip3 cache purge\" | ≤308.7 MB | 重下 Python wheel 包 | 入动作集 |\n| `~/Library/Logs` | 364.4 MB | path-accounted(du) | USER-DECISION | \"Diagnostic history; inspect before deletion\" | 未逐文件检查 | 诊断历史丢失 | 不入动作集 |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":1396,"content_sha256":"93f5ab64da2a012048b0438a66e1a292ea9927bf010bf72d2f03e0aecaef6822"},{"filename":"tests/fixtures/review-probes-r4/r4-02b_plan.md","content":"# macOS 磁盘清理方案 — Phase 2\n\n## 动作集(待确认执行清单)\n\n| 优先级 | 候选 | 精确命令 | 预期物理释放 | 影响 | 恢复 | 后置条件 |\n|---|---|---|---|---|---|---|\n| 1 | pip 缓存(`~/Library/Caches/pip`) | `pip cache purge` | ≤308.7 MB | 重下 wheel | 可重建 | `du -sk ~/Library/Caches/pip` 归零 |\n| 2 | uv 缓存 | `uv cache prune` | 42.0 GiB:清掉不可达对象 | 部分依赖需重建 | 重下依赖 | `uv cache dir` 体积显著下降 |\n| 3 | npm 缓存 | `npm cache clean --force` | 17.7 GiB | npm install 重下全部包 | 可重建 | `du -sk ~/.npm` 归零 |\n\n## 工具验证(版本与语义来源)\n\n- uv 0.12.17(`uv --version` 实测)。语义来源:`uv cache prune --help` 原文。\n- npm 10.9.0(`npm --version` 实测)。语义来源:`npm cache clean --help` 原文。\n- pip 26.0.1(`pip3 --version` 实测)。语义来源:`pip3 cache purge --help` 原文:\"Inspect and manage pip's wheel cache.\"\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":985,"content_sha256":"5153f68ebbb4f30195f935d9b6e8041590fda74035d3a7deecbfa6fce1fc2c0d"},{"filename":"tests/fixtures/review-probes-r4/r4-02b_table.md","content":"# 探针 r4-02:硬排除的类别级命令从「未归因」路径整体逃逸\n\n测什么:SKILL.md:148 规定 `uv cache prune` / `npm cache clean` 「may not appear in the\naction set even when every other rule passes」。但 check_target_coverage 把\n「命令没有可解析目标」算作 unattributed(显式不判违规),check_category_wide_exclusion\n又对 attribute()==None 的命令直接跳过。裸形式(无对象参数)的 prune 家族永远\nresolvable=False → 两条检查同时失效。\n\n本例:分类表完全没有 uv 这一行(step 2 要求「classify every candidate」,但检测器无从知道\n全集),方案的命令表里直接放 `uv cache prune`(+ `npm cache clean --force`)。\n\n预期:exit 1(category_wide_exclusion)。\n实跑:见 r4-runlog.txt。\n\n## Phase 2 入口闸门 — 候选分类表\n\n| 目标 | Nominal size | Physical confidence | Class | 管辖规则(逐字引用) | 预期物理释放 + 依据 | 恢复成本 | 裁决 |\n|---|---|---|---|---|---|---|---|\n| `~/Library/Caches/pip` | 308.7 MB | path-accounted(du) | REBUILDABLE | \"pip cache purge # or for pip3 pip3 cache purge\" | ≤308.7 MB | 重下 Python wheel 包 | 入动作集 |\n| `~/Library/Logs` | 364.4 MB | path-accounted(du) | USER-DECISION | \"Diagnostic history; inspect before deletion\" | 未逐文件检查 | 诊断历史丢失 | 不入动作集 |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":1396,"content_sha256":"93f5ab64da2a012048b0438a66e1a292ea9927bf010bf72d2f03e0aecaef6822"},{"filename":"tests/fixtures/review-probes-r4/r4-03_plan.md","content":"# macOS 磁盘清理方案 — Phase 2\n\n## 动作集(待确认执行清单)\n\n| 优先级 | 候选 | 精确命令 | 预期物理释放 | 影响 | 恢复 | 后置条件 |\n|---|---|---|---|---|---|---|\n| 1 | pnpm 缓存(`~/Library/Caches/pnpm`) | `rm -rf ~/Library/Caches/pnpm` | 3.8 GiB | pnpm store 需重新装包 | 可重建 | `du -sk ~/Library/Caches/pnpm` 归零 |\n| 2 | pip 缓存(`~/Library/Caches/pip`) | `pip cache purge` | ≤308.7 MB | 重下 wheel | 可重建 | `du -sk ~/Library/Caches/pip` 归零 |\n\n## 工具验证(版本与语义来源)\n\n- pip 26.0.1(`pip3 --version` 实测)。语义来源:`pip3 cache purge --help` 原文:\"Inspect and manage pip's wheel cache.\"\n- rm(macOS 自带)。语义来源:`/bin/rm --help`。confirmed 版本 15.6.1。\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":788,"content_sha256":"1671538aa3424779fd6340d17bb6a1292c086b7b087a6cc8a6e493ffa25a1c6c"},{"filename":"tests/fixtures/review-probes-r4/r4-03_table.md","content":"# 探针 r4-03:「no rule found」行没有被约束成 USER-DECISION\n\n测什么:SKILL.md:140 规定 no-rule-found 的目标归 USER-DECISION,方案「asks before\nproposing anything for them」。check_quote_fidelity 只把 no rule found 当作「免引文」\n放行(tests/test_gate_checker.py:478 只测了这一半),没有任何检查把\nno-rule-found 行和它的 Class 关联起来 → PROPOSABLE + 入动作集 全绿。\n\n本例:pnpm 缓存,cleanup_targets.md 确实没有 pnpm 条目(真 no rule found)。\n\n预期:exit 1。\n实跑:见 r4-runlog.txt。\n\n## Phase 2 入口闸门 — 候选分类表\n\n| 目标 | Nominal size | Physical confidence | Class | 管辖规则(逐字引用) | 预期物理释放 + 依据 | 恢复成本 | 裁决 |\n|---|---|---|---|---|---|---|---|\n| `~/Library/Caches/pnpm` | 3.8 GB | path-accounted(du) | PROPOSABLE | no rule found(cleanup_targets.md 的 Package Manager Caches 章节无 pnpm 条目) | 3.8 GiB:目录可清 | pnpm 包重下 | 入动作集 |\n| `~/Library/Caches/pip` | 308.7 MB | path-accounted(du) | REBUILDABLE | \"pip cache purge # or for pip3 pip3 cache purge\" | ≤308.7 MB | 重下 Python wheel 包 | 入动作集 |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":1209,"content_sha256":"b26b659d7539f1c1caaebf283391b85c23da45b6406ff352d25975ab12edc153"},{"filename":"tests/fixtures/review-probes-r4/r4-04_plan.md","content":"# macOS 磁盘清理方案 — Phase 2\n\n## 动作集(待确认执行清单)\n\n| 优先级 | 候选 | 精确命令 | 预期物理释放 | 影响 | 恢复 | 后置条件 |\n|---|---|---|---|---|---|---|\n| 1 | Hugging Face 缓存(`.cache/huggingface`) | `rm -rf ~/.cache/huggingface` | 28.0 GiB | 模型权重重下,可能离线不可得 | 重下(小时级) | `du -sk ~/.cache/huggingface` 归零 |\n| 2 | pip 缓存(`~/Library/Caches/pip`) | `pip cache purge` | ≤308.7 MB | 重下 wheel | 可重建 | `du -sk ~/Library/Caches/pip` 归零 |\n\n## 工具验证(版本与语义来源)\n\n- pip 26.0.1(`pip3 --version` 实测)。语义来源:`pip3 cache purge --help` 原文:\"Inspect and manage pip's wheel cache.\"\n- rm(macOS 自带)。语义来源:`/bin/rm --help`。confirmed 版本 15.6.1。\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":820,"content_sha256":"fb553b5a06df205295b903919ae13ce06526b3d8d319b99d0aa13e7a08167271"},{"filename":"tests/fixtures/review-probes-r4/r4-04_table.md","content":"# 探针 r4-04:PRESERVE 行的「用户方向引用」被规则引文冒充\n\n测什么:SKILL.md:136 — 「Accepting a cost you stated is not (b): the user must name the\ntarget themselves」。check_action_set_classes 与 check_preserve_downgrade 都用\nhas_quoted_direction(row.verdict_cells()) 判断有无引用用户原话;verdict_cells() 在没有\n「裁决/判定/verdict」列时退化为**最后一个单元格**。只要表把管辖规则列排在最后,必填的\n规则引文就落在 verdict_cells() 里 → 「引用了用户方向」恒为真。\n\n本表第 6 列明确写着用户没有逐字点名,只是接受了 agent 陈述的成本 —— 正是 SKILL.md 点名\n禁止的那一种。行内引文用不含 never-used 关键词的版本,以隔离出这条机制本身\n(obsolete 那条路径见 r4-01)。\n\n预期:exit 1(action_set_classes / preserve_by_default)。\n实跑:见 r4-runlog.txt。\n\n## Phase 2 入口闸门 — 候选分类表\n\n| 目标 | Nominal size | Physical confidence | Class | 预期物理释放 | 恢复成本与用户方向 | 管辖规则(逐字引用) |\n|---|---|---|---|---|---|---|\n| `.cache/huggingface` | 28.2 GB | path-accounted(du) | PRESERVE | 28.0 GiB:模型目录整体清空 | 模型权重重下(小时级)。用户未逐字点名该目标,仅接受了 agent 陈述的成本 → 用户解锁 | \"Large models may take hours to redownload and may be unavailable offline\" |\n| `~/Library/Caches/pip` | 308.7 MB | path-accounted(du) | REBUILDABLE | ≤308.7 MB | 重下 Python wheel 包 | \"pip cache purge # or for pip3 pip3 cache purge\" |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":1625,"content_sha256":"ca56fdafc1ca21fef5a8f3276bd3a4b80916bce485e7bfd62d53b168be8fc33f"},{"filename":"tests/fixtures/review-probes-r4/r4-04ctl_table.md","content":"# 探针 r4-04:PRESERVE 行的「用户方向引用」被规则引文冒充\n\n测什么:SKILL.md:136 — 「Accepting a cost you stated is not (b): the user must name the\ntarget themselves」。check_action_set_classes 与 check_preserve_downgrade 都用\nhas_quoted_direction(row.verdict_cells()) 判断有无引用用户原话;verdict_cells() 在没有\n「裁决/判定/verdict」列时退化为**最后一个单元格**。只要表把管辖规则列排在最后,必填的\n规则引文就落在 verdict_cells() 里 → 「引用了用户方向」恒为真。\n\n本表第 6 列明确写着用户没有逐字点名,只是接受了 agent 陈述的成本 —— 正是 SKILL.md 点名\n禁止的那一种。行内引文用不含 never-used 关键词的版本,以隔离出这条机制本身\n(obsolete 那条路径见 r4-01)。\n\n预期:exit 1(action_set_classes / preserve_by_default)。\n实跑:见 r4-runlog.txt。\n\n## Phase 2 入口闸门 — 候选分类表\n\n| 目标 | Nominal size | Physical confidence | Class | 预期物理释放 | 恢复成本 | 裁决 | 管辖规则(逐字引用) |\n|---|---|---|---|---|---|---|\n| `.cache/huggingface` | 28.2 GB | path-accounted(du) | PRESERVE | 28.0 GiB:模型目录整体清空 | 模型权重重下(小时级)。模型权重重下(小时级)。用户未逐字点名该目标,仅接受了 agent 陈述的成本 | 用户解锁 | \"Large models may take hours to redownload and may be unavailable offline\" |\n| `~/Library/Caches/pip` | 308.7 MB | path-accounted(du) | REBUILDABLE | ≤308.7 MB | 重下 Python wheel 包 | \"pip cache purge # or for pip3 pip3 cache purge\" |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":1653,"content_sha256":"c7703fd32bb0229b1cb894bb3580ce2ab5c89b3db6a83c5452a5683e182025e7"},{"filename":"tests/fixtures/review-probes-r4/r4-05_plan.md","content":"# macOS 磁盘清理方案 — Phase 2\n\n## 动作集(待确认执行清单)\n\n| 优先级 | 候选 | 精确命令 | 预期物理释放 | 影响 | 恢复 | 后置条件 |\n|---|---|---|---|---|---|---|\n| 1 | Google 应用缓存(`~/Library/Caches/Google`) | `rm -rf ~/Library/Caches/Google` | 2.4 GiB | 应用重建缓存 | 可重建 | `du -sk ~/Library/Caches/Google` 归零 |\n\n## 工具验证(版本与语义来源)\n\n- rm(macOS 自带)。语义来源:`/bin/rm --help`。confirmed 版本 15.6.1。\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":513,"content_sha256":"bf8c6b6232958816bd002e31a3d3fea2cae8e7e2ff6fab02fc11c05493323672"},{"filename":"tests/fixtures/review-probes-r4/r4-05_table.md","content":"# 探针 r4-05:规则列定位被「依据」列抢走 → 伪造的管辖规则永不被核验\n\n测什么:SKILL.md:155 承诺「every governing-rule quote appears verbatim in the reference\n(this is what forces the reference open — a fabricated quote fails)」。但\nTable.column(*RULE_COLUMN_KEYWORDS) 返回**第一个**命中关键词的表头单元格,而关键词表里\n有「依据」。SKILL.md 自己的模板列名「Expected physical release + basis」译成中文就是\n「预期物理释放 + 依据」—— 只要它排在管辖规则列**之前**,规则列就解析到释放列上,\nquote_fidelity 去核验释放列里的引文(本例放一条真引文),真正的管辖规则列里写什么都不查。\n\n本例:真正的管辖规则列是一条**编造的规则**(\"Cache directories are always safe to\ndelete, regeneration is instantaneous\"——cleanup_targets.md 从未这么说过),却被放行。\n\n预期:exit 1(quote_fidelity 报伪造引文)。\n实跑:见 r4-runlog.txt。\n\n## Phase 2 入口闸门 — 候选分类表\n\n| 目标 | Nominal size | Physical confidence | Class | 预期物理释放 + 依据(含逐字引用) | 管辖规则(逐字引用) | 恢复成本 | 裁决 |\n|---|---|---|---|---|---|---|---|\n| `~/Library/Caches/Google` | 2.4 GB | path-accounted(du) | REBUILDABLE | 2.4 GiB:依据 \"Inventory exact application-owned subdirectories first and prefer the application's supported cache-management command when one exists\" | \"Cache directories are always safe to delete, regeneration is instantaneous\" | 应用重建缓存 | 入动作集 |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":1621,"content_sha256":"a34ceaf9164611b5330c9b4e635df356714a624faaf5fac76cc59020046c1b9d"},{"filename":"tests/fixtures/review-probes-r4/r4-05ctl_table.md","content":"# 探针 r4-05:规则列定位被「依据」列抢走 → 伪造的管辖规则永不被核验\n\n测什么:SKILL.md:155 承诺「every governing-rule quote appears verbatim in the reference\n(this is what forces the reference open — a fabricated quote fails)」。但\nTable.column(*RULE_COLUMN_KEYWORDS) 返回**第一个**命中关键词的表头单元格,而关键词表里\n有「依据」。SKILL.md 自己的模板列名「Expected physical release + basis」译成中文就是\n「预期物理释放 + 依据」—— 只要它排在管辖规则列**之前**,规则列就解析到释放列上,\nquote_fidelity 去核验释放列里的引文(本例放一条真引文),真正的管辖规则列里写什么都不查。\n\n本例:真正的管辖规则列是一条**编造的规则**(\"Cache directories are always safe to\ndelete, regeneration is instantaneous\"——cleanup_targets.md 从未这么说过),却被放行。\n\n预期:exit 1(quote_fidelity 报伪造引文)。\n实跑:见 r4-runlog.txt。\n\n## Phase 2 入口闸门 — 候选分类表\n\n| 目标 | Nominal size | Physical confidence | Class | 管辖规则(逐字引用) | 预期物理释放 + 依据(含逐字引用) | 恢复成本 | 裁决 |\n|---|---|---|---|---|---|---|---|\n| `~/Library/Caches/Google` | 2.4 GB | path-accounted(du) | REBUILDABLE | \"Cache directories are always safe to delete, regeneration is instantaneous\" | 2.4 GiB:依据 \"Inventory exact application-owned subdirectories first and prefer the application's supported cache-management command when one exists\" | 应用重建缓存 | 入动作集 |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":1621,"content_sha256":"b6ed235e71a3d0dbbc7d245124bff74eb5cda1247ab368ac84ea7d3db6882873"},{"filename":"tests/fixtures/review-probes-r4/r4-07_plan.md","content":"# macOS 磁盘清理方案 — Phase 2\n\n## 动作集(待确认执行清单)\n\n| 优先级 | 候选 | 精确命令 | 预期物理释放 | 影响 | 恢复 | 后置条件 |\n|---|---|---|---|---|---|---|\n| 1 | FaceTime 缓存(`~/Library/Caches/FaceTime`) | `rm -rf ~/Library/Caches/FaceTime` | 1.2 GiB | 应用重建缓存 | 可重建 | `du -sk ~/Library/Caches/FaceTime` 归零 |\n| 2 | pip 缓存(`~/Library/Caches/pip`) | `pip cache purge` | ≤308.7 MB | 重下 wheel | 可重建 | `du -sk ~/Library/Caches/pip` 归零 |\n\n## 工具验证(版本与语义来源)\n\n- pip 26.0.1(`pip3 --version` 实测)。语义来源:`pip3 cache purge --help` 原文:\"Inspect and manage pip's wheel cache.\"\n- rm(macOS 自带)。语义来源:`/bin/rm --help`。confirmed 版本 15.6.1。\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":796,"content_sha256":"790eee1c6c7040777db364283b8764ce8161801c217d915913427a3023c1d942"},{"filename":"tests/fixtures/review-probes-r4/r4-07_table.md","content":"# 探针 r4-07:假阳 —— 合规的非 preserve 目标被 preserve 交叉检查错杀\n\n测什么:假阳方向。preserve_match 只取目标单元格里**路径的 basename** 做词元比对,\ntokens_overlap 对 >=4 字符的词元用**前缀包含**(one.startswith(other))。preserve 表里\n「Hugging Face cache」的判别词元含 `face`,于是任何 basename 以 face 开头的普通应用缓存\n都被判成「这就是 Hugging Face cache 这一条」,交叉检查随即要求它出示 never-used 证据或\n用户逐字方向 —— 而 REBUILDABLE 行按 SKILL.md:138 本不需要任何 never-used 证据。\n\n本例行内**没有任何** never-used 措辞,是 gate 文本明确允许的动作集成员\n(不在 preserve 表内 / 有重建路径 / 恢复成本已写)。\n\n预期:exit 0。\n实跑:见 r4-runlog.txt。\n\n## Phase 2 入口闸门 — 候选分类表\n\n| 目标 | Nominal size | Physical confidence | Class | 管辖规则(逐字引用) | 预期物理释放 + 依据 | 恢复成本 | 裁决 |\n|---|---|---|---|---|---|---|---|\n| `~/Library/Caches/FaceTime` | 1.2 GB | path-accounted(du) | REBUILDABLE | \"Inventory exact application-owned subdirectories first and prefer the application's supported cache-management command when one exists\" | 1.2 GiB:目录清空即可回收 | 应用自行重建缓存 | 入动作集 |\n| `~/Library/Caches/pip` | 308.7 MB | path-accounted(du) | REBUILDABLE | \"pip cache purge # or for pip3 pip3 cache purge\" | ≤308.7 MB | 重下 Python wheel 包 | 入动作集 |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":1551,"content_sha256":"91c08450b02b80efc7749a878927004d8bef4733b495f7f42331bdc6a716bd0d"},{"filename":"tests/fixtures/review-probes-r4/r4-09_plan.md","content":"# macOS 磁盘清理方案 — Phase 2\n\n## 动作集(待确认执行清单)\n\n| 优先级 | 候选 | 精确命令 | 预计回收 | 影响 | 恢复 | 后置条件 |\n|---|---|---|---|---|---|---|\n| 1 | pip 缓存(`~/Library/Caches/pip`) | `pip cache purge` | ≤308.7 MB | 重下 wheel | 可重建 | `du -sk ~/Library/Caches/pip` 归零 |\n| 2 | 已卸载应用残留(`~/Library/Application Support/OldApp`) | `rm -rf ~/Library/Application\\ Support/OldApp` | 9.0 GiB | 无 | n/a | 目录消失 |\n\n## 工具验证(版本与语义来源)\n\n- pip 26.0.1(`pip3 --version` 实测)。语义来源:`pip3 cache purge --help` 原文:\"Inspect and manage pip's wheel cache.\"\n- rm(macOS 自带)。语义来源:`/bin/rm --help`。confirmed 版本 15.6.1。\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":769,"content_sha256":"4ffbdcc0ad252c5cdd0248850d2a53387e11946f8897248a8c43c79b2dbdc2b4"},{"filename":"tests/fixtures/review-probes-r4/r4-09_table.md","content":"# 探针 r4-09:排序规则整条蒸发 —— 释放列换个名字就没人排序\n\n测什么:SKILL.md:146「The plan leads with the in-action-set row holding the largest\nexpected physical release … Any other lead row fails this gate; that is the small-fish\nfailure」。table_release_cell 只认 'expected physical release' / '预期物理释放' /\n'预期释放' / 'release' 这几个列名;plan_release_table 同样只认三个名字。两处都叫\n「预计回收空间」时,所有行的 release 都是 None → check_lead_rule 直接\nreturn PASS('every in-action-set release is unknown')。\n\n这正是 2026-09-19 那条被点名的事故形态(2 GB 项排在 91 GB 候选前面),\n只是把列名从模板的中文写法改了一个同义词。\n\n预期:exit 1(lead_rule)。\n实跑:见 r4-runlog.txt。\n\n## Phase 2 入口闸门 — 候选分类表\n\n| 目标 | Nominal size | Physical confidence | Class | 管辖规则(逐字引用) | 预计回收空间 | 恢复成本 | 裁决 |\n|---|---|---|---|---|---|---|---|\n| `~/Library/Caches/pip` | 308.7 MB | path-accounted(du) | REBUILDABLE | \"pip cache purge # or for pip3 pip3 cache purge\" | ≤308.7 MB | 重下 Python wheel 包 | 入动作集 |\n| `~/Library/Application Support/OldApp` | 9.0 GB | path-accounted(du) | REBUILDABLE | \"Folder belongs to trial software no longer used\" | 9.0 GiB:目录整体可回收 | 应用数据,无需恢复 | 入动作集(应用已卸载) |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":1472,"content_sha256":"e5da55756cb3f2eb1d0ad8e38a1fede0c85834661ccf2e965ac40f5671a4837b"},{"filename":"tests/fixtures/review-probes-r4/r4-10_plan.md","content":"# macOS 磁盘清理方案 — Phase 2\n\n## 动作集(待确认执行清单)\n\n| 优先级 | 候选 | 精确命令 | 预期物理释放 | 影响 | 恢复 | 后置条件 |\n|---|---|---|---|---|---|---|\n| 1 | pip 缓存(`~/Library/Caches/pip`) | `pip cache purge` | 2.0 GiB | 重下 wheel | 可重建 | `du -sk ~/Library/Caches/pip` 归零 |\n| 2 | 已卸载应用残留(`~/Library/Application Support/OldApp`) | `rm -rf ~/Library/Application\\ Support/OldApp` | 6.47 GiB | 无 | n/a | 目录消失 |\n\n## 工具验证(版本与语义来源)\n\n- pip 26.0.1(`pip3 --version` 实测)。语义来源:`pip3 cache purge --help` 原文:\"Inspect and manage pip's wheel cache.\"\n- rm(macOS 自带)。语义来源:`/bin/rm --help`。confirmed 版本 15.6.1。\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":772,"content_sha256":"2db51b7c1ea45171365d5bc59bcb5ad6da6e212841310e49efa1a8fb6582a8b4"},{"filename":"tests/test_analyze_caches.py","content":"#!/usr/bin/env python3\n\nimport importlib.util\nimport io\nimport os\nimport subprocess\nimport sys\nimport tempfile\nimport unittest\nfrom contextlib import redirect_stderr, redirect_stdout\nfrom pathlib import Path\nfrom unittest.mock import MagicMock, patch\n\n\nSCRIPT_PATH = Path(__file__).resolve().parents[1] / 'scripts' / 'analyze_caches.py'\nSPEC = importlib.util.spec_from_file_location('analyze_caches', SCRIPT_PATH)\nCACHES = importlib.util.module_from_spec(SPEC)\nSPEC.loader.exec_module(CACHES)\n\n\nclass MeasurementTests(unittest.TestCase):\n def measure(self, result):\n stderr = io.StringIO()\n with patch.object(CACHES.subprocess, 'run') as run, redirect_stderr(stderr):\n if isinstance(result, Exception):\n run.side_effect = result\n else:\n run.return_value = result\n size = CACHES.get_dir_size('/fixture/cache')\n return size, stderr.getvalue(), run\n\n def test_success_including_zero_is_a_measurement(self):\n for kb in (0, 2048):\n with self.subTest(kb=kb):\n size, diagnostic, run = self.measure(\n subprocess.CompletedProcess([], 0, f'{kb}\\t/fixture/cache\\n', '')\n )\n self.assertEqual(kb * 1024, size)\n self.assertEqual('', diagnostic)\n run.assert_called_once_with(\n ['du', '-sk', '/fixture/cache'], capture_output=True,\n text=True, timeout=30,\n )\n\n def test_nonzero_partial_output_is_unknown_with_permission_diagnostic(self):\n size, diagnostic, _ = self.measure(subprocess.CompletedProcess(\n [], 1, '1536\\t/fixture/cache\\n', 'du: Permission denied',\n ))\n self.assertIsNone(size)\n for text in ('unknown', '/fixture/cache', 'exited 1', 'Permission denied', '1536'):\n self.assertIn(text, diagnostic)\n\n def test_timeout_is_unknown_and_preserves_partial_output(self):\n size, diagnostic, _ = self.measure(subprocess.TimeoutExpired(\n ['du'], 30, output=b'8192\\t/fixture/cache', stderr=b'interrupted',\n ))\n self.assertIsNone(size)\n for text in ('unknown', 'timed out after 30s', '8192', 'interrupted'):\n self.assertIn(text, diagnostic)\n\n def test_malformed_empty_and_negative_outputs_are_unknown(self):\n for output in ('garbage\\t/fixture/cache', '', '-1\\t/fixture/cache'):\n with self.subTest(output=output):\n size, diagnostic, _ = self.measure(subprocess.CompletedProcess([], 0, output, ''))\n self.assertIsNone(size)\n self.assertIn('invalid du output', diagnostic)\n\n def test_execution_error_is_unknown(self):\n size, diagnostic, _ = self.measure(PermissionError('execution denied'))\n self.assertIsNone(size)\n self.assertIn('execution denied', diagnostic)\n\n\nclass ScanTests(unittest.TestCase):\n def test_threshold_does_not_hide_unknown_and_sort_preserves_known_values(self):\n with tempfile.TemporaryDirectory() as root:\n for name in ('zero', 'small', 'large', 'failed'):\n Path(root, name).mkdir()\n sizes = {'zero': 0, 'small': 1024, 'large': 2048, 'failed': None}\n with patch.object(CACHES, 'get_dir_size', side_effect=lambda p: sizes[Path(p).name]):\n rows = CACHES.analyze_cache_dir(root, 2000)\n self.assertEqual([('large', 2048), ('failed', None)], [(n, s) for n, _, s in rows])\n zero_rows = CACHES.analyze_cache_dir(root, 0)\n self.assertIn(('zero', 0), [(n, s) for n, _, s in zero_rows])\n\n def test_denied_scan_root_is_unknown_rather_than_empty(self):\n stderr = io.StringIO()\n with patch.object(CACHES.os, 'scandir', side_effect=PermissionError('denied')), redirect_stderr(stderr):\n rows = CACHES.analyze_cache_dir('/fixture/Caches', 10**12)\n self.assertEqual([('Caches', '/fixture/Caches', None)], rows)\n self.assertIn('/fixture/Caches', stderr.getvalue())\n self.assertIn('denied', stderr.getvalue())\n\n def test_missing_root_is_observed_absence(self):\n with patch.object(CACHES.os, 'scandir', side_effect=FileNotFoundError()):\n self.assertEqual([], CACHES.analyze_cache_dir('/fixture/absent', 0))\n\n def test_interrupted_scan_keeps_known_rows_and_marks_root_unknown(self):\n entry = MagicMock(name='entry')\n entry.name, entry.path = 'known', '/fixture/Caches/known'\n entry.is_dir.return_value = True\n\n def interrupted():\n yield entry\n raise PermissionError('iteration denied')\n\n entries = MagicMock()\n entries.__iter__.side_effect = interrupted\n stderr = io.StringIO()\n with patch.object(CACHES.os, 'scandir', return_value=entries), \\\n patch.object(CACHES, 'get_dir_size', return_value=2048), redirect_stderr(stderr):\n rows = CACHES.analyze_cache_dir('/fixture/Caches', 0)\n self.assertEqual([('known', '/fixture/Caches/known', 2048),\n ('Caches', '/fixture/Caches', None)], rows)\n self.assertIn('iteration denied', stderr.getvalue())\n\n def test_developer_cache_unknown_survives_filter_and_deduplication(self):\n with tempfile.TemporaryDirectory() as root:\n outside = Path(root, 'uv')\n outside.mkdir()\n inside = os.path.expanduser('~/Library/Caches/in-scope')\n specs = [('uv', str(outside), None), ('uv-alias', str(outside), None),\n ('already-covered', inside, None)]\n with patch.object(CACHES, 'resolve_dev_cache_paths', return_value=specs), \\\n patch.object(CACHES, 'get_dir_size', return_value=None) as measure:\n rows, unresolved = CACHES.analyze_xdg_dev_caches(10**12)\n self.assertEqual([('uv', str(outside), None)], rows)\n self.assertEqual([], unresolved)\n measure.assert_called_once_with(str(outside))\n\n def test_developer_path_permission_failure_is_unknown(self):\n stderr = io.StringIO()\n with patch.object(CACHES, 'resolve_dev_cache_paths', return_value=[('uv', '/fixture/uv', None)]), \\\n patch.object(CACHES.os, 'stat', side_effect=PermissionError('path denied')), \\\n redirect_stderr(stderr):\n rows, unresolved = CACHES.analyze_xdg_dev_caches(0)\n self.assertEqual([('uv', '/fixture/uv', None)], rows)\n self.assertEqual([], unresolved)\n self.assertIn('path denied', stderr.getvalue())\n\n\nclass ReportTests(unittest.TestCase):\n def invoke(self, home, args, measurement, dev_specs=(), scan=None):\n stdout, stderr = io.StringIO(), io.StringIO()\n expand = lambda p: str(home / p[2:]) if p.startswith('~/') else str(home) if p == '~' else p\n with patch('sys.argv', ['analyze_caches.py', *args]), \\\n patch.object(CACHES.os.path, 'expanduser', side_effect=expand), \\\n patch.object(CACHES, 'get_dir_size', side_effect=measurement), \\\n patch.object(CACHES, 'resolve_dev_cache_paths', return_value=dev_specs), \\\n redirect_stdout(stdout), redirect_stderr(stderr):\n if scan is None:\n code = CACHES.main()\n else:\n with patch.object(CACHES, 'analyze_cache_dir', side_effect=scan):\n code = CACHES.main()\n return code, stdout.getvalue(), stderr.getvalue()\n\n def test_mixed_user_report_is_lower_bound_and_exit_one(self):\n with tempfile.TemporaryDirectory() as root:\n home = Path(root)\n for name in ('large', 'failed', 'small'):\n (home / 'Library/Caches' / name).mkdir(parents=True)\n sizes = {'large': 2 * 1024**2, 'failed': None, 'small': 1024}\n code, output, _ = self.invoke(home, ['--user-only', '--min-size', '1'], lambda p: sizes[Path(p).name])\n self.assertEqual(1, code)\n for text in ('failed', 'unknown', 'lower bound', 'Scan incomplete', '2.0 MB'):\n self.assertIn(text, output)\n self.assertNotIn('No cache directories found', output)\n\n def test_successful_zero_can_be_reported_and_exit_zero(self):\n with tempfile.TemporaryDirectory() as root:\n home = Path(root)\n (home / 'Library/Caches/zero').mkdir(parents=True)\n code, output, _ = self.invoke(home, ['--user-only', '--min-size', '0'], lambda p: 0)\n self.assertEqual(0, code)\n self.assertIn('zero', output)\n self.assertIn('0.0 B', output)\n self.assertNotIn('Scan incomplete', output)\n\n def test_unknown_logs_are_visible_and_make_user_summary_incomplete(self):\n with tempfile.TemporaryDirectory() as root:\n home = Path(root)\n (home / 'Library/Logs').mkdir(parents=True)\n code, output, _ = self.invoke(home, ['--user-only'], lambda p: None)\n self.assertEqual(1, code)\n self.assertIn('User Logs:', output)\n self.assertIn('Size: unknown', output)\n self.assertIn('Known user cache/log lower bound', output)\n\n def test_denied_log_path_cannot_be_treated_as_missing(self):\n real_stat = CACHES.os.stat\n with tempfile.TemporaryDirectory() as root:\n home = Path(root)\n\n def stat_path(path, *args, **kwargs):\n if str(path) == str(home / 'Library/Logs'):\n raise PermissionError('logs denied')\n return real_stat(path, *args, **kwargs)\n\n with patch.object(CACHES.os, 'stat', side_effect=stat_path):\n code, output, diagnostic = self.invoke(home, ['--user-only'], lambda p: 0)\n self.assertEqual(1, code)\n self.assertIn('Size: unknown', output)\n self.assertIn('logs denied', diagnostic)\n\n def test_unknown_dev_cache_is_visible_and_not_summed(self):\n with tempfile.TemporaryDirectory() as root:\n home = Path(root)\n for name in ('uv', 'go-build'):\n (home / name).mkdir()\n specs = [(name, str(home / name), None) for name in ('uv', 'go-build')]\n sizes = {'uv': None, 'go-build': 2 * 1024**2}\n code, output, _ = self.invoke(home, ['--user-only', '--include-dev', '--min-size', '1'],\n lambda p: sizes[Path(p).name], specs)\n self.assertEqual(1, code)\n self.assertIn('uv', output)\n self.assertIn('unknown', output)\n self.assertIn('keep', output)\n self.assertIn('Known developer-cache lower bound above threshold (incomplete): 2.0 MB', output)\n self.assertNotIn('No developer caches above', output)\n\n def test_unresolved_dev_paths_cannot_claim_empty_complete_scan(self):\n with tempfile.TemporaryDirectory() as root:\n home = Path(root)\n specs = [('uv', str(home / 'absent'), 'tool config unconfirmed')]\n code, output, _ = self.invoke(home, ['--user-only', '--include-dev'], lambda p: 0, specs)\n self.assertEqual(1, code)\n self.assertIn('paths unresolved', output)\n self.assertIn('tool config unconfirmed', output)\n self.assertIn('Scan incomplete', output)\n\n def test_system_unknown_is_not_hidden_after_top_ten(self):\n with tempfile.TemporaryDirectory() as root:\n home = Path(root)\n known = [(f'known{i}', f'/fixture/{i}', (20 - i) * 1024**2) for i in range(12)]\n system = known + [('failed', '/fixture/failed', None)]\n code, output, _ = self.invoke(home, [], lambda p: 0, scan=[[], system])\n self.assertEqual(1, code)\n self.assertIn('/fixture/failed', output)\n self.assertIn('unknown', output)\n self.assertIn('2 more measured entries', output)\n self.assertIn('Known displayed system-cache lower bound (incomplete)', output)\n self.assertIn('155.0 MB', output)\n\n def test_real_cli_exit_and_diagnostics_use_isolated_fake_du(self):\n with tempfile.TemporaryDirectory() as root:\n home = Path(root)\n (home / 'Library/Caches/failed').mkdir(parents=True)\n binaries = home / 'bin'\n binaries.mkdir()\n du = binaries / 'du'\n du.write_text(f'#!{sys.executable}\\nimport sys\\nprint(\"4096\\\\t\" + sys.argv[-1])\\n'\n 'print(\"fixture Permission denied\", file=sys.stderr)\\nsys.exit(1)\\n')\n du.chmod(0o755)\n env = {**os.environ, 'HOME': str(home), 'PATH': str(binaries)}\n result = subprocess.run([sys.executable, str(SCRIPT_PATH), '--user-only'],\n env=env, text=True, capture_output=True, timeout=10)\n self.assertEqual(1, result.returncode)\n self.assertIn('unknown', result.stdout)\n self.assertIn('Scan incomplete', result.stdout)\n self.assertIn('fixture Permission denied', result.stderr)\n self.assertIn('4096', result.stderr)\n self.assertNotIn('No cache directories found', result.stdout)\n\n\nif __name__ == '__main__':\n unittest.main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":13119,"content_sha256":"f72b2c9307130016824de10bdaa3bd916781ef6e855b85bfebbefcfcaae60b04"},{"filename":"tests/test_analyze_code_sign_clones.py","content":"#!/usr/bin/env python3\n\nimport importlib.util\nimport io\nimport plistlib\nimport tempfile\nimport unittest\nfrom contextlib import redirect_stderr\nfrom pathlib import Path\nfrom unittest.mock import patch\n\n\nSCRIPT_PATH = (\n Path(__file__).resolve().parents[1]\n / \"scripts\"\n / \"analyze_code_sign_clones.py\"\n)\nSPEC = importlib.util.spec_from_file_location(\n \"analyze_code_sign_clones\", SCRIPT_PATH\n)\nANALYZER = importlib.util.module_from_spec(SPEC)\nSPEC.loader.exec_module(ANALYZER)\n\n\nclass LsofParsingTests(unittest.TestCase):\n def test_parses_process_and_path_fields(self):\n records = ANALYZER.parse_lsof_fields(\n \"p123\\ncGoogle Chrome\\nntmp/root/code_sign_clone.ABC123/app\\n\"\n )\n self.assertEqual(\n [\n {\n \"pid\": \"123\",\n \"command\": \"Google Chrome\",\n \"path\": \"tmp/root/code_sign_clone.ABC123/app\",\n }\n ],\n records,\n )\n\n @patch.object(ANALYZER, \"run_command\")\n def test_empty_exit_one_is_complete_no_open_files(self, run_command):\n run_command.return_value.returncode = 1\n run_command.return_value.stdout = \"\"\n run_command.return_value.stderr = \"\"\n result = ANALYZER.scan_open_files(Path(\"/private/var/folders/a/b/X/root\"))\n self.assertTrue(result[\"complete\"])\n self.assertEqual([], result[\"records\"])\n\n @patch.object(ANALYZER, \"run_command\")\n def test_exit_one_with_records_is_complete_on_bundled_macos_lsof(\n self, run_command\n ):\n run_command.return_value.returncode = 1\n run_command.return_value.stdout = \"p123\\ncChrome\\nn/private/path\\n\"\n run_command.return_value.stderr = \"\"\n result = ANALYZER.scan_open_files(Path(\"/private/path\"))\n self.assertTrue(result[\"complete\"])\n self.assertEqual(\"/private/path\", result[\"records\"][0][\"path\"])\n\n @patch.object(ANALYZER, \"run_command\")\n def test_unexpected_exit_with_records_marks_scan_incomplete(self, run_command):\n run_command.return_value.returncode = 2\n run_command.return_value.stdout = \"p123\\ncChrome\\nn/private/path\\n\"\n run_command.return_value.stderr = \"\"\n result = ANALYZER.scan_open_files(Path(\"/private/path\"))\n self.assertFalse(result[\"complete\"])\n self.assertEqual(\"/private/path\", result[\"records\"][0][\"path\"])\n\n\nclass InventoryTests(unittest.TestCase):\n def make_clone(self, root, name, version=\"152.0.0.0\"):\n child = root / name\n plist_path = child / \"Browser.app.bundle\" / \"Contents\" / \"Info.plist\"\n plist_path.parent.mkdir(parents=True)\n with plist_path.open(\"wb\") as handle:\n plistlib.dump(\n {\n \"CFBundleIdentifier\": \"org.example.Browser\",\n \"CFBundleShortVersionString\": version,\n \"CFBundleExecutable\": \"Browser\",\n },\n handle,\n )\n return child\n\n @patch.object(ANALYZER, \"df_available_kib\", return_value=5000)\n @patch.object(ANALYZER, \"du_kib\", return_value=(100, None))\n @patch.object(ANALYZER, \"scan_open_files\")\n def test_active_child_excluded_and_inactive_child_hashed(\n self, scan_open_files, _du_kib, _df\n ):\n with tempfile.TemporaryDirectory() as temp_dir:\n root = Path(temp_dir) / \"org.example.Browser.code_sign_clone\"\n root.mkdir()\n active = self.make_clone(root, \"code_sign_clone.ACT123\")\n inactive = self.make_clone(root, \"code_sign_clone.INA123\")\n scan_open_files.return_value = {\n \"complete\": True,\n \"returncode\": 0,\n \"stderr\": \"\",\n \"records\": [\n {\n \"pid\": \"42\",\n \"command\": \"Browser\",\n \"path\": str(active / \"Browser.app.bundle/Contents/MacOS/Browser\"),\n }\n ],\n }\n\n report = ANALYZER.build_report([root.resolve()], [active])\n\n self.assertEqual([str(inactive.resolve())], report[\"candidate_paths\"])\n self.assertEqual(\n ANALYZER.candidate_hash([str(inactive.resolve())]),\n report[\"candidate_sha256\"],\n )\n statuses = {\n entry[\"path\"]: entry[\"status\"]\n for entry in report[\"roots\"][0][\"entries\"]\n }\n self.assertEqual(\"active\", statuses[str(active.resolve())])\n self.assertEqual(\"inactive\", statuses[str(inactive.resolve())])\n\n @patch.object(ANALYZER, \"df_available_kib\", return_value=5000)\n @patch.object(ANALYZER, \"du_kib\", return_value=(100, None))\n @patch.object(ANALYZER, \"scan_open_files\")\n def test_incomplete_lsof_never_emits_inactive_candidate(\n self, scan_open_files, _du_kib, _df\n ):\n with tempfile.TemporaryDirectory() as temp_dir:\n root = Path(temp_dir) / \"org.example.Browser.code_sign_clone\"\n root.mkdir()\n child = self.make_clone(root, \"code_sign_clone.UNK123\")\n scan_open_files.return_value = {\n \"complete\": False,\n \"returncode\": 1,\n \"stderr\": \"partial scan\",\n \"records\": [],\n }\n\n report = ANALYZER.build_report([root.resolve()], [])\n\n self.assertEqual([], report[\"candidate_paths\"])\n entry = report[\"roots\"][0][\"entries\"][0]\n self.assertEqual(str(child.resolve()), entry[\"path\"])\n self.assertEqual(\"unknown\", entry[\"status\"])\n\n @patch.object(ANALYZER, \"df_available_kib\", return_value=5000)\n @patch.object(ANALYZER, \"du_kib\", return_value=(100, None))\n @patch.object(ANALYZER, \"scan_open_files\")\n def test_preserved_active_child_can_exit_without_expiring_candidate_hash(\n self, scan_open_files, _du_kib, _df\n ):\n with tempfile.TemporaryDirectory() as temp_dir:\n root = Path(temp_dir) / \"org.example.Browser.code_sign_clone\"\n root.mkdir()\n kept = self.make_clone(root, \"code_sign_clone.KEP123\")\n candidate = self.make_clone(root, \"code_sign_clone.CAN123\")\n scan_open_files.return_value = {\n \"complete\": True,\n \"returncode\": 1,\n \"stderr\": \"\",\n \"records\": [\n {\n \"pid\": \"42\",\n \"command\": \"Browser\",\n \"path\": str(kept / \"Browser.app.bundle/Contents/MacOS/Browser\"),\n }\n ],\n }\n before = ANALYZER.build_report([root.resolve()], [kept])\n\n scan_open_files.return_value = {\n \"complete\": True,\n \"returncode\": 1,\n \"stderr\": \"\",\n \"records\": [],\n }\n after = ANALYZER.build_report([root.resolve()], [kept])\n\n self.assertEqual([str(candidate.resolve())], before[\"candidate_paths\"])\n self.assertEqual(before[\"candidate_paths\"], after[\"candidate_paths\"])\n self.assertEqual(before[\"candidate_sha256\"], after[\"candidate_sha256\"])\n\n @patch.object(ANALYZER, \"df_available_kib\", return_value=5000)\n @patch.object(ANALYZER, \"du_kib\", return_value=(100, None))\n @patch.object(ANALYZER, \"scan_open_files\")\n def test_candidate_becoming_active_expires_candidate_hash(\n self, scan_open_files, _du_kib, _df\n ):\n with tempfile.TemporaryDirectory() as temp_dir:\n root = Path(temp_dir) / \"org.example.Browser.code_sign_clone\"\n root.mkdir()\n candidate = self.make_clone(root, \"code_sign_clone.CAN123\")\n scan_open_files.return_value = {\n \"complete\": True,\n \"returncode\": 1,\n \"stderr\": \"\",\n \"records\": [],\n }\n before = ANALYZER.build_report([root.resolve()], [])\n\n scan_open_files.return_value = {\n \"complete\": True,\n \"returncode\": 1,\n \"stderr\": \"\",\n \"records\": [\n {\n \"pid\": \"42\",\n \"command\": \"Browser\",\n \"path\": str(candidate / \"Browser.app.bundle/Contents/MacOS/Browser\"),\n }\n ],\n }\n after = ANALYZER.build_report([root.resolve()], [])\n\n self.assertNotEqual(before[\"candidate_sha256\"], after[\"candidate_sha256\"])\n self.assertEqual([], after[\"candidate_paths\"])\n\n\nclass ManifestTests(unittest.TestCase):\n def report(self, candidate):\n return {\n \"candidate_sha256\": ANALYZER.candidate_hash([candidate]),\n \"candidate_paths\": [candidate],\n \"roots\": [\n {\n \"entries\": [\n {\n \"path\": candidate,\n \"status\": \"inactive\",\n }\n ]\n }\n ],\n }\n\n def test_manifest_requires_matching_hash_and_refuses_overwrite(self):\n with tempfile.TemporaryDirectory() as temp_dir:\n candidate = \"/private/var/folders/a/b/X/root/code_sign_clone.ABC123\"\n report = self.report(candidate)\n output = Path(temp_dir) / \"approved.txt\"\n\n with self.assertRaisesRegex(ANALYZER.AnalysisError, \"hash changed\"):\n ANALYZER.write_manifest(output, report, \"wrong\")\n\n written = ANALYZER.write_manifest(\n output, report, report[\"candidate_sha256\"]\n )\n self.assertEqual(output.resolve(), written)\n self.assertEqual(candidate + \"\\n\", output.read_text())\n self.assertEqual(0o600, output.stat().st_mode & 0o777)\n\n with self.assertRaisesRegex(ANALYZER.AnalysisError, \"overwrite\"):\n ANALYZER.write_manifest(\n output, report, report[\"candidate_sha256\"]\n )\n\n\nclass SafetyBoundaryTests(unittest.TestCase):\n def test_manifest_cli_requires_expected_candidate_hash(self):\n errors = io.StringIO()\n with redirect_stderr(errors):\n exit_code = ANALYZER.main([\"--write-manifest\", \"/private/tmp/list\"])\n self.assertEqual(2, exit_code)\n self.assertIn(\"requires --expect-candidate-sha\", errors.getvalue())\n\n def test_explicit_root_outside_var_folders_is_rejected(self):\n with tempfile.TemporaryDirectory() as temp_dir:\n root = Path(temp_dir) / \"org.example.Browser.code_sign_clone\"\n root.mkdir()\n with self.assertRaisesRegex(\n ANALYZER.AnalysisError,\n \"must be below /private/var/folders\",\n ):\n ANALYZER.validate_root(root)\n\n\nif __name__ == \"__main__\":\n unittest.main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":10938,"content_sha256":"65132d753702adf517ff4e87322fa20b6d670564426538224be5423dbe224567"},{"filename":"tests/test_analyze_large_files.py","content":"#!/usr/bin/env python3\n\"\"\"Injected find/stat outcomes for analyze_large_files.py; no real home scan.\n\nEvery failure shape (find rc!=0 with empty or partial stdout, timeout, unreadable\nfile) must surface as an incomplete scan, never as a clean \"no large files\"\nsuccess. Healthy empty stays a success; healthy findings keep the existing\ndisplay and exit 0.\n\"\"\"\n\nimport importlib.util\nimport subprocess\nimport sys\nimport tempfile\nimport unittest\nfrom contextlib import redirect_stderr, redirect_stdout\nfrom io import StringIO\nfrom pathlib import Path\nfrom unittest.mock import patch\n\n\nSCRIPT_PATH = Path(__file__).resolve().parents[1] / 'scripts' / 'analyze_large_files.py'\nSKILL_PATH = Path(__file__).resolve().parents[1] / 'SKILL.md'\nSPEC = importlib.util.spec_from_file_location('analyze_large_files', SCRIPT_PATH)\nLARGE_FILES = importlib.util.module_from_spec(SPEC)\nSPEC.loader.exec_module(LARGE_FILES)\n\n\ndef find_result(returncode, stdout='', stderr=''):\n return subprocess.CompletedProcess([], returncode, stdout, stderr)\n\n\nclass FindLargeFilesUnitTests(unittest.TestCase):\n def setUp(self):\n tmp = tempfile.TemporaryDirectory()\n self.addCleanup(tmp.cleanup)\n self.root = Path(tmp.name).resolve()\n self.big = self.root / 'big.mov'\n self.big.write_bytes(b'x' * 4096)\n\n def test_healthy_empty_scan_is_complete_and_empty(self):\n with patch.object(LARGE_FILES.subprocess, 'run', return_value=find_result(0, '', '')):\n files, issues = LARGE_FILES.find_large_files(str(self.root), 100 * 1024 * 1024, 50)\n self.assertEqual(files, [])\n self.assertEqual(issues, [])\n\n def test_find_rc1_empty_output_reports_incomplete_not_clean(self):\n with patch.object(LARGE_FILES.subprocess, 'run',\n return_value=find_result(1, '', 'Permission denied')):\n files, issues = LARGE_FILES.find_large_files(str(self.root), 100 * 1024 * 1024, 50)\n self.assertEqual(files, [])\n self.assertEqual(len(issues), 1)\n self.assertIn('incomplete', issues[0])\n self.assertIn('Permission denied', issues[0])\n\n def test_find_rc1_partial_findings_survive_and_mark_incomplete(self):\n with patch.object(LARGE_FILES.subprocess, 'run',\n return_value=find_result(1, f'{self.big}\\n', 'some dir: Permission denied')):\n files, issues = LARGE_FILES.find_large_files(str(self.root), 100 * 1024 * 1024, 50)\n self.assertEqual([path for path, _ in files], [self.big])\n self.assertEqual(len(issues), 1)\n\n def test_timeout_keeps_partial_findings_marked_incomplete(self):\n expired = subprocess.TimeoutExpired(['find'], 120, output=f'{self.big}\\n'.encode())\n with patch.object(LARGE_FILES.subprocess, 'run', side_effect=expired):\n files, issues = LARGE_FILES.find_large_files(str(self.root), 100 * 1024 * 1024, 50)\n self.assertEqual([path for path, _ in files], [self.big])\n self.assertEqual(len(issues), 1)\n self.assertIn('timed out', issues[0])\n\n def test_timeout_without_output_is_incomplete_not_clean(self):\n expired = subprocess.TimeoutExpired(['find'], 120)\n with patch.object(LARGE_FILES.subprocess, 'run', side_effect=expired):\n files, issues = LARGE_FILES.find_large_files(str(self.root), 100 * 1024 * 1024, 50)\n self.assertEqual(files, [])\n self.assertEqual(len(issues), 1)\n\n def test_unmeasurable_file_marks_scan_incomplete(self):\n with patch.object(LARGE_FILES.subprocess, 'run',\n return_value=find_result(0, f'{self.big}\\n')), \\\n patch.object(Path, 'stat', side_effect=PermissionError(13, 'Permission denied')):\n files, issues = LARGE_FILES.find_large_files(str(self.root), 100 * 1024 * 1024, 50)\n self.assertEqual(files, [])\n self.assertEqual(len(issues), 1)\n self.assertIn('could not measure', issues[0])\n\n def test_vanished_file_is_an_incomplete_read_not_healthy_empty(self):\n gone = self.root / 'deleted-while-scanning.mov'\n with patch.object(LARGE_FILES.subprocess, 'run',\n return_value=find_result(0, f'{gone}\\n')):\n files, issues = LARGE_FILES.find_large_files(str(self.root), 100 * 1024 * 1024, 50)\n self.assertEqual(files, [])\n self.assertEqual(len(issues), 1)\n self.assertIn('could not measure', issues[0])\n\n\nclass MainExitTests(unittest.TestCase):\n def setUp(self):\n tmp = tempfile.TemporaryDirectory()\n self.addCleanup(tmp.cleanup)\n self.root = Path(tmp.name).resolve()\n self.big = self.root / 'big.mov'\n self.big.write_bytes(b'x' * 4096)\n\n def run_main(self, run_return=None, run_side_effect=None):\n out, err = StringIO(), StringIO()\n with patch.object(LARGE_FILES.subprocess, 'run',\n return_value=run_return, side_effect=run_side_effect), \\\n patch.object(LARGE_FILES.Path, 'home', return_value=self.root.parent), \\\n patch.object(LARGE_FILES.sys, 'argv',\n ['probe', '--path', str(self.root), '--threshold', '1']), \\\n redirect_stdout(out), redirect_stderr(err):\n code = LARGE_FILES.main()\n return code, out.getvalue(), err.getvalue()\n\n def test_healthy_empty_prints_success_and_exits_zero(self):\n code, out, err = self.run_main(run_return=find_result(0, '', ''))\n self.assertEqual(code, 0)\n self.assertIn('No large files found', out)\n self.assertNotIn('INCOMPLETE', out)\n\n def test_find_rc1_empty_never_claims_no_large_files(self):\n code, out, err = self.run_main(run_return=find_result(1, '', 'Permission denied'))\n self.assertEqual(code, 1)\n self.assertNotIn('No large files found', out)\n self.assertIn('INCOMPLETE SCAN', out)\n self.assertIn('not a clean result', err)\n\n def test_find_rc1_partial_findings_displayed_marked_incomplete_exit_one(self):\n code, out, err = self.run_main(\n run_return=find_result(1, f'{self.big}\\n', 'partial: Permission denied'))\n self.assertEqual(code, 1)\n self.assertIn('INCOMPLETE SCAN', out)\n self.assertIn('big.mov', out)\n self.assertIn('not exhaustive', err)\n\n def test_stat_failure_with_no_findings_never_claims_no_large_files(self):\n expired_stat = PermissionError(13, 'Permission denied')\n with patch.object(Path, 'stat', side_effect=expired_stat):\n code, out, err = self.run_main(run_return=find_result(0, f'{self.big}\\n'))\n self.assertEqual(code, 1)\n self.assertNotIn('No large files found', out)\n self.assertIn('INCOMPLETE SCAN', out)\n\n def test_timeout_partial_findings_displayed_exit_one(self):\n expired = subprocess.TimeoutExpired(['find'], 120, output=f'{self.big}\\n'.encode())\n code, out, err = self.run_main(run_side_effect=expired)\n self.assertEqual(code, 1)\n self.assertIn('INCOMPLETE SCAN', out)\n self.assertIn('big.mov', out)\n self.assertNotIn('No large files found', out)\n\n def test_healthy_findings_keep_existing_display_and_exit_zero(self):\n code, out, err = self.run_main(run_return=find_result(0, f'{self.big}\\n'))\n self.assertEqual(code, 0)\n self.assertIn('Found 1 large files', out)\n self.assertIn('big.mov', out)\n self.assertNotIn('INCOMPLETE', out)\n\n\nclass DocumentedCommandTests(unittest.TestCase):\n def test_documented_threshold_reaches_real_scan_entry(self):\n command_lines = [\n line for line in SKILL_PATH.read_text(encoding='utf-8').splitlines()\n if 'uv run scripts/analyze_large_files.py' in line and '--threshold' in line\n ]\n self.assertEqual(len(command_lines), 1)\n threshold = command_lines[0].split('--threshold', 1)[1].split()[0]\n\n with tempfile.TemporaryDirectory() as tmp:\n result = subprocess.run(\n [sys.executable, str(SCRIPT_PATH),\n '--threshold', threshold, '--path', tmp],\n capture_output=True,\n text=True,\n timeout=10,\n check=False,\n )\n\n self.assertEqual(result.returncode, 0, result.stderr)\n self.assertIn(f'Searching for files larger than {threshold} MB', result.stdout)\n self.assertIn('No large files found', result.stdout)\n\n\nif __name__ == '__main__':\n unittest.main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":8481,"content_sha256":"77ba506b1f5adb65ad88d158fabc1abfcb95f738af5ba9ab30be16138f8fd2a0"},{"filename":"tests/test_cleanup_report.py","content":"#!/usr/bin/env python3\n\nimport importlib.util\nimport io\nimport unittest\nfrom contextlib import redirect_stdout\nfrom pathlib import Path\nfrom unittest.mock import patch\n\n\nSCRIPT_PATH = Path(__file__).resolve().parents[1] / 'scripts' / 'cleanup_report.py'\nSPEC = importlib.util.spec_from_file_location('cleanup_report', SCRIPT_PATH)\nCLEANUP_REPORT = importlib.util.module_from_spec(SPEC)\nSPEC.loader.exec_module(CLEANUP_REPORT)\n\n\nclass DiskUsageTests(unittest.TestCase):\n @patch.object(CLEANUP_REPORT.subprocess, 'run')\n def test_default_volume_uses_data_volume(self, run):\n run.return_value.returncode = 0\n run.return_value.stdout = (\n 'Filesystem 1024-blocks Used Available Capacity Mounted on\\n'\n '/dev/disk3s5 239362496 194050916 14493920 94% '\n '/System/Volumes/Data\\n'\n )\n\n usage = CLEANUP_REPORT.get_disk_usage()\n\n run.assert_called_once_with(\n ['/bin/df', '-k', '/System/Volumes/Data'],\n capture_output=True,\n text=True,\n timeout=10,\n )\n self.assertEqual('/System/Volumes/Data', usage['volume'])\n self.assertEqual(14493920 * 1024, usage['available'])\n self.assertEqual(94, usage['percent'])\n\n @patch.object(CLEANUP_REPORT.subprocess, 'run')\n def test_custom_volume_is_passed_to_df(self, run):\n run.return_value.returncode = 0\n run.return_value.stdout = (\n 'Filesystem 1024-blocks Used Available Capacity Mounted on\\n'\n '/dev/disk4s1 1000 400 600 40% /Volumes/External\\n'\n )\n\n usage = CLEANUP_REPORT.get_disk_usage('/Volumes/External')\n\n run.assert_called_once_with(\n ['/bin/df', '-k', '/Volumes/External'],\n capture_output=True,\n text=True,\n timeout=10,\n )\n self.assertEqual('/Volumes/External', usage['volume'])\n\n @patch.object(CLEANUP_REPORT.subprocess, 'run')\n def test_snapshot_timestamp_has_explicit_offset(self, run):\n run.return_value.returncode = 0\n run.return_value.stdout = (\n 'Filesystem 1024-blocks Used Available Capacity Mounted on\\n'\n '/dev/disk3s5 1000 400 600 40% /System/Volumes/Data\\n'\n )\n usage = CLEANUP_REPORT.get_disk_usage()\n parsed = CLEANUP_REPORT.datetime.fromisoformat(usage['timestamp'])\n self.assertIsNotNone(parsed.utcoffset())\n\n\nclass ComparisonTests(unittest.TestCase):\n def snapshot(self, volume):\n return {\n 'total': 1000,\n 'used': 500,\n 'available': 500,\n 'percent': 50,\n 'volume': volume,\n 'timestamp': '2026-08-24T01:00:00+08:00',\n }\n\n def test_volume_mismatch_fails_fast(self):\n before = self.snapshot('/System/Volumes/Data')\n after = self.snapshot('/Volumes/External')\n\n with self.assertRaisesRegex(ValueError, 'volume mismatch'):\n CLEANUP_REPORT.generate_report(before, after)\n\n def test_legacy_snapshot_without_volume_fails_fast(self):\n before = self.snapshot('/System/Volumes/Data')\n before.pop('volume')\n after = self.snapshot('/System/Volumes/Data')\n\n with self.assertRaisesRegex(ValueError, 'missing its volume'):\n CLEANUP_REPORT.generate_report(before, after)\n\n def test_matching_volume_reports_successfully(self):\n before = self.snapshot('/System/Volumes/Data')\n after = self.snapshot('/System/Volumes/Data')\n after['used'] = 300\n after['available'] = 700\n after['timestamp'] = '2026-08-24T01:05:00+08:00'\n\n output = io.StringIO()\n with redirect_stdout(output):\n CLEANUP_REPORT.generate_report(before, after)\n\n self.assertIn('Volume: /System/Volumes/Data', output.getvalue())\n self.assertIn('Recovered:', output.getvalue())\n self.assertIn('Before: 2026-08-24T01:00:00+08:00', output.getvalue())\n self.assertIn('After: 2026-08-24T01:05:00+08:00', output.getvalue())\n\n def test_after_snapshot_cannot_predate_before(self):\n before = self.snapshot('/System/Volumes/Data')\n after = self.snapshot('/System/Volumes/Data')\n after['timestamp'] = '2026-08-24T00:59:00+08:00'\n\n with self.assertRaisesRegex(ValueError, 'predates before'):\n CLEANUP_REPORT.generate_report(before, after)\n\n def test_naive_timestamp_is_rejected(self):\n before = self.snapshot('/System/Volumes/Data')\n after = self.snapshot('/System/Volumes/Data')\n before['timestamp'] = '2026-08-24T01:00:00'\n\n with self.assertRaisesRegex(ValueError, 'explicit UTC offsets'):\n CLEANUP_REPORT.generate_report(before, after)\n\n\nclass MainExitTests(unittest.TestCase):\n @patch.object(CLEANUP_REPORT, 'save_snapshot', return_value=None)\n @patch('sys.argv', ['cleanup_report.py', '--snapshot', 'before'])\n def test_before_snapshot_failure_returns_nonzero(self, _save):\n output = io.StringIO()\n with redirect_stdout(output):\n exit_code = CLEANUP_REPORT.main()\n self.assertEqual(1, exit_code)\n self.assertIn('Capturing disk usage before cleanup', output.getvalue())\n\n\nif __name__ == '__main__':\n unittest.main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":5233,"content_sha256":"65d6be128acfc67b4af3b9c48c12d4b5a6b9adced6b0ccdc9819b6fc5e3df675"},{"filename":"tests/test_cow_workflows.py","content":"import ctypes\nimport fcntl\nimport importlib.util\nimport io\nimport json\nimport os\nfrom pathlib import Path\nimport sys\nimport subprocess\nimport tempfile\nimport unittest\nfrom unittest import mock\n\nSCRIPTS=Path(__file__).parents[1]/'scripts'\nsys.path.insert(0,str(SCRIPTS))\nimport batch_budget\nimport stream_accounting\n\n\nclass BudgetTests(unittest.TestCase):\n def test_ceiling_cannot_close_gap(self):\n r=batch_budget.assess(dict(available_bytes=159_000_000_000,target_available_bytes=200_000_000_000,remaining_items=2500,pilot_items=10,pilot_seconds=100,reclaim_upper_bytes=4_000_000_000))\n self.assertEqual(r['upper_bound_remaining_gap_bytes'],37_000_000_000)\n self.assertEqual(r['estimated_remaining_seconds'],25000)\n self.assertIn('insufficient',r['decision']);self.assertFalse(r['capacity_goal_complete'])\n\n def test_unknown_ceiling_and_met_watermark(self):\n v=dict(available_bytes=159,target_available_bytes=200,remaining_items=0,pilot_items=1,pilot_seconds=2,reclaim_upper_bytes=None)\n self.assertIn('unknown',batch_budget.assess(v)['decision'])\n v['available_bytes']=201;self.assertIn('sustainability_unverified',batch_budget.assess(v)['decision'])\n\n def test_missing_null_boolean_negative(self):\n v=dict(available_bytes=0,target_available_bytes=200,remaining_items=4,pilot_items=2,pilot_seconds=3,reclaim_upper_bytes=1)\n for k in v:\n for value in [None,True,-1,float('nan')]:\n if k=='reclaim_upper_bytes' and value is None:continue\n with self.assertRaises((ValueError,KeyError,TypeError)):batch_budget.assess(dict(v,**{k:value}))\n del v['reclaim_upper_bytes']\n with self.assertRaises(KeyError):batch_budget.assess(v)\n\n\nclass AccountingTests(unittest.TestCase):\n def row(self,path,ino,refs=2,links=1):\n return dict(path=path,device=1,inode=ino,logical_bytes=10,allocated_bytes=4096,nlink=links,clone_id=8,full_clone_refs=refs,extended_flags=0x40,stable=True)\n\n def test_internal_shared_once_and_external_excluded(self):\n r=stream_accounting.account([self.row('a',1),self.row('b',2)])\n self.assertEqual(r['internal_allocated_upper_bytes'],4096);self.assertEqual(r['path_accounted_allocated_bytes'],8192)\n r=stream_accounting.account([self.row('a',1,3),self.row('b',2,3)])\n self.assertEqual(r['internal_allocated_upper_bytes'],0);self.assertEqual(len(r['external_or_missing_reference_streams']),1)\n\n def test_hardlink_paths_not_clone_refs(self):\n r=stream_accounting.account([self.row('a',1,1,2),self.row('b',1,1,2)])\n self.assertEqual(r['internal_allocated_upper_bytes'],4096)\n r=stream_accounting.account([self.row('a',1,1,2)])\n self.assertEqual(r['internal_allocated_upper_bytes'],0)\n\n def test_missing_null_or_unstable_unknown(self):\n for k in ['inode','clone_id','full_clone_refs','extended_flags','stable']:\n for value in [None,False]:\n a=self.row('a',1);a[k]=value\n r=stream_accounting.account([a]);self.assertEqual(r['internal_allocated_upper_bytes'],0)\n a=self.row('a',1);del a[k];self.assertEqual(stream_accounting.account([a])['internal_allocated_upper_bytes'],0)\n with self.assertRaises(ValueError):stream_accounting.account([])\n\n\[email protected](sys.platform=='darwin','native APFS acceptance requires macOS')\nclass NativeTests(unittest.TestCase):\n def setUp(self):\n import apfs_cow\n self.c=apfs_cow\n self.tmp=tempfile.TemporaryDirectory();self.r=Path(self.tmp.name).resolve()\n self.source=self.r/'source';self.target=self.r/'target'\n payload=b'cow-fixture-0123'*65536\n self.source.write_bytes(payload);self.target.write_bytes(payload)\n for p,value in [(self.source,b''),(self.target,b'value')]:\n buf=ctypes.create_string_buffer(value)\n self.c.core.native.ok(self.c.core.native.setx(os.fsencode(p),b'com.apple.quarantine',buf,len(value),0,0),'fixture xattr')\n self.source.chmod(0o444)\n self.pairs=[dict(source=str(self.source),target=str(self.target))]\n self.manifest=self.r/'manifest.json'\n\n def tearDown(self):self.tmp.cleanup()\n\n def test_native_preserves_path_bytes_metadata_and_shared_source(self):\n self.c.prepare(self.pairs,self.manifest,1024*1024,None)\n m,h=self.c.load(self.manifest);cfg=m['objects'][0];old=self.target.stat().st_ino\n # Ordinary data is not blocked by an unrelated LFS process.\n with mock.patch.object(self.c,'LFS_QUIET',side_effect=AssertionError('ordinary must not use global veto')):\n s=self.c.execute(self.manifest,h,self.r/'run')\n self.assertEqual(s['status'],'complete');self.assertNotEqual(self.target.stat().st_ino,old)\n self.assertEqual(self.c.core.sha(self.target),cfg['oid'])\n self.assertEqual(self.c.core.metadata(self.target),cfg['target_metadata'])\n self.assertEqual(self.c.core.metadata(self.source),cfg['source_metadata'])\n self.assertEqual(self.c.core.native.clone_info(self.target)['clone_id'],self.c.core.native.clone_info(self.source)['clone_id'])\n self.assertFalse(Path(cfg['exact_temporary_path']).exists())\n\n def test_pause_and_exact_remainder_without_mutation(self):\n self.c.prepare(self.pairs,self.manifest,1024*1024,None);m,h=self.c.load(self.manifest)\n pause=self.r/'pause';pause.touch();old=self.target.stat().st_ino\n self.assertEqual(self.c.execute(self.manifest,h,self.r/'run',pause)['status'],'paused')\n self.assertEqual(self.target.stat().st_ino,old)\n out=self.r/'remainder.json';v=self.c.remainder(self.manifest,self.r/'run/summary.json',out)\n self.assertEqual(v,dict(selected=1,completed_excluded=0))\n self.assertEqual(json.loads(out.read_text())['objects'],m['objects'])\n with self.assertRaises(ValueError):self.c.execute(out,'0'*64,self.r/'bad')\n self.assertFalse((self.r/'bad').exists())\n\n def test_postswap_failure_rolls_back_same_inode(self):\n self.c.prepare(self.pairs,self.manifest,1024*1024,None);m,h=self.c.load(self.manifest);cfg=m['objects'][0]\n native=self.c.core.native;original=native.swap;calls=[]\n def swapping(a,b,flags):\n rc=original(a,b,flags);calls.append(rc);return rc\n original_guard=self.c.guard\n def guard(c):\n if calls:raise RuntimeError('synthetic post-swap occupied condition')\n return original_guard(c)\n with mock.patch.object(native,'swap',side_effect=swapping),mock.patch.object(self.c,'guard',side_effect=guard):\n with self.assertRaises(RuntimeError):self.c.execute(self.manifest,h,self.r/'failed')\n self.assertEqual(self.target.stat().st_ino,cfg['cache_stat'][1]);self.assertEqual(len(calls),2)\n self.assertFalse(Path(cfg['exact_temporary_path']).exists())\n self.assertEqual(self.c.core.sha(self.target),cfg['oid'])\n # Rollback ctime is not silently accepted by a refreshed remainder.\n with self.assertRaises(RuntimeError):self.c.remainder(self.manifest,self.r/'failed/summary.json',self.r/'invalid-remainder')\n\n def test_in_use_failure_and_alias_hardlink_protected_no_mutation(self):\n with mock.patch.object(self.c.core,'open_files',return_value=[dict(pid=123,path=str(self.target))]):\n with self.assertRaises(RuntimeError):self.c.prepare(self.pairs,self.manifest,1,None)\n self.assertFalse(self.manifest.exists())\n alias=self.r/'alias';alias.symlink_to(self.target)\n with self.assertRaises(RuntimeError):self.c.physical(alias)\n with self.assertRaises(ValueError):self.c.physical(self.r/'.codex/sessions/x')\n os.link(self.target,self.r/'hardlink')\n with self.assertRaises(ValueError):self.c.prepare(self.pairs,self.manifest,1,None)\n\n def test_lsof_stderr_is_unknown_not_idle(self):\n for rc in (0,1):\n with mock.patch.object(self.c.core.subprocess,'run',return_value=subprocess.CompletedProcess([],rc,'','permission visibility unknown')):\n with self.assertRaises(RuntimeError):self.c.core.open_files([self.target])\n\n def test_busy_lock_refuses_and_releases_at_boundary(self):\n lock=self.r/'official.lock';lock.touch()\n self.c.prepare(self.pairs,self.manifest,1024*1024,lock);m,h=self.c.load(self.manifest)\n fd=os.open(lock,os.O_RDONLY)\n try:\n fcntl.flock(fd,fcntl.LOCK_EX|fcntl.LOCK_NB)\n with self.assertRaises(RuntimeError):self.c.execute(self.manifest,h,self.r/'busy')\n self.assertEqual(self.target.stat().st_ino,m['objects'][0]['cache_stat'][1])\n finally:os.close(fd)\n # After a completed item, another independently opened descriptor can lock.\n self.c.execute(self.manifest,h,self.r/'unblocked')\n fd=os.open(lock,os.O_RDONLY)\n try:fcntl.flock(fd,fcntl.LOCK_EX|fcntl.LOCK_NB)\n finally:os.close(fd)\n\n def test_completed_prefix_excluded_and_pending_exact(self):\n second=self.r/'target2';second.write_bytes(self.source.read_bytes())\n pairs=self.pairs+[dict(source=str(self.source),target=str(second))]\n self.c.prepare(pairs,self.manifest,1024*1024,None);m,h=self.c.load(self.manifest)\n pause=self.r/'pause';original=self.c.core.execute\n def one(*args,**kwargs):\n result=original(*args,**kwargs);pause.touch();return result\n with mock.patch.object(self.c.core,'execute',side_effect=one):\n result=self.c.execute(self.manifest,h,self.r/'partial',pause)\n self.assertEqual((result['status'],len(result['completed'])),('paused',1))\n out=self.r/'remaining';self.c.remainder(self.manifest,self.r/'partial/summary.json',out)\n pending,ph=self.c.load(out);self.assertEqual(pending['objects'],m['objects'][1:])\n # Resume processes the second only, retaining the completed target inode.\n first_inode=self.target.stat().st_ino\n self.c.execute(out,ph,self.r/'resumed')\n self.assertEqual(self.target.stat().st_ino,first_inode)\n\n def test_actual_cli_prepare_rejects_invalid_input_without_manifest(self):\n pairs=self.r/'pairs.json';pairs.write_text('{}')\n result=subprocess.run([sys.executable,str(SCRIPTS/'apfs_cow.py'),'prepare','--pairs',str(pairs),'--output',str(self.manifest),'--reserve-bytes','1'],capture_output=True,text=True)\n self.assertEqual(result.returncode,2);self.assertFalse(self.manifest.exists())\n\n def test_metadata_preflight_reports_names_without_values(self):\n target=dict(self.c.core.metadata(self.target),xattrs={'com.apple.quarantine':'76616c7565'})\n for target_attrs, source_attrs, expected in [\n (target['xattrs'], dict(target['xattrs']), dict(source_only=[],target_only=[],different_values=[])),\n (target['xattrs'], {}, dict(source_only=[],target_only=['com.apple.quarantine'],different_values=[])),\n ({}, {'com.apple.quarantine':''}, dict(source_only=['com.apple.quarantine'],target_only=[],different_values=[])),\n (target['xattrs'], {'com.apple.quarantine':''}, dict(source_only=[],target_only=[],different_values=['com.apple.quarantine'])),\n (target['xattrs'], {**target['xattrs'],'com.apple.provenance':'1234'}, dict(source_only=['com.apple.provenance'],target_only=[],different_values=[])),\n ]:\n source=dict(target,xattrs=source_attrs)\n target_meta=dict(target,xattrs=target_attrs)\n output=io.StringIO()\n with mock.patch.object(self.c.core,'metadata',side_effect=[target_meta,source]),mock.patch('sys.stderr',output):\n self.c.metadata_preflight(self.target,self.source)\n event=json.loads(output.getvalue())\n self.assertEqual(event['metadata_differences']['xattrs'],expected)\n self.assertEqual(event['clone_metadata_compatibility'],'unverified')\n self.assertNotIn('1234',output.getvalue())\n self.assertNotIn(str(self.source),output.getvalue())\n\n def test_prepare_metadata_notice_precedes_payload_hash(self):\n original=self.c.core.stable_hash\n output=io.StringIO()\n def hashing(*args):\n event=json.loads(output.getvalue().splitlines()[0])\n self.assertEqual(event['event'],'metadata_preflight')\n self.assertEqual(event['metadata_differences']['xattrs']['different_values'],['com.apple.quarantine'])\n return original(*args)\n with mock.patch('sys.stderr',output),mock.patch.object(self.c.core,'stable_hash',side_effect=hashing):\n self.c.prepare(self.pairs,self.manifest,1024*1024,None)\n m,h=self.c.load(self.manifest)\n self.assertEqual(m['schema'],'apfs-explicit-pairs-v1')\n self.assertNotIn('verification',m)\n self.c.check_pair(m['objects'][0])\n\n def test_existing_metadata_exclusions_precede_payload_hash(self):\n healthy=self.c.core.metadata(self.target)\n for changed in [dict(flags=1),dict(mode=0o4644),dict(uid=os.getuid()+1),\n dict(xattrs={'com.apple.ResourceFork':'00'}),\n dict(xattrs={'synthetic.large':'00'*(1024*1024+1)})]:\n for side in (0,1):\n metadata=[healthy,healthy];metadata[side]=dict(healthy,**changed)\n with mock.patch.object(self.c.core,'metadata',side_effect=metadata),mock.patch.object(self.c.core,'stable_hash') as hashed,mock.patch('sys.stderr',io.StringIO()):\n with self.assertRaises(RuntimeError):\n self.c.prepare(self.pairs,self.manifest,1,None)\n hashed.assert_not_called()\n self.assertFalse(self.manifest.exists())\n\n def test_cli_snapshot_scope_and_legacy_fields(self):\n pairs=self.r/'pairs.json';pairs.write_text(json.dumps(self.pairs))\n before=[self.c.core.ident(p) for p in (self.target,self.source)]\n commands=[['prepare','--pairs',str(pairs),'--output',str(self.manifest),'--reserve-bytes','1048576'],['check',str(self.manifest)]]\n for command in commands:\n result=subprocess.run([sys.executable,str(SCRIPTS/'apfs_cow.py'),*command],capture_output=True,text=True)\n self.assertEqual(result.returncode,0,result.stderr)\n value=json.loads(result.stdout)\n self.assertFalse(value['mutation']);self.assertEqual(len(value['manifest_sha256']),64)\n self.assertEqual(value['verification'],dict(bytes_identity_metadata_snapshot='verified',clone_metadata_compatibility='unverified',physical_release='unverified'))\n self.assertEqual(json.loads(result.stderr.splitlines()[0])['event'],'metadata_preflight')\n self.assertEqual(value['objects'],1)\n self.assertEqual(before,[self.c.core.ident(p) for p in (self.target,self.source)])\n\n\nif __name__=='__main__':unittest.main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":14891,"content_sha256":"77cdaf70868ef4ff5ecde1e16547364148e5fb41dfab1c74f68d8ea639b2a35a"},{"filename":"tests/test_gate_checker.py","content":"#!/usr/bin/env python3\n\n\"\"\"Tests for the Phase 2 entry-gate checker.\n\nRun with:\n uv run python3 -m unittest discover -s tests -v\n uv run python3 tests/test_gate_checker.py\n\nThe calibration classes at the bottom are the load-bearing ones. They run\nthe checker over real historical outputs — a plan that passed an independent\nreview, and the report whose failures the gate was written to catch — because a\nchecker that only passes its own unit tests proves nothing about whether it\ncatches the failure it exists for, or whether it would block a healthy plan.\n\"\"\"\n\nimport importlib.util\nimport io\nimport json\nimport os\nimport tempfile\nfrom types import SimpleNamespace\nimport unittest\nfrom contextlib import redirect_stdout\nfrom pathlib import Path\nfrom unittest.mock import patch\n\n\nSCRIPT_PATH = Path(__file__).resolve().parents[1] / 'scripts' / 'check_gate_plan.py'\nSPEC = importlib.util.spec_from_file_location('check_gate_plan', SCRIPT_PATH)\nCHECKER = importlib.util.module_from_spec(SPEC)\nSPEC.loader.exec_module(CHECKER)\n\n# The corpus lives in the bundle so anyone can re-run the calibration without\n# the replay workspace. Inputs retain the historical plan/table shapes with\n# sanitized identities and paths; healthy governing quotations track the current\n# references. The known-bad case remains the original failure control. Parser\n# shapes and path basenames retain the tokens their checks depend on.\nCORPUS = Path(__file__).resolve().parent / 'fixtures' / 'corpus'\nKNOWN_GOOD = CORPUS / 'known-good-arm1.md'\nKNOWN_BAD = CORPUS / 'known-bad-original.md'\nROUND2_TABLE = CORPUS / 'known-good-round2-table.md'\nROUND2_PLAN = CORPUS / 'known-good-round2-plan.md'\nROUND2_ZAI_TABLE = CORPUS / 'known-good-zai.md'\n\n\ndef run_checker(*arguments):\n output = io.StringIO()\n with redirect_stdout(output):\n code = CHECKER.main(list(arguments))\n return code, output.getvalue()\n\n\ndef table_of(text):\n return CHECKER.parse_tables(text)[0]\n\n\nclass PermanentHelperCoverageTests(unittest.TestCase):\n \"\"\"Replay the supported helper invocation that used to check zero targets.\n\n Paths are synthetic; no deletion helper is executed by these tests.\n \"\"\"\n\n def row(self, target):\n return SimpleNamespace(target=target)\n\n def coverage(self, plan, targets):\n commands = CHECKER.find_destructive_commands(plan)\n return commands, CHECKER.check_target_coverage(\n commands, [self.row(target) for target in targets], [], plan)\n\n def test_documented_and_absolute_launchers_cover_the_target(self):\n for launcher in ('uv run scripts/safe_delete.py',\n 'uv run /opt/skills/cleaner/scripts/safe_delete.py',\n '/opt/bin/uv run --script /opt/skills/safe_delete.py',\n 'python3 /opt/skills/safe_delete.py',\n '/usr/bin/python3.12 /opt/skills/safe_delete.py',\n '\"/opt/skill folder/safe_delete.py\"'):\n with self.subTest(launcher=launcher):\n commands, result = self.coverage(\n f'```bash\\n{launcher} \"/tmp/approved item\"\\n```',\n ['/tmp/approved item'])\n self.assertEqual(1, len(commands))\n self.assertTrue(result.passed, result.details)\n self.assertEqual('/tmp/approved item', commands[0].exact_target)\n\n def test_every_argument_requires_its_own_exact_row(self):\n plan = 'uv run /opt/skills/safe_delete.py /tmp/first /tmp/second'\n commands, result = self.coverage(plan, ['/tmp/first'])\n self.assertEqual(2, len(commands))\n self.assertFalse(result.passed, result.details)\n self.assertIn('/tmp/second', '\\n'.join(result.details))\n self.assertTrue(self.coverage(plan, ['/tmp/first', '/tmp/second'])[1].passed)\n\n def test_same_basename_and_uv_cache_row_cannot_hide_a_target(self):\n for rows in (['/tmp/other/item'], ['uv cache']):\n self.assertFalse(self.coverage(\n 'uv run /opt/skills/safe_delete.py /tmp/private/item', rows)[1].passed)\n\n def test_empty_missing_expanded_batch_or_compound_targets_fail(self):\n for arguments in ('', '\"\"', '--batch /tmp/list.txt', '$TARGET',\n '/tmp/*', '/tmp/item; echo done', '/tmp/item | cat'):\n with self.subTest(arguments=arguments):\n _, result = self.coverage(\n 'uv run /opt/skills/safe_delete.py ' + arguments, ['uv cache'])\n self.assertFalse(result.passed, result.details)\n\n def test_helper_help_and_plain_mentions_are_not_deletion_commands(self):\n for text in ('uv run /opt/skills/safe_delete.py --help',\n 'Read safe_delete.py before choosing a cleanup target.',\n '`safe_delete.py` is a legacy helper.',\n 'echo safe_delete.py /tmp/example'):\n self.assertEqual([], CHECKER.find_destructive_commands(text), text)\n\n def test_missing_semantics_is_checked(self):\n commands = CHECKER.find_destructive_commands(\n 'uv run /opt/skills/safe_delete.py /tmp/item')\n result = CHECKER.check_tool_verification(commands, '', [], [])\n self.assertFalse(result.passed, result.details)\n verified = CHECKER.check_tool_verification(\n commands, 'Tool verification: safe_delete.py docs: permanent deletion.', [], [])\n self.assertTrue(verified.passed, verified.details)\n\n def test_unquoted_brace_expansion_is_unresolved(self):\n plan = 'uv run /opt/skills/safe_delete.py /tmp/{first,second}'\n commands, result = self.coverage(plan, ['/tmp/{first,second}'])\n self.assertEqual(1, len(commands))\n self.assertFalse(result.passed, result.details)\n\n def test_quoted_and_escaped_filename_characters_are_literal(self):\n for argument, target in (\n (\"'/tmp/approved [draft]'\", '/tmp/approved [draft]'),\n ('\"/tmp/approved & final\"', '/tmp/approved & final'),\n (\"'/tmp/{first,second}'\", '/tmp/{first,second}'),\n (\"'/tmp/$literal'\", '/tmp/$literal'),\n (r'\"/tmp/\\$literal\"', '/tmp/$literal'),\n (\"'/tmp/not used/item'\", '/tmp/not used/item'),\n (r'/tmp/approved\\ \\[draft\\]', '/tmp/approved [draft]'),\n ('\"/tmp/approved \"final', '/tmp/approved final')):\n with self.subTest(argument=argument):\n commands, result = self.coverage(\n 'uv run /opt/skills/safe_delete.py ' + argument, [target])\n self.assertEqual(1, len(commands))\n self.assertTrue(result.passed, result.details)\n\n def test_help_in_path_does_not_skip_any_target(self):\n plan = 'uv run /opt/skills/safe_delete.py /tmp/help/first /tmp/second'\n commands, result = self.coverage(plan, ['/tmp/help/first'])\n self.assertEqual(2, len(commands))\n self.assertFalse(result.passed, result.details)\n self.assertEqual([], CHECKER.find_destructive_commands(\n '本轮未执行 `uv run /opt/skills/safe_delete.py /tmp/help/first`。'))\n\n def test_unsupported_preview_flag_is_not_a_read_only_exemption(self):\n for arguments in ('--dry-run /tmp/item', '-- --help',\n '\"/tmp/approved \"[draft]'):\n commands, result = self.coverage(\n 'uv run /opt/skills/safe_delete.py ' + arguments, ['/tmp/item'])\n self.assertEqual(1, len(commands))\n self.assertFalse(result.passed, result.details)\n\n\nclass NormalizeTests(unittest.TestCase):\n def test_markdown_noise_is_stripped(self):\n self.assertEqual(\n 'uv cache | downloaded | keep unless approved',\n CHECKER.normalize('uv cache \\\\| `downloaded` \\\\| **keep unless approved**'))\n\n def test_code_fence_markers_do_not_break_a_cross_fence_quote(self):\n # cleanup_targets.md puts the command in a fenced block under the\n # sentence; a plan quotes the two as one continuous rule.\n reference = ('**Cleanup after the redownload impact is approved**:\\n'\n '```bash\\npip cache purge\\n```\\n')\n self.assertEqual(\n 'Cleanup after the redownload impact is approved: pip cache purge',\n CHECKER.normalize(reference))\n\n def test_whitespace_is_collapsed(self):\n self.assertEqual('a b c', CHECKER.normalize('a\\n b\\tc'))\n\n\nclass QuoteSegmentTests(unittest.TestCase):\n def test_ellipsis_splits_a_quote_into_independently_checked_parts(self):\n cell = '\"Never replace this narrow target ... exact redownload approval.\"'\n self.assertEqual(\n ['Never replace this narrow target',\n 'exact redownload approval.'],\n CHECKER.quote_segments(cell))\n\n def test_no_rule_found_is_not_checked_as_a_quote(self):\n self.assertEqual([], CHECKER.quote_segments('\"no rule found\"'))\n\n def test_escaped_quotes_inside_a_quote_survive(self):\n # The dangling-volume rule quotes a phrase of its own inside the quote.\n cell = r'\"\\\"Not referenced by any current container\\\" is tempting.\"'\n self.assertEqual(\n ['\"Not referenced by any current container\" is tempting.'],\n CHECKER.quote_segments(cell))\n\n def test_an_apostrophe_pair_is_not_a_quotation(self):\n # The same rule's real wording ends \"... they're safe to remove. **It\n # isn't**: ...\" — read naively, the two apostrophes invent a segment\n # (\"re safe to remove. It isn\") and the check verifies a fragment that\n # exists in the reference while the real quote went unchecked.\n cell = ('\"Not referenced by any container is tempting to treat as '\n \"sufficient evidence they're safe to remove. **It isn't**.\\\"\")\n self.assertEqual(\n ['Not referenced by any container is tempting to treat as sufficient '\n \"evidence they're safe to remove. It isn't.\"],\n CHECKER.quote_segments(cell))\n\n def test_curly_quotes_are_recognized(self):\n self.assertEqual(\n ['Keep when tests or automation use Playwright'],\n CHECKER.quote_segments('“Keep when tests or automation use Playwright”'))\n\n\nclass VerdictTests(unittest.TestCase):\n def test_negated_marker_beats_the_positive_substring(self):\n # 不入动作集 contains 入动作集, and \"not in action set\" contains\n # \"in action set\": the negated form has to win or every PRESERVE row\n # reads as a proposal.\n row = table_of('| Candidate | Verdict |\\n|---|---|\\n'\n '| `~/.cache/uv` | 不入动作集,除非你接受 |\\n')\n self.assertEqual('not in action set', CHECKER.row_verdict(row.rows[0].cells))\n\n def test_chinese_positive_marker(self):\n row = table_of('| Candidate | Verdict |\\n|---|---|\\n| `_npx` | **入动作集** |\\n')\n self.assertEqual('in action set', CHECKER.row_verdict(row.rows[0].cells))\n\n def test_english_markers(self):\n row = table_of('| Candidate | Verdict |\\n|---|---|\\n'\n '| a | in action set |\\n| b | not in action set |\\n'\n '| c | unlocked by user |\\n')\n self.assertEqual(\n ['in action set', 'not in action set', 'unlocked by user'],\n [CHECKER.row_verdict(item.cells) for item in row.rows])\n\n def test_zai_dongzuoji_is_a_positive_marker(self):\n # An agent wrote the verdict as 在动作集 rather than 入动作集. Reading it\n # as no-verdict made the action set silently unchecked while the checker\n # still printed a green light.\n row = table_of('| 候选 | 裁决 |\\n|---|---|\\n| `_npx` | **在动作集** |\\n')\n self.assertEqual('in action set', CHECKER.row_verdict(row.rows[0].cells))\n\n def test_bu_zai_dongzuoji_is_a_negated_marker(self):\n row = table_of('| 候选 | 裁决 |\\n|---|---|\\n| `~/.cache/uv` | 不在动作集 |\\n')\n self.assertEqual('not in action set', CHECKER.row_verdict(row.rows[0].cells))\n\n def test_a_table_with_no_readable_verdict_fails_closed(self):\n # Green on an unchecked action set is worse than a false alarm: it\n # teaches the reader that this PASS means something it does not.\n gate = ('| 候选 | 类别 | 裁决 |\\n|---|---|---|\\n'\n '| `~/.npm/_npx` | **PROPOSABLE** | 待确认 |\\n'\n '| `~/Library/Caches/pip` | **REBUILDABLE** | 待你回复 |\\n')\n result = CHECKER.check_action_set_classes(CHECKER.collect_gate_rows(gate))\n self.assertFalse(result.passed)\n self.assertIn('verdict column unrecognized', '\\n'.join(result.details))\n self.assertIn('在动作集', '\\n'.join(result.details))\n\n def test_a_table_with_some_unreadable_verdicts_still_passes(self):\n gate = ('| 候选 | 类别 | 管辖规则 | 裁决 |\\n|---|---|---|---|\\n'\n '| `~/.npm/_npx` | **PROPOSABLE** | \"`_npx` may go to Trash after '\n 'confirming no `npx` process.\" | **入动作集** |\\n'\n '| `~/Library/Caches/pnpm` | **USER-DECISION** | no rule found | 等你定 |\\n')\n result = CHECKER.check_action_set_classes(CHECKER.collect_gate_rows(gate))\n self.assertTrue(result.passed, result.details)\n self.assertIn('state no action-set position', '\\n'.join(result.details))\n\n def test_class_is_found_by_content_not_by_header(self):\n # Agents translate the template, so the columns do not keep their names.\n row = table_of('| 候选 | 类别 |\\n|---|---|\\n'\n '| `~/.npm/_npx` | **PROPOSABLE** |\\n')\n self.assertEqual('PROPOSABLE', CHECKER.row_class(row.rows[0].cells))\n\n\nclass CommandExtractionTests(unittest.TestCase):\n PLAN = '\\n'.join([\n '## Tool verification',\n '',\n '- `brew cleanup -s -n` was run read-only: brew 7.0.1, see --help.',\n '- 未跑 `npm cache clean`.',\n '- never ran `uv cache prune`.',\n '- `docker rmi abc123` verified against docker 29.4.0 with --filter.',\n '',\n '| # | 命令 | 精确目标 |',\n '|---|---|---|',\n '| 1 | `pip cache purge` | pip 缓存 |',\n '',\n '```bash',\n 'rm -rf /tmp/gone',\n '```',\n ])\n\n def test_dry_run_is_not_a_destructive_command(self):\n commands = CHECKER.find_destructive_commands(self.PLAN)\n self.assertFalse(any('cleanup -s -n' in command.text for command in commands))\n\n def test_declared_unused_command_is_not_a_proposal(self):\n commands = CHECKER.find_destructive_commands(self.PLAN)\n self.assertFalse(any('npm cache clean' in command.text for command in commands))\n self.assertFalse(any('uv cache prune' in command.text for command in commands))\n\n def test_a_line_mixing_an_exclusion_with_a_real_command_is_not_exempt(self):\n # \"不使用 `pip cache purge`,改用 `rm -rf ...`\" — the negation covers the\n # first command and hides the second, so the line gets no exemption.\n commands = CHECKER.find_destructive_commands(\n '本轮不使用 `pip cache purge`,改用 `rm -rf ~/Library/Caches/pip` 直接清掉。\\n')\n self.assertTrue(any('rm -rf' in command.text for command in commands))\n\n def test_commands_are_found_in_prose_cells_and_code_blocks(self):\n commands = CHECKER.find_destructive_commands(self.PLAN)\n texts = ' '.join(command.text for command in commands)\n self.assertIn('docker rmi', texts)\n self.assertIn('pip cache purge', texts)\n self.assertIn('rm -rf', texts)\n\n def test_read_only_probe_is_not_a_destructive_command(self):\n plan = '`du -sk ~/Library/Caches/pip` and `docker system df` were read.\\n'\n self.assertEqual([], CHECKER.find_destructive_commands(plan))\n\n def test_a_quoted_governing_rule_is_not_a_command(self):\n # The rule cell quotes cleanup_targets.md verbatim; the command inside\n # the quote is a citation, not the plan's own command.\n plan = ('| 候选 | 管辖规则 |\\n|---|---|\\n'\n '| `~/Library/Caches/pip` | \"Cleanup after the redownload impact '\n 'is approved: `pip cache purge`\" |\\n')\n self.assertEqual([], CHECKER.find_destructive_commands(plan))\n\n\nclass ActionSetRuleTests(unittest.TestCase):\n def rows(self, body):\n return CHECKER.collect_gate_rows(\n '| 候选 | 类别 | 判定 |\\n|---|---|---|\\n' + body + '\\n')\n\n def test_preserve_row_in_the_action_set_fails(self):\n result = CHECKER.check_action_set_classes(self.rows(\n '| `~/.cache/uv` | **PRESERVE** | **入动作集** |\\n'))\n self.assertFalse(result.passed)\n self.assertIn('PRESERVE', '\\n'.join(result.details))\n\n def test_user_data_row_in_the_action_set_fails(self):\n result = CHECKER.check_action_set_classes(self.rows(\n '| `~/Documents` | **USER-DATA** | 入动作集 |\\n'))\n self.assertFalse(result.passed)\n\n HEAD = '| 候选 | 类别 | 管辖规则 | 判定 |\\n|---|---|---|---|\\n'\n\n def test_proposable_row_in_the_action_set_passes(self):\n # The rule quote is the `_npx` sanction, which is what vouches for the\n # target when the row itself states no evidence.\n result = CHECKER.check_action_set_classes(CHECKER.collect_gate_rows(\n self.HEAD + '| `~/.npm/_npx` | **PROPOSABLE** | \"`_npx` may go to Trash '\n 'after confirming no `npx` process.\" | **入动作集** |\\n'))\n self.assertTrue(result.passed, result.details)\n\n def test_proposable_row_without_evidence_or_sanction_fails(self):\n result = CHECKER.check_action_set_classes(CHECKER.collect_gate_rows(\n self.HEAD + '| `~/Library/Caches/Spotify` | **PROPOSABLE** | \"Local cache '\n 'copies are regenerated, but redownload time, bandwidth, authentication, '\n 'and offline availability may matter\" | **入动作集** |\\n'))\n self.assertFalse(result.passed)\n self.assertIn('never-used evidence', '\\n'.join(result.details))\n\n def test_unlocked_preserve_row_passes_only_with_the_direction_quoted(self):\n # The template's verdict value is \"unlocked by user (quote the\n # direction)\", so the direction quote is what the checker looks for.\n without = CHECKER.check_action_set_classes(self.rows(\n '| `~/.cache/uv` | **PRESERVE** | unlocked by user |\\n'))\n self.assertFalse(without.passed)\n with_direction = CHECKER.check_action_set_classes(self.rows(\n '| `~/.cache/uv` | **PRESERVE** | unlocked by user(用户点名:\"接受重下代价\") |\\n'))\n self.assertTrue(with_direction.passed)\n\n def test_empty_action_set_is_a_legitimate_outcome(self):\n result = CHECKER.check_action_set_classes(self.rows(\n '| `~/.cache/uv` | **PRESERVE** | 不入动作集 |\\n'))\n self.assertTrue(result.passed)\n\n\nclass CategoryWideExclusionTests(unittest.TestCase):\n GATE = ('| 候选 | 类别 | 判定 |\\n|---|---|---|\\n'\n '| `~/.cache/uv` | **PRESERVE** | 不入动作集,除非你接受 |\\n'\n '| `~/.npm/_cacache` | **PROPOSABLE** | **入动作集** |\\n')\n\n def test_category_wide_command_in_the_action_set_fails(self):\n plan = self.GATE.replace('`~/.npm/_cacache`', '`~/.npm`') + \\\n '\\nProposal: `npm cache clean --force`\\n'\n rows = CHECKER.collect_gate_rows(self.GATE.replace('`~/.npm/_cacache`', '`~/.npm`'))\n commands = CHECKER.find_destructive_commands('\\nProposal: `npm cache clean --force`\\n')\n result = CHECKER.check_category_wide_exclusion(\n commands, rows, CHECKER.parse_tables(plan))\n self.assertFalse(result.passed)\n\n def test_same_command_as_an_unlock_option_does_not_fail(self):\n # The rule bans a category-wide command from sitting *in the action set*.\n # Offered as an unlock option for a PRESERVE row it is the documented\n # way to hand the decision to the user, so it must not fail.\n rows = CHECKER.collect_gate_rows(self.GATE)\n commands = CHECKER.find_destructive_commands(\n '\\n解锁选项(未入动作集):`uv cache prune`\\n')\n result = CHECKER.check_category_wide_exclusion(\n commands, rows, CHECKER.parse_tables(self.GATE))\n self.assertTrue(result.passed)\n\n def test_explicit_package_argument_narrows_the_scope(self):\n command = CHECKER.find_destructive_commands('`uv cache clean torch`\\n')[0]\n self.assertFalse(command.category_wide)\n self.assertTrue(CHECKER.find_destructive_commands('`uv cache prune`\\n')[0]\n .category_wide)\n\n\nclass QuarantineMoveRecognitionTests(unittest.TestCase):\n \"\"\"The ssh/headless quarantine fallback gets target-coverage binding; an\n ordinary `mv` / `git mv` / build-script rename must not be pulled in.\"\"\"\n\n def test_quarantine_mv_is_recognized(self):\n commands = CHECKER.find_destructive_commands(\n '`mv \"/data/app\" \"/data/_quarantine-20261007/app\"`\\n')\n self.assertEqual(1, len(commands))\n self.assertIn('mv', commands[0].tools)\n self.assertFalse(commands[0].category_wide)\n\n def test_quarantine_mv_with_flags_and_tilde(self):\n commands = CHECKER.find_destructive_commands(\n '`mv -v ~/Library/Caches/foo ~/Library/_quarantine-20261007/foo`\\n')\n self.assertEqual(1, len(commands))\n\n def test_quarantine_mv_leaves_unrecognized_report(self):\n missed = CHECKER.unrecognized_command_lines(\n '`mv \"/data/app\" \"/data/_quarantine-20261007/app\"`\\n',\n CHECKER.find_destructive_commands(\n '`mv \"/data/app\" \"/data/_quarantine-20261007/app\"`\\n'))\n self.assertEqual([], missed)\n\n def test_ordinary_mv_is_not_recognized(self):\n self.assertEqual([], CHECKER.find_destructive_commands(\n '`mv ~/Downloads/a ~/Downloads/b`\\n'))\n\n def test_git_mv_is_not_recognized(self):\n self.assertEqual([], CHECKER.find_destructive_commands(\n '`git mv old.md new.md`\\n'))\n\n def test_build_script_mv_is_not_recognized(self):\n self.assertEqual([], CHECKER.find_destructive_commands(\n '`mv dist/bundle.js dist/app.js`\\n'))\n\n def test_mv_to_a_non_quarantine_dir_is_not_recognized(self):\n # A `mv` whose destination merely reorganizes (no `_quarantine-` marker)\n # is not the sanctioned fallback and stays out of the gate.\n self.assertEqual([], CHECKER.find_destructive_commands(\n '`mv \"/data/app\" \"/data/archive/app\"`\\n'))\n\n def test_backticked_command_table_cell_is_recognized(self):\n # The canonical calibration shape: a command-table cell keeps its\n # markdown backticks (command_chunks strips them only from prose spans),\n # so the pattern must tolerate the leading backtick.\n plan = ('| 命令 | 精确目标 |\\n|---|---|\\n'\n '| `mv ~/.npm/_npx ~/.npm/_quarantine-20261007/_npx` | `~/.npm/_npx` |\\n')\n commands = CHECKER.find_destructive_commands(plan)\n self.assertEqual(1, len(commands))\n self.assertIn('mv', commands[0].tools)\n\n def test_prose_sentence_starting_with_mv_is_not_gated(self):\n # A prose paraphrase of the approach is not a command; the first token\n # after \"mv\" is a word, not a path/quote. The line MUST begin with \"mv\"\n # (no leading label) — that is the previously-failed shape this pins:\n # against the pre-fix pattern `^\\s*mv\\s+[^|;]*_quarantine-` a prefixed\n # line would not be gated either, so a prefixed test would be vacuous.\n plan = ('\\nmv each approved target to a directory on the same volume '\n '(\u003capproved-dir>/_quarantine-\u003cdate>/), writing a MANIFEST beside it\\n')\n self.assertEqual([], CHECKER.find_destructive_commands(plan))\n\n def test_sudo_mv_is_not_recognized(self):\n self.assertEqual([], CHECKER.find_destructive_commands(\n '`sudo mv /data/app /data/_quarantine-20261007/app`\\n'))\n\n def test_restore_from_quarantine_is_recognized(self):\n # The reverse move (marker in the source) is also state-changing on the\n # approved target; the marker may sit anywhere in the command.\n commands = CHECKER.find_destructive_commands(\n '`mv /data/_quarantine-20261007/app /data/app`\\n')\n self.assertEqual(1, len(commands))\n\n\nclass LeadRuleTests(unittest.TestCase):\n GATE = ('| 候选 | 类别 | 判定 |\\n|---|---|---|\\n'\n '| `~/.cache/uv` | **PRESERVE** | 不入动作集 |\\n'\n '| `~/.npm/_npx` | **PROPOSABLE** | **入动作集** |\\n'\n '| `~/Library/Caches/pip` | **PROPOSABLE** | **入动作集** |\\n')\n RANKING = ('\\n| 排名 | 候选 | 预期物理释放 |\\n|---|---|---|\\n'\n '| 1 | `~/.npm/_npx` | ≈4.84 GiB |\\n'\n '| 2 | `~/Library/Caches/pip` | ≈301 MiB |\\n')\n\n def rows_and_commands(self, plan):\n return (CHECKER.find_destructive_commands(plan),\n CHECKER.collect_gate_rows(self.GATE))\n\n def test_leading_with_the_largest_release_passes(self):\n plan = ('| 命令 | 精确目标 |\\n|---|---|\\n'\n '| `osascript ... -- \"~/.npm/_npx\"` | `_npx` |\\n'\n '| `pip cache purge` | pip 缓存 |\\n' + self.RANKING)\n commands, rows = self.rows_and_commands(plan)\n result = CHECKER.check_lead_rule(commands, rows, plan)\n self.assertTrue(result.passed, result.details)\n\n def test_leading_with_the_small_fish_fails(self):\n plan = ('| 命令 | 精确目标 |\\n|---|---|\\n'\n '| `pip cache purge` | pip 缓存 |\\n'\n '| `osascript ... -- \"~/.npm/_npx\"` | `_npx` |\\n'\n + self.RANKING)\n commands, rows = self.rows_and_commands(plan)\n result = CHECKER.check_lead_rule(commands, rows, plan)\n self.assertFalse(result.passed)\n self.assertIn('hotspot', '\\n'.join(result.details))\n\n def test_unknown_releases_do_not_fire_the_rule(self):\n gate = ('| 候选 | 类别 | 判定 |\\n|---|---|---|\\n'\n '| `~/.npm/_npx` | **PROPOSABLE** | **入动作集** |\\n')\n plan = '| 命令 | 精确目标 |\\n|---|---|\\n| `osascript ...` | `_npx` |\\n'\n result = CHECKER.check_lead_rule(\n CHECKER.find_destructive_commands(plan),\n CHECKER.collect_gate_rows(gate), plan)\n self.assertTrue(result.passed)\n\n def test_hotspot_marker_justifies_a_smaller_lead(self):\n gate = ('| 候选 | 类别 | 判定 |\\n|---|---|\\n'\n '| `~/.npm/_npx` | **PROPOSABLE** | **入动作集** |\\n'\n '| `~/Library/Caches/pip` | **PROPOSABLE** | **入动作集**(热点:磁盘压力来源,'\n '95% 容量告警来自此目录) |\\n')\n plan = ('| 命令 | 精确目标 |\\n|---|---|\\n'\n '| `pip cache purge` | pip 缓存 |\\n'\n '| `osascript ... -- \"~/.npm/_npx\"` | `_npx` |\\n'\n + self.RANKING)\n result = CHECKER.check_lead_rule(\n CHECKER.find_destructive_commands(plan),\n CHECKER.collect_gate_rows(gate), plan)\n self.assertTrue(result.passed)\n\n\nclass ReleaseTableFallbackTests(unittest.TestCase):\n def test_a_summary_row_restating_preserve_targets_cannot_win_the_ranking(self):\n # The known-good plan's ranking table ends with a \"—\" row that lists the\n # PRESERVE block and its nominal sizes. Reading its release cell is what\n # keeps that row from outranking the real lead candidate.\n plan = ('| 排名 | 精确候选 | 名义 | 预期物理释放 |\\n|---|---|---|---|\\n'\n '| 1 | `~/.npm/_npx` | 4.84 GiB | ≈4.84 GiB |\\n'\n '| — | `~/.cache/uv` 41.45 GiB / `~/.npm/_cacache` 12.90 GiB | 见证据表 '\n '| 不确定,且按规则默认保留 |\\n')\n readings = CHECKER.plan_release_table(plan)\n self.assertEqual(1, len(readings))\n self.assertAlmostEqual(4.84 * 1024 ** 3, readings[0][1])\n\n\nclass ReleaseColumnDisciplineTests(unittest.TestCase):\n \"\"\"A number is only a release if the release column says so.\n\n An agent annotated the Homebrew row \"du 路径口径 2.0 GB 不构成释放承诺\" and\n the checker read the 2.0 GB as its release, which outranked the real lead\n candidate and made the honest annotation the thing that had to be deleted.\n \"\"\"\n\n GATE = ('| 目标 | Nominal size | 预期物理释放 | Class | 判定 |\\n'\n '|---|---|---|---|---|\\n'\n '| Homebrew 缓存 | 2.0 GB | unknown(取决于缓存构成,未逐对象测量) '\n '| REBUILDABLE | 入动作集 |\\n'\n '| pip 缓存 | 308.7 MB | ≤308.7 MB(du 路径口径上界) '\n '| REBUILDABLE | 入动作集 |\\n')\n\n def test_a_reserved_release_cell_yields_no_number(self):\n rows = {row.target: row for row in CHECKER.collect_gate_rows(self.GATE)}\n self.assertIsNone(rows['Homebrew 缓存'].release)\n self.assertAlmostEqual(308.7 * 1000 ** 2, rows['pip 缓存'].release)\n\n def test_a_nominal_size_column_cannot_participate_in_the_ranking(self):\n plan = ('| 命令 | 精确目标 |\\n|---|---|\\n'\n '| `pip cache purge` | pip 缓存 |\\n'\n '| `brew cleanup -s` | Homebrew 缓存 |\\n')\n result = CHECKER.check_lead_rule(\n CHECKER.find_destructive_commands(plan),\n CHECKER.collect_gate_rows(self.GATE), plan)\n self.assertTrue(result.passed, result.details)\n self.assertIn('pip 缓存', '\\n'.join(result.details))\n\n def test_reservation_wording_inside_the_release_cell_is_ignored(self):\n # The shape the agent actually wrote: the number and the withdrawal in\n # the same cell.\n gate = ('| 目标 | 预期物理释放 | Class | 判定 |\\n|---|---|---|---|\\n'\n '| Homebrew 缓存 | du 路径口径 2.0 GB 不构成释放承诺 | REBUILDABLE '\n '| 入动作集 |\\n'\n '| pip 缓存 | ≤308.7 MB | REBUILDABLE | 入动作集 |\\n')\n rows = {row.target: row for row in CHECKER.collect_gate_rows(gate)}\n self.assertIsNone(rows['Homebrew 缓存'].release)\n self.assertAlmostEqual(308.7 * 1000 ** 2, rows['pip 缓存'].release)\n\n\nclass ProgramNamePrefixTests(unittest.TestCase):\n GATE = ('| 目标 | Class | 判定 |\\n|---|---|---|\\n'\n '| pip 缓存(`~/Library/Caches/pip`) | REBUILDABLE | 入动作集 |\\n')\n\n def test_pip3_matches_a_pip_row(self):\n command = CHECKER.find_destructive_commands('`pip3 cache purge`\\n')[0]\n rows = CHECKER.collect_gate_rows(self.GATE)\n row, _resolvable, _target = CHECKER.attribute(\n command, rows, CHECKER.parse_tables(self.GATE))\n self.assertIsNotNone(row)\n self.assertIn('pip', row.target)\n\n def test_pip3_leads_when_it_is_the_biggest_release(self):\n plan = ('| 命令 | 精确目标 |\\n|---|---|\\n| `pip3 cache purge` | pip 缓存 |\\n')\n result = CHECKER.check_lead_rule(\n CHECKER.find_destructive_commands(plan),\n CHECKER.collect_gate_rows(self.GATE), plan)\n self.assertTrue(result.passed, result.details)\n\n\nclass ActionSetMembershipTests(unittest.TestCase):\n \"\"\"An unlocked row is in the action set for every consumer, not just one.\"\"\"\n\n GATE = ('| 候选 | 类别 | 判定 |\\n|---|---|---|\\n'\n '| `~/.npm/_cacache` | **PROPOSABLE** | unlocked by user(用户点名:\"删吧\") |\\n')\n\n def rows(self):\n return CHECKER.collect_gate_rows(self.GATE)\n\n def test_unlocked_row_counts_as_action_set_membership(self):\n self.assertTrue(CHECKER.in_action_set(self.rows()[0]))\n\n def test_a_category_wide_command_on_an_unlocked_row_fails(self):\n commands = CHECKER.find_destructive_commands('`npm cache clean --force`\\n')\n result = CHECKER.check_category_wide_exclusion(\n commands, self.rows(), CHECKER.parse_tables(self.GATE))\n self.assertFalse(result.passed)\n self.assertIn('unlocked by user', '\\n'.join(result.details))\n\n def test_an_unlocked_row_is_checked_for_tool_verification(self):\n commands = CHECKER.find_destructive_commands('`npm cache clean --force`\\n')\n result = CHECKER.check_tool_verification(commands, '## Proposal\\n`npm cache clean '\n '--force`\\n', self.rows(),\n CHECKER.parse_tables(self.GATE))\n self.assertFalse(result.passed)\n\n def test_an_unlocked_row_joins_the_ranking(self):\n gate = ('| 候选 | 预期物理释放 | 类别 | 判定 |\\n|---|---|---|---|\\n'\n '| `~/.cache/uv` | 41.45 GiB | PRESERVE | unlocked by user(用户点名:\"删\") |\\n'\n '| `~/.npm/_npx` | 4.84 GiB | PROPOSABLE | 入动作集 |\\n')\n plan = ('| 命令 | 目标 |\\n|---|---|\\n'\n '| `uv cache clean` | `~/.cache/uv` |\\n')\n result = CHECKER.check_lead_rule(\n CHECKER.find_destructive_commands(plan),\n CHECKER.collect_gate_rows(gate), plan)\n self.assertTrue(result.passed, result.details)\n self.assertIn('41.45 GiB', '\\n'.join(result.details))\n\n def test_user_data_row_is_barred_under_every_action_set_verdict(self):\n gate = ('| 候选 | 类别 | 判定 |\\n|---|---|---|\\n'\n '| `~/Documents` | **USER-DATA** | unlocked by user(用户点名:\"删\") |\\n')\n result = CHECKER.check_action_set_classes(CHECKER.collect_gate_rows(gate))\n self.assertFalse(result.passed)\n self.assertIn('USER-DATA', '\\n'.join(result.details))\n\n\nclass RuleColumnTests(unittest.TestCase):\n def test_quote_fidelity_fails_when_the_rule_column_cannot_be_located(self):\n gate = ('| 候选 | 类别 | 出处 | 判定 |\\n|---|---|---|---|\\n'\n '| `~/.npm/_npx` | **PROPOSABLE** | somewhere | 入动作集 |\\n')\n result = CHECKER.check_quote_fidelity(CHECKER.collect_gate_rows(gate), {})\n self.assertFalse(result.passed)\n self.assertIn('rule column not located', '\\n'.join(result.details))\n self.assertIn('候选', '\\n'.join(result.details))\n\n def test_a_row_without_a_quotation_or_a_no_rule_marker_fails(self):\n gate = ('| 候选 | 类别 | 管辖规则 | 判定 |\\n|---|---|---|---|\\n'\n '| `~/.npm/_cacache` | **PROPOSABLE** | cleanup_targets.md says keep '\n 'this by default | 入动作集 |\\n')\n result = CHECKER.check_quote_fidelity(CHECKER.collect_gate_rows(gate), {})\n self.assertFalse(result.passed)\n self.assertIn('states no quotation', '\\n'.join(result.details))\n\n def test_a_no_rule_found_marker_satisfies_the_citation_requirement(self):\n gate = ('| 候选 | 类别 | 管辖规则 | 判定 |\\n|---|---|---|---|\\n'\n '| `~/Library/Caches/pnpm` | **USER-DECISION** | no rule found | 不入动作集 |\\n')\n result = CHECKER.check_quote_fidelity(CHECKER.collect_gate_rows(gate), {})\n self.assertTrue(result.passed)\n\n def test_a_ditto_cell_cites_the_row_above(self):\n gate = ('| 候选 | 类别 | 管辖规则 | 判定 |\\n|---|---|---|---|\\n'\n '| `~/Library/Caches/Google` | **PRESERVE** | \"Do not remove an exact '\n 'cache directory while its owning application or service is using it.\" '\n '| 不入动作集 |\\n'\n '| `~/Library/Caches/LarkShell` | **PRESERVE** | 同上 | 不入动作集 |\\n')\n rows = CHECKER.collect_gate_rows(gate)\n reference = 'Do not remove an exact cache directory while its owning ' \\\n 'application or service is using it.\\n'\n result = CHECKER.check_quote_fidelity(rows, {'cleanup_targets.md': reference})\n self.assertTrue(result.passed, result.details)\n # The ditto row verifies the same segment as the row it cites.\n self.assertIn('2 quoted segment(s)', '\\n'.join(result.details))\n\n def test_a_ditto_pointing_at_another_ditto_resolves_to_a_real_quote(self):\n gate = ('| 候选 | 类别 | 管辖规则 | 判定 |\\n|---|---|---|---|\\n'\n '| `~/Library/Caches/Google` | **PRESERVE** | \"Do not remove an exact '\n 'cache directory while its owning application or service is using it.\" '\n '| 不入动作集 |\\n'\n '| `~/Library/Caches/LarkShell` | **PRESERVE** | 同上 | 不入动作集 |\\n'\n '| `~/Library/Caches/ShipIt` | **PRESERVE** | 同上 | 不入动作集 |\\n')\n reference = 'Do not remove an exact cache directory while its owning ' \\\n 'application or service is using it.\\n'\n result = CHECKER.check_quote_fidelity(\n CHECKER.collect_gate_rows(gate), {'cleanup_targets.md': reference})\n self.assertTrue(result.passed, result.details)\n\n def test_a_table_that_does_not_classify_is_not_read_as_the_gate_table(self):\n # A ranking table whose summary row mentions PRESERVE in prose is not the\n # classification table, and applying the gate to it would invent rows.\n ranking = ('| 排名 | 候选 | 预期物理释放 |\\n|---|---|---|\\n'\n '| — | uv 41.45 GiB 都属于 PRESERVE | 不确定 |\\n')\n self.assertEqual([], CHECKER.collect_gate_rows(ranking))\n\n\nclass PreserveDowngradeTests(unittest.TestCase):\n \"\"\"A preserve-by-default target needs evidence or a user direction.\"\"\"\n\n def rows(self, body):\n return CHECKER.collect_gate_rows(\n '| 候选 | 类别 | 管辖规则 | 判定 |\\n|---|---|---|---|\\n' + body)\n\n def references(self):\n return {'cleanup_targets.md': (\n '| Target | Value retained | Real deletion impact | Default |\\n'\n '|---|---|---|---|\\n'\n '| Xcode DerivedData | indexes | slower build | Keep |\\n'\n '| npm `_cacache` | content | redownload | Keep; do not confuse it with `_npx` |\\n'\n '| uv cache | packages | rebuild | Keep |\\n')}\n\n def test_a_relabelled_preserve_target_fails(self):\n rows = self.rows('| `~/.npm/_cacache` | **PROPOSABLE** | \"Keep; do not confuse it '\n 'with `_npx`\" | 入动作集 |\\n')\n result = CHECKER.check_preserve_downgrade(rows, self.references())\n self.assertFalse(result.passed)\n self.assertIn('preserve-by-default table', '\\n'.join(result.details))\n\n def test_a_preserve_row_that_stays_out_is_not_asked_for_evidence(self):\n rows = self.rows('| `~/.cache/uv` | **PRESERVE** | \"Keep unless the user accepts '\n 'dependency restoration cost\" | 不入动作集 |\\n')\n result = CHECKER.check_preserve_downgrade(rows, self.references())\n self.assertTrue(result.passed)\n\n def test_never_used_evidence_is_accepted_as_a_claim(self):\n rows = self.rows('| `~/.npm/_cacache` | **PROPOSABLE** | \"Keep; do not confuse it '\n 'with `_npx`\" | 入动作集(项目已删,确认无 npm 进程) |\\n')\n result = CHECKER.check_preserve_downgrade(rows, self.references())\n self.assertTrue(result.passed)\n\n def test_an_exact_child_of_a_preserve_target_is_the_sanctioned_narrow_form(self):\n # The reference prescribes removing an exact inactive project child, so a\n # child row must not be treated as the preserve target itself.\n rows = self.rows('| `~/Library/Developer/Xcode/DerivedData/ProjA` | **PROPOSABLE** '\n '| \"Quit Xcode; prefer removing an exact inactive project child.\" '\n '| 入动作集 |\\n')\n result = CHECKER.check_preserve_downgrade(rows, self.references())\n self.assertTrue(result.passed)\n\n def test_a_generic_token_does_not_match_an_unrelated_preserve_entry(self):\n # \"cache\" appears in several preserve names, so it identifies none of\n # them; a Homebrew row must not read as the uv cache entry.\n rows = self.rows('| `$(brew --cache)` | **REBUILDABLE** | \"brew cleanup -s\" '\n '| 入动作集 |\\n')\n result = CHECKER.check_preserve_downgrade(rows, self.references())\n self.assertTrue(result.passed)\n\n\nclass CommandArgumentTests(unittest.TestCase):\n def test_object_ids_are_a_stated_target(self):\n self.assertTrue(CHECKER.command_arguments('docker rmi a02c40cc28df 555434521374'))\n\n def test_a_subcommand_word_is_not_a_stated_target(self):\n self.assertFalse(CHECKER.command_arguments('docker rmi'))\n\n def test_a_placeholder_is_a_stated_target(self):\n self.assertTrue(CHECKER.command_arguments('docker rmi \u003cIMAGE_ID>'))\n\n def test_a_command_in_bare_prose_is_found(self):\n commands = CHECKER.find_destructive_commands(\n 'Step 3 runs uv cache prune to reclaim 50.9 GiB. No backticks are used here.\\n')\n self.assertTrue(any('uv cache prune' in command.text for command in commands))\n\n\nclass MainUsageTests(unittest.TestCase):\n @patch('sys.argv', ['check_gate_plan.py', '--table', '/nope.md', '--plan', '/nope.md'])\n def test_missing_table_file_is_a_usage_error(self):\n output = io.StringIO()\n with redirect_stdout(output):\n self.assertEqual(2, CHECKER.main())\n\n def test_missing_required_arguments_exit_two(self):\n with self.assertRaises(SystemExit) as raised:\n with redirect_stdout(io.StringIO()):\n CHECKER.main(['--table', KNOWN_GOOD.name])\n self.assertEqual(2, raised.exception.code)\n\n\[email protected](KNOWN_GOOD.is_file(),\n 'calibration corpus absent: %s (see tests/test_gate_checker.py '\n 'header — the replay workspace is cleaned up periodically)'\n % KNOWN_GOOD)\nclass CalibrationKnownGoodTests(unittest.TestCase):\n \"\"\"False-positive calibration: a healthy plan must exit 0 with zero FAILs.\n\n Corpus: /tmp/macos-cleaner-workspace/replay-arm1/output.md — the with-skill\n replay of the 2026-09-19 run, written after the Phase 2 entry gate existed.\n It is the known-good sample because an independent review of that gate draft\n found no blocking defect in this output's classification: classified rows,\n governing rules quoted from cleanup_targets.md and docker_analysis.md,\n PROPOSABLE rows in the action set, every PRESERVE row excluded, no prune\n family command proposed, and the lead command pointing at the largest\n in-action-set release. Its shapes are what forced the parser's tolerances:\n Chinese column headers, a gate table with no expected-release column (the\n release ranking lives in a later section), `\\\\|` escapes inside quoted rules,\n a quoted rule that spans a fenced code block, commands that appear in table\n cells, inline backticks and a \"we did not run these\" list, and a Finder\n Trash command whose owning control is an application rather than a versioned\n tool.\n \"\"\"\n\n @classmethod\n def setUpClass(cls):\n cls.code, cls.output = run_checker(\n '--table', str(KNOWN_GOOD), '--plan', str(KNOWN_GOOD))\n\n def test_exit_zero(self):\n self.assertEqual(0, self.code, self.output)\n\n def test_no_check_fails(self):\n self.assertNotIn('FAIL', self.output)\n\n def test_every_rule_was_actually_evaluated(self):\n for name in ('table_exists', 'quote_fidelity', 'target_coverage',\n 'action_set_classes', 'category_wide_exclusion', 'lead_rule',\n 'tool_verification'):\n self.assertIn(name, self.output)\n\n def test_the_gate_table_was_found_and_quotes_were_verified(self):\n # A PASS here must mean something: the table has to have been parsed and\n # the quotes checked, not skipped for want of a parser. Anchored on the\n # line start, because \"20 quoted segment(s)\" contains \"0 quoted ...\".\n self.assertIn('gate row(s) classified', self.output)\n self.assertRegex(self.output, r'(?m)^\\s+[1-9]\\d* quoted segment\\(s\\) verified')\n\n\[email protected](KNOWN_BAD.is_file(),\n 'calibration corpus absent: %s' % KNOWN_BAD)\nclass CalibrationKnownBadTests(unittest.TestCase):\n \"\"\"Recall calibration: the failure the gate exists for must exit 1.\n\n Corpus: /tmp/macos-cleaner-workspace/fixtures/known-bad-original-20260917.md\n — a faithful reproduction of the first 2026-09-17 Phase 2 report, the run\n that triggered the gate. It is the known-bad sample because it shows every\n failure mode the gate names: no classification table at all, category-wide\n commands (`npm cache clean --force`, `brew cleanup`, `uv cache clean`,\n `uv cache prune`) offered as a low-risk green combination, `uv cache prune`\n recommended as the headline on the strength of a --help line plus size\n ratios with no version or semantics check, and a 2 GB item ranked beside a\n 91 GB candidate with no free-space target. exit 1 must name the missing\n table and the uncovered targets at minimum.\n \"\"\"\n\n @classmethod\n def setUpClass(cls):\n cls.code, cls.output = run_checker(\n '--table', str(KNOWN_BAD), '--plan', str(KNOWN_BAD))\n\n def test_exit_one(self):\n self.assertEqual(1, self.code, self.output)\n\n def test_names_the_missing_table(self):\n self.assertIn('FAIL table_exists', self.output)\n\n def test_names_the_uncovered_targets(self):\n self.assertIn('FAIL target_coverage', self.output)\n\n def test_names_each_category_wide_command_it_offered(self):\n for command in ('npm cache clean', 'brew cleanup', 'uv cache clean',\n 'uv cache prune'):\n self.assertIn(command, self.output)\n\n\[email protected](ROUND2_TABLE.is_file() and ROUND2_PLAN.is_file(),\n 'calibration corpus absent: %s' % ROUND2_TABLE)\nclass CalibrationRoundTwoTests(unittest.TestCase):\n \"\"\"False-positive calibration #2: an end-to-end replay must exit 0.\n\n Corpus: /tmp/macos-cleaner-workspace/replay-round2/{gate-table.md,plan.md} —\n the table and plan a fresh agent produced from the revised skill on a real\n task. It is the known-good sample because it passed the gate honestly: it\n classified 14 candidates, quoted rules from cleanup_targets.md and\n docker_analysis.md, put only two REBUILDABLE rows in the action set, kept\n every PRESERVE and USER-DECISION row out, declared its prune-family\n exclusions, recorded tool versions and semantics for every proposed command,\n and — the part that matters most — led with the smaller of its two proposed\n rows while writing \"unknown\" and \"不构成释放承诺\" next to the bigger ones.\n\n That last property is a regression guard, not decoration. An earlier draft\n of this plan annotated Homebrew's row \"du 路径口径 2.0 GB 不构成释放承诺\"\n and the checker read 2.0 GB as the row's release, outranked the real lead\n candidate, and forced the agent to delete the annotation to get a green\n light. Honest wording must never be the thing that fails.\n \"\"\"\n\n @classmethod\n def setUpClass(cls):\n cls.code, cls.output = run_checker(\n '--table', str(ROUND2_TABLE), '--plan', str(ROUND2_PLAN))\n\n def test_exit_zero(self):\n self.assertEqual(0, self.code, self.output)\n\n def test_no_check_fails(self):\n self.assertNotIn('FAIL', self.output)\n\n def test_the_action_set_was_actually_read(self):\n # Both in-action-set rows are REBUILDABLE, and the verdict column is\n # named 裁决 here rather than 判定 — the header name is not load-bearing.\n self.assertIn('in action set: 2', self.output)\n self.assertNotIn('verdict column unrecognized', self.output)\n\n def test_the_lead_row_is_the_only_row_with_a_real_release(self):\n self.assertIn('PASS lead_rule', self.output)\n self.assertIn('pip 缓存', self.output)\n # The Homebrew row says unknown, so its 2.0 GB nominal size must not\n # appear as a release figure in the ranking.\n self.assertNotIn('2.0 GB', self.output)\n\n @unittest.skipUnless(ROUND2_ZAI_TABLE.is_file(),\n 'zai variant absent: %s' % ROUND2_ZAI_TABLE)\n def test_the_zai_wording_variant_reads_the_same_action_set(self):\n # Same table with the verdicts written 在动作集 / 不在动作集 instead of\n # 入动作集 / 不入动作集. This is the wording the end-to-end replay\n # actually used, and it is what exposed the false green: the checker\n # read every verdict as unrecognized, skipped the action set entirely,\n # and still printed exit 0. The two wordings must produce the same\n # verdict counts, not one green and one red.\n code, output = run_checker(\n '--table', str(ROUND2_ZAI_TABLE), '--plan', str(ROUND2_PLAN))\n self.assertEqual(0, code, output)\n self.assertIn('in action set: 2, unlocked by user: 0, not in action set: 12',\n output)\n self.assertNotIn('verdict column unrecognized', output)\n\n\nPROBE_DIR = Path(__file__).resolve().parent / 'fixtures' / 'review-probes'\n\n\ndef probe(name):\n \"\"\"(table, plan) for an adversarial probe, either file standing in for both.\"\"\"\n gate = PROBE_DIR / ('%s_gate.md' % name)\n plan = PROBE_DIR / ('%s_plan.md' % name)\n return (gate if gate.is_file() else plan, plan if plan.is_file() else gate)\n\n\nclass ProbeCase(unittest.TestCase):\n \"\"\"Base for the adversarial probes.\n\n Each probe is a shape an independent reviewer built to attack one gate rule.\n They are the recall half of the calibration: the known-good corpora prove the\n checker does not cry wolf, these prove it does not stay silent. A probe that\n stops failing after a later change is a regression, not an improvement.\n \"\"\"\n\n NAME = None\n EXPECT_EXIT = 1\n EXPECT_RULES = ()\n\n @classmethod\n def setUpClass(cls):\n if not probe(cls.NAME)[0].is_file():\n raise unittest.SkipTest('probe absent: %s' % cls.NAME)\n cls.code, cls.output = run_checker(\n '--table', str(probe(cls.NAME)[0]), '--plan', str(probe(cls.NAME)[1]))\n\n def test_exit_code(self):\n self.assertEqual(self.EXPECT_EXIT, self.code, self.output)\n\n def test_names_the_violated_rules(self):\n for rule in self.EXPECT_RULES:\n self.assertIn('FAIL %s' % rule, self.output)\n\n\nclass ProbeP1UnlockedRowTakesCommands(ProbeCase):\n \"\"\"An unlocked PRESERVE row is in the action set, so the rules apply to it.\n\n The row says `unlocked by user(用户点名:\"删吧\")` and the plan proposes\n `npm cache clean --force` — category-wide, with no version and no semantics\n source recorded. Before the action set included unlocked rows, nothing\n checked any of that and the plan exited 0.\n \"\"\"\n\n NAME = 'p1'\n EXPECT_RULES = ('category_wide_exclusion', 'tool_verification')\n\n\nclass ProbeP2UnlockedRowRanksFirst(ProbeCase):\n \"\"\"An unlocked row's release participates in the ranking.\n\n The 41.45 GiB unlocked uv row leads and the 4.84 GiB in-action-set row\n follows, which is what the ranking rule asks for. lead_rule now agrees; the\n probe still exits 1 because its plan proposes a whole-cache command and\n records no tool verification — two violations the probe itself contains,\n independent of whether unlocked rows rank.\n \"\"\"\n\n NAME = 'p2'\n EXPECT_RULES = ('tool_verification',)\n\n def test_lead_rule_accepts_the_unlocked_row_as_the_lead(self):\n self.assertIn('PASS lead_rule', self.output)\n self.assertIn('41.45 GiB', self.output)\n\n\nclass ProbeP3FabricationInTheReleaseColumn(ProbeCase):\n \"\"\"A fabricated string in the *release* column is not a rule quote.\n\n The rule column carries a real quote; the invented text sits in \"Expected\n physical release + basis\", which the gate defines as an estimate plus its\n derivation, not a verbatim citation. Nothing verifies prose there, so this\n shape is correctly green — p3b is the same table with the two swapped, and\n that one is red.\n \"\"\"\n\n NAME = 'p3'\n EXPECT_EXIT = 0\n\n def test_no_check_fails(self):\n self.assertNotIn('FAIL', self.output)\n\n\nclass ProbeP3bRuleColumnOffByOne(ProbeCase):\n \"\"\"Locating the rule column by position read the release column instead.\n\n The rule cell holds an invented quote and the release cell holds a real one.\n A positional guess (third from the end) landed on the release column, so the\n fabricated rule was never checked. The column is now found by its header.\n \"\"\"\n\n NAME = 'p3b'\n EXPECT_RULES = ('quote_fidelity',)\n\n\nclass ProbeP4PreserveDowngradeWithVerification(ProbeCase):\n \"\"\"The original 2026-09-17 failure, in the new table format.\n\n `~/.npm/_cacache` is relabelled PROPOSABLE with its rule quote intact, and\n the plan even records npm's version and semantics. Every pre-existing check\n passed. What catches it is that the target is in the preserve-by-default\n table and the row states neither never-used evidence nor a user direction.\n \"\"\"\n\n NAME = 'p4'\n EXPECT_RULES = ('preserve_by_default',)\n\n\nclass ProbeP4bPreserveDowngradeViaFinderTrash(ProbeCase):\n \"\"\"The same downgrade reached through the Finder Trash branch.\n\n Identical table to p4 with the destructive command expressed as an\n application control instead of a package-manager cache wipe, so the\n category-wide rule does not fire. The preserve downgrade is the check that\n still catches it.\n \"\"\"\n\n NAME = 'p4b'\n EXPECT_RULES = ('preserve_by_default',)\n\n\nclass ProbeP5UncoveredDockerObjects(ProbeCase):\n \"\"\"A destructive command naming objects with no row in the table.\n\n The gate table classifies `_npx` only, and the plan proposes\n `docker rmi \u003ctwo object IDs>`. Explicit object IDs are a stated target, so\n the absence of any Docker row is a coverage violation.\n \"\"\"\n\n NAME = 'p5'\n EXPECT_RULES = ('target_coverage',)\n\n\nclass ProbeP6CommandInBareProse(ProbeCase):\n \"\"\"A command with no markdown quoting anywhere.\n\n \"Step 3 runs uv cache prune to reclaim 50.9 GiB. No backticks are used here.\"\n A checker that only reads code spans, fences and table cells sees no command\n at all, so an unsanctioned prune command passes unnoticed.\n \"\"\"\n\n NAME = 'p6'\n EXPECT_RULES = ('table_exists',)\n\n\nclass ProbeP7ChineseRuleHeader(ProbeCase):\n \"\"\"A translated header name must not disable the check.\n\n The rule column is named 规则依据. When the parser only knew 管辖规则, the\n whole column went unverified and the fabricated quote in the `_cacache` row\n passed. That row is also a preserve downgrade, which is caught independently.\n \"\"\"\n\n NAME = 'p7'\n EXPECT_RULES = ('quote_fidelity', 'preserve_by_default')\n\n\nclass ProbeP8PreserveRowInTheActionSet(ProbeCase):\n \"\"\"A PRESERVE row marked in-action-set, proposing an unsanctioned command.\n\n Three rules fire: the class may not sit in the action set, the category-wide\n command maps to a row that is in it, and the tool verification is missing.\n \"\"\"\n\n NAME = 'p8'\n EXPECT_RULES = ('action_set_classes', 'category_wide_exclusion')\n\n\nclass ProbeP9RuleCellWithoutQuotes(ProbeCase):\n \"\"\"A rule cell that paraphrases instead of quoting.\n\n \"cleanup_targets.md says keep this by default\" cites nothing checkable. A\n pass here would mean the checker verified zero quotes while reporting success,\n which is the failure mode the gate exists to prevent.\n \"\"\"\n\n NAME = 'p9'\n EXPECT_RULES = ('quote_fidelity',)\n\n\nclass ProbeP10UnknownReleaseCannotWin(ProbeCase):\n \"\"\"A row whose release is unknown cannot win the ranking.\n\n Chromium code-sign clones share extents, so its nominal size is not a\n release. The ranking half of that property now lives in the r3/r4 ranking\n probes (r3-07b, r3-07c, r3-10); this row is red for a different, later rule —\n a PROPOSABLE row in the action set with no never-used evidence — and the\n assertion below is what preserves the original point.\n \"\"\"\n\n NAME = 'p10'\n EXPECT_EXIT = 1\n EXPECT_RULES = ('action_set_classes',)\n\n def test_the_unknown_release_row_is_not_treated_as_a_preserve_target(self):\n self.assertNotIn('FAIL preserve_by_default', self.output)\n\n def test_the_measured_row_still_leads(self):\n self.assertIn('PASS lead_rule', self.output)\n\n\nclass ProbeP11NarrowChildOfAPreserveTarget(ProbeCase):\n \"\"\"Removing one exact inactive DerivedData child is the sanctioned form.\n\n The row targets `.../DerivedData/ProjA`, not \"Xcode DerivedData\" itself, and\n the reference prescribes removing an exact inactive project child. Treating\n the child as the preserve target would tell the agent that the one form the\n reference allows is a violation.\n \"\"\"\n\n NAME = 'p11'\n EXPECT_EXIT = 1\n EXPECT_RULES = ('action_set_classes',)\n\n def test_the_child_is_not_treated_as_the_preserve_target_itself(self):\n self.assertNotIn('FAIL preserve_by_default', self.output)\n\n\nclass ProbeP12NarrowChildWithSizeInTheTargetCell(ProbeCase):\n \"\"\"The same narrow child, with its nominal size written into the target cell.\n\n The target cell reads `.../DerivedData/ProjA(30.0 GiB)`. Reading a release\n out of the target column made that 30.0 GiB outrank the brew row's measured\n 2.00 GiB and failed a plan that ranks correctly.\n \"\"\"\n\n NAME = 'p12'\n EXPECT_EXIT = 1\n EXPECT_RULES = ('action_set_classes',)\n\n def test_the_child_is_not_treated_as_the_preserve_target_itself(self):\n self.assertNotIn('FAIL preserve_by_default', self.output)\n\n def test_its_nominal_size_is_not_read_as_a_release(self):\n # The target cell reads `.../DerivedData/ProjA(30.0 GiB)`, so the string\n # does appear in the row listing; what must not happen is the ranking\n # treating that 30.0 GiB as a release.\n self.assertNotIn('30.0 GiB)', self.output.split('PASS lead_rule')[-1])\n\n\nPROBE3_DIR = Path(__file__).resolve().parent / 'fixtures' / 'review-probes-r3'\n\n\ndef probe3(table_name, plan_name=None):\n \"\"\"(table, plan) for a round-3 probe.\n\n Some probes ship only one file: the A/B pairs share a plan or a gate table,\n so each assertion class names both halves explicitly.\n \"\"\"\n table = PROBE3_DIR / ('%s_gate.md' % table_name)\n plan = PROBE3_DIR / ('%s_plan.md' % (plan_name or table_name))\n return (table if table.is_file() else plan, plan if plan.is_file() else table)\n\n\nclass Probe3Case(unittest.TestCase):\n \"\"\"Base for the round-3 adversarial probes.\"\"\"\n\n TABLE = None\n PLAN = None\n EXPECT_EXIT = 1\n EXPECT_RULES = ()\n\n @classmethod\n def setUpClass(cls):\n table, plan = probe3(cls.TABLE, cls.PLAN)\n if not table.is_file():\n raise unittest.SkipTest('probe absent: %s' % cls.TABLE)\n cls.code, cls.output = run_checker('--table', str(table), '--plan', str(plan))\n\n def test_exit_code(self):\n self.assertEqual(self.EXPECT_EXIT, self.code, self.output)\n\n def test_names_the_violated_rules(self):\n for rule in self.EXPECT_RULES:\n self.assertIn('FAIL %s' % rule, self.output)\n\n\nclass Probe3WhitelistTests(unittest.TestCase):\n \"\"\"Commands the allowlist missed are now recognized rather than invisible.\n\n `docker volume rm \u003cname>` is the per-object form cleanup_targets.md itself\n prescribes; yarn/conda clear whole caches; `find ... -delete` and\n `diskutil secureErase` mutate; `xattr -d` strips quarantine. Before, none\n matched a pattern and the checker reported zero commands checked — which\n reads as \"checked and clean\" rather than \"not looked at\".\n \"\"\"\n\n def test_docker_volume_rm_is_recognized(self):\n table, plan = probe3('r3-01')\n code, output = run_checker('--table', str(table), '--plan', str(plan))\n # Red since the evidence rule landed: the row is PROPOSABLE with no\n # never-used evidence. What this probe still proves is that the command\n # is seen at all, which is what \"0 destructive command(s) checked\" hid.\n self.assertEqual(1, code, output)\n self.assertIn('FAIL action_set_classes', output)\n # Recognized, not invisible: the proposal and its help-text citation are\n # both seen, which is what \"0 destructive command(s) checked\" used to hide.\n self.assertRegex(output, r'[1-9]\\d* destructive command\\(s\\) checked')\n # Being counted is what makes the target and tool checks apply at all.\n self.assertRegex(output, r'[1-9]\\d* proposed command\\(s\\) checked')\n\n def test_yarn_and_conda_are_category_wide(self):\n table, plan = probe3('r3-02')\n code, output = run_checker('--table', str(table), '--plan', str(plan))\n self.assertEqual(1, code, output)\n self.assertIn('FAIL category_wide_exclusion', output)\n\n def test_find_delete_and_diskutil_secure_erase_are_recognized(self):\n table, plan = probe3('r3-03')\n code, output = run_checker('--table', str(table), '--plan', str(plan))\n self.assertEqual(1, code, output)\n self.assertIn('FAIL target_coverage', output)\n\n def test_unrecognized_commands_are_counted_not_silently_dropped(self):\n # The allowlist is a declared limit; a plan whose commands are all\n # outside it must say so instead of reporting \"0 checked\".\n plan = '## Proposal\\n\\n`someobscurecleaner --all`\\n'\n with open(str(PROBE3_DIR / 'r3-01_gate.md')) as handle:\n table_text = handle.read()\n commands = CHECKER.find_destructive_commands(plan)\n missed = CHECKER.unrecognized_command_lines(plan, commands)\n self.assertEqual(1, len(missed))\n self.assertIn('someobscurecleaner', missed[0][1])\n\n\nclass Probe3ExclusionScopeTests(unittest.TestCase):\n \"\"\"A \"we will not run this\" statement has to reach the command.\n\n A table row is a record: the release cell describing an exclusion does not\n retract the command stated in the command cell. Prose is contiguous, so a\n sentence-level negation still covers the commands inside it.\n \"\"\"\n\n TABLE = 'r3-04d'\n\n def test_exclusion_in_another_cell_does_not_exempt_the_command(self):\n table, plan = probe3('r3-04d')\n code, output = run_checker('--table', str(table), '--plan', str(plan))\n self.assertEqual(1, code, output)\n self.assertIn('FAIL preserve_by_default', output)\n\n def test_the_same_wording_in_the_command_cell_still_exempts(self):\n table, plan = probe3('r3-04d')\n exempt = ('## Proposal\\n\\n`uv cache prune` 不使用(prune 家族硬排除)\\n')\n commands = CHECKER.find_destructive_commands(exempt)\n self.assertEqual([], commands)\n\n def test_a_prose_negation_covers_its_command(self):\n prose = '本轮未跑 `npm cache clean`。\\n'\n self.assertEqual([], CHECKER.find_destructive_commands(prose))\n\n\nclass Probe3UnlockDirectionTests(Probe3Case):\n \"\"\"Only the verdict cell's quote counts as the user's direction.\n\n The governing-rule cell always carries quotes — that is what the gate asks\n for — so scanning the whole row confirmed an \"unlocked by user\" that had no\n user instruction at all.\n \"\"\"\n\n TABLE = 'r3-05'\n PLAN = 'r3-05b'\n EXPECT_RULES = ('action_set_classes',)\n\n\nclass Probe3PreserveDowngradeTests(unittest.TestCase):\n \"\"\"Downgraded preserve targets are caught through their real identifiers.\"\"\"\n\n def test_uv_cache_is_reachable(self):\n # \"uv\" is two characters, and a prose tokenizer with a four-character\n # floor dropped it, which made the uv cache entry unmatchable.\n table, plan = probe3('r3-06')\n code, output = run_checker('--table', str(table), '--plan', str(plan))\n self.assertEqual(1, code, output)\n self.assertIn('FAIL preserve_by_default', output)\n self.assertIn('uv cache', output)\n\n def test_playwright_matches_by_containment(self):\n table, plan = probe3('r3-06b')\n code, output = run_checker('--table', str(table), '--plan', str(plan))\n self.assertEqual(1, code, output)\n self.assertIn('FAIL preserve_by_default', output)\n self.assertIn('Playwright', output)\n\n\nclass Probe3ReleaseRankingTests(unittest.TestCase):\n \"\"\"A number labelled nominal is not a release, whoever wrote the label.\"\"\"\n\n def test_a_nominal_figure_beside_a_real_one_does_not_win(self):\n table, plan = probe3('r3-07', 'r3-07b')\n code, output = run_checker('--table', str(table), '--plan', str(plan))\n self.assertEqual(1, code, output)\n self.assertIn('FAIL lead_rule', output)\n\n def test_the_control_without_the_nominal_sentence_behaves_the_same(self):\n table, plan = probe3('r3-07c', 'r3-07b')\n code, output = run_checker('--table', str(table), '--plan', str(plan))\n self.assertEqual(1, code, output)\n self.assertIn('FAIL lead_rule', output)\n\n def test_the_plan_that_already_leads_with_the_biggest_passes(self):\n table, plan = probe3('r3-07', 'r3-07')\n code, output = run_checker('--table', str(table), '--plan', str(plan))\n self.assertEqual(0, code, output)\n\n\nclass Probe3CoverageBasenameTests(Probe3Case):\n \"\"\"Shared path components do not attribute a command to a row.\n\n `rm -rf ~/Library/Keychains` shares \"Library\" with the `~/Library/Logs` row;\n matching on that made the command look classified when no row covered it.\n \"\"\"\n\n TABLE = 'r3-08'\n EXPECT_RULES = ('target_coverage',)\n\n\nclass Probe3ShortQuoteTests(unittest.TestCase):\n \"\"\"A fabricated short quote is still a fabricated quote.\n\n \"Zzqqxx\" and \"Keep\" were dropped by a twelve-character floor before\n verification, so the check reported nothing quoted and passed.\n \"\"\"\n\n def test_short_fabricated_quotes_fail(self):\n table, plan = probe3('r3-09b', 'r3-09')\n code, output = run_checker('--table', str(table), '--plan', str(plan))\n self.assertEqual(1, code, output)\n self.assertIn('FAIL quote_fidelity', output)\n\n def test_the_same_content_lengthened_also_fails(self):\n table, plan = probe3('r3-09c', 'r3-09')\n code, output = run_checker('--table', str(table), '--plan', str(plan))\n self.assertEqual(1, code, output)\n self.assertIn('FAIL quote_fidelity', output)\n\n\nclass Probe3BrewPruneTests(Probe3Case):\n \"\"\"`brew cleanup --prune=all` is category-wide; plain `brew cleanup -s` is not.\n\n The gate names the --prune form specifically, and the reference prescribes\n `brew cleanup -s` as the safe one, so only the flag widens the scope.\n \"\"\"\n\n TABLE = 'r3-10'\n EXPECT_RULES = ('category_wide_exclusion',)\n\n\nclass Probe3HotspotReasonTests(unittest.TestCase):\n \"\"\"A bare hotspot label is not the stated reason the gate asks for.\"\"\"\n\n def test_a_two_character_reason_does_not_excuse_a_small_lead(self):\n table, plan = probe3('r3-11')\n code, output = run_checker('--table', str(table), '--plan', str(plan))\n self.assertEqual(1, code, output)\n self.assertIn('FAIL lead_rule', output)\n\n def test_the_control_without_any_marker_fails_the_same_way(self):\n table, plan = probe3('r3-11c', 'r3-11')\n code, output = run_checker('--table', str(table), '--plan', str(plan))\n self.assertEqual(1, code, output)\n self.assertIn('FAIL lead_rule', output)\n\n def test_a_section_named_for_semantics_does_not_exempt_the_real_lead(self):\n # The plan puts the small-fish command first under a heading that merely\n # contains the word \"semantics\". An earlier keyword exemption skipped it\n # and ranked the bigger row first, which is the wrong answer.\n table, plan = probe3('r3-11c', 'r3-12')\n code, output = run_checker('--table', str(table), '--plan', str(plan))\n self.assertEqual(1, code, output)\n self.assertIn('FAIL lead_rule', output)\n\n\nclass Probe3NegatedVerdictTests(unittest.TestCase):\n \"\"\"「保留在动作集之外」 keeps a row OUT of the action set.\n\n The string contains 在动作集, so the positive marker won and a compliant row\n read as a proposal — a false positive, which is the more damaging direction.\n \"\"\"\n\n def test_the_compliant_row_is_not_read_as_a_proposal(self):\n table, plan = probe3('r3-14b', 'r3-14')\n code, output = run_checker('--table', str(table), '--plan', str(plan))\n self.assertEqual(0, code, output)\n\n def test_the_proposable_variant_is_unchanged(self):\n table, plan = probe3('r3-14', 'r3-14')\n code, output = run_checker('--table', str(table), '--plan', str(plan))\n self.assertEqual(0, code, output)\n\n\nPROBE4_DIR = Path(__file__).resolve().parent / 'fixtures' / 'review-probes-r4'\n\n\ndef probe4(table_name, plan_name=None):\n \"\"\"(table, plan) for a round-4 probe; either file may stand in for both.\"\"\"\n table = PROBE4_DIR / ('%s_table.md' % table_name)\n plan = PROBE4_DIR / ('%s_plan.md' % (plan_name or table_name))\n return (table if table.is_file() else plan, plan if plan.is_file() else table)\n\n\nclass Probe4Case(unittest.TestCase):\n \"\"\"Base for the round-4 adversarial probes.\"\"\"\n\n TABLE = None\n PLAN = None\n EXPECT_EXIT = 1\n EXPECT_RULES = ()\n\n @classmethod\n def setUpClass(cls):\n table, plan = probe4(cls.TABLE, cls.PLAN)\n if not table.is_file():\n raise unittest.SkipTest('probe absent: %s' % cls.TABLE)\n cls.code, cls.output = run_checker('--table', str(table), '--plan', str(plan))\n\n def test_exit_code(self):\n self.assertEqual(self.EXPECT_EXIT, self.code, self.output)\n\n def test_names_the_violated_rules(self):\n for rule in self.EXPECT_RULES:\n self.assertIn('FAIL %s' % rule, self.output)\n\n\nclass Probe4PreserveSelfUnlockTests(Probe4Case):\n \"\"\"The mandatory rule quote may not stand in for never-used evidence.\n\n The reference's own preserve text contains \"obsolete\" and \"never-used\", so\n searching the whole row let a downgraded preserve target cite its own\n compliant quote as proof it was dead. This is the 2026-09-17 failure in the\n new table format, which is why the evidence search now excludes the rule cell\n and a preserve downgrade gets the stricter of the two bars.\n \"\"\"\n\n TABLE = 'r4-01'\n EXPECT_RULES = ('preserve_by_default',)\n\n\nclass Probe4PreserveSelfUnlockControlTests(Probe4Case):\n \"\"\"The same downgrade with a quote that carries no evidence word.\n\n The control uses the other uv rule. It was already red; the point of the pair\n is that both quotes now land in the same place, because what changed is where\n the evidence is looked for, not which words appear in the quote.\n \"\"\"\n\n TABLE = 'r4-01ctl'\n EXPECT_RULES = ('preserve_by_default',)\n\n\nclass Probe4PreserveSelfUnlockSecondTargetTests(Probe4Case):\n \"\"\"`_cacache` downgraded with a quote about \"verified never-used evidence\".\n\n The quote is the gate's own sentence describing what a downgrade requires, so\n matching it as evidence is circular.\n \"\"\"\n\n TABLE = 'r4-18'\n EXPECT_RULES = ('preserve_by_default',)\n\n\nclass Probe4UnplacedCategoryWideTests(Probe4Case):\n \"\"\"A category-wide command the table cannot place is not \"outside\" the set.\n\n The plan lists `uv cache prune` and `npm cache clean --force` against targets\n the classification table never mentions. Attributing them to nothing used to\n print \"none of them in the action set\" — a false clean bill on exactly the\n shape the hard-exclusion rules exist to stop.\n \"\"\"\n\n TABLE = 'r4-02b'\n EXPECT_RULES = ('category_wide_exclusion',)\n\n def test_it_does_not_claim_they_are_outside_the_action_set(self):\n self.assertNotIn('none of them in the action set', self.output)\n\n\nclass Probe4NoRuleFoundMustBeUserDecisionTests(Probe4Case):\n \"\"\"A target no reference covers is USER-DECISION, not PROPOSABLE.\n\n The gate says such a target is asked about before anything is proposed for\n it, and labelling it PROPOSABLE let an uncovered target lead the action set.\n \"\"\"\n\n TABLE = 'r4-03'\n EXPECT_RULES = ('action_set_classes',)\n\n\nclass Probe4UnlockDirectionNeedsVerdictColumnTests(Probe4Case):\n \"\"\"Without a verdict column, an unlock cannot be confirmed.\n\n The table puts the rule column last, so the last-cell fallback read the rule\n quote as the user's direction. With no verdict column to read, the row fails\n closed instead.\n \"\"\"\n\n TABLE = 'r4-04'\n EXPECT_RULES = ('action_set_classes',)\n\n\nclass Probe4UnlockDirectionControlTests(Probe4Case):\n \"\"\"The same row with a verdict column present and no quote in it.\n\n Caught by the ordinary missing-quote rule rather than the fail-closed one.\n \"\"\"\n\n TABLE = 'r4-04ctl'\n EXPECT_RULES = ('action_set_classes',)\n\n\nclass Probe4RuleColumnKeywordCollisionTests(Probe4Case):\n \"\"\"A bare 依据 must not claim the release column.\n\n \"Expected physical release + basis\" translates to 预期物理释放 + 依据, which\n matched the rule-column keywords and made the checker read the release cell\n as the rule — so a fabricated rule quote passed while the real quote sat in\n the column being read as an estimate.\n \"\"\"\n\n TABLE = 'r4-05'\n EXPECT_RULES = ('quote_fidelity',)\n\n\nclass Probe4PreserveFacePrefixTests(Probe4Case):\n \"\"\"`facetime` is not `face`.\n\n Prefix containment made a FaceTime cache match the Hugging Face cache entry.\n Exact token matching fixes it, and splitting a hyphenated basename is what\n keeps `ms-playwright` matching Playwright.\n \"\"\"\n\n TABLE = 'r4-07'\n EXPECT_EXIT = 0\n\n def test_no_check_fails(self):\n self.assertNotIn('FAIL', self.output)\n\n\nclass Probe4ReleaseColumnSynonymTests(Probe4Case):\n \"\"\"A release column the parser does not know silently disables the ranking.\n\n The column is named 预计回收空间; with no keyword for it every release read as\n unknown and the ranking rule passed without ranking anything.\n \"\"\"\n\n TABLE = 'r4-09'\n EXPECT_RULES = ('lead_rule',)\n\n\nclass Probe4NegatedNominalMarkerTests(Probe4Case):\n \"\"\"「不是 du 名义值」 confirms the figure it follows.\n\n A bare 不是 used to void the whole cell, so \"6.47 GiB(引擎 UNIQUE SIZE 求和,\n 不是 du 名义值)\" lost its 6.47 and the checker called the 2.0 GiB row the\n biggest. Nominal markers now only withdraw a number when nothing negates them.\n \"\"\"\n\n TABLE = 'r4-10'\n EXPECT_RULES = ('lead_rule',)\n\n def test_the_defended_number_is_the_one_ranked(self):\n self.assertIn('6.47 GiB', self.output)\n\n\nclass Probe4DockerPerObjectTests(Probe4Case):\n \"\"\"`docker rmi \u003cid>` names one object, so it is not prune-family.\n\n The reference prescribes this form; a subshell or a give-me-everything flag\n puts it back in the category-wide class, which is what the sibling probe\n covers.\n \"\"\"\n\n TABLE = 'r4-15'\n EXPECT_EXIT = 0\n\n def test_no_check_fails(self):\n self.assertNotIn('FAIL', self.output)\n\n\nclass Probe4DockerCategoryWideStillCaughtTests(Probe4Case):\n \"\"\"`docker rmi $(docker images -q)` is still category-wide.\"\"\"\n\n TABLE = 'r4-14'\n EXPECT_RULES = ('category_wide_exclusion',)\n\n\nclass Probe4ReferenceScopeKeepsPreserveTableTests(Probe4Case):\n \"\"\"Narrowing --reference must not disable the preserve cross-check.\n\n The preserve-by-default table is the skill's own contract, not part of the\n plan's citation scope, so it is always read from the bundle.\n \"\"\"\n\n TABLE = 'r4-16'\n\n def test_preserve_table_survives_a_narrowed_reference(self):\n bundle = SCRIPT_PATH.resolve().parents[1]\n code, output = run_checker(\n '--table', str(probe4('r4-16')[0]), '--plan', str(probe4('r4-16')[1]),\n '--reference', str(bundle / 'references' / 'cleanup_targets.md'))\n self.assertEqual(1, code, output)\n self.assertIn('FAIL preserve_by_default', output)\n self.assertIn('9 preserve-by-default target(s) parsed', output)\n\n\nclass Probe4MultilineTrashTests(Probe4Case):\n \"\"\"The skill's own documented Trash command spans lines and must pass.\n\n The `-- \"\u003cpath>\"` line never mentions osascript, so it was never recognized\n as part of the command — the gate rejected the form its own documentation\n prescribes.\n \"\"\"\n\n TABLE = 'r4-11b'\n EXPECT_EXIT = 0\n\n def test_no_check_fails(self):\n self.assertNotIn('FAIL', self.output)\n\n\nclass Probe4MultilineTrashControlTests(Probe4Case):\n \"\"\"The same command on one line already worked and still does.\"\"\"\n\n TABLE = 'r4-12'\n EXPECT_EXIT = 0\n\n def test_no_check_fails(self):\n self.assertNotIn('FAIL', self.output)\n\n\nclass Probe4EvidenceRequirementTests(Probe4Case):\n \"\"\"A PROPOSABLE row in the action set must state never-used evidence.\n\n Previously the requirement only existed inside the preserve-downgrade branch,\n so a non-preserve target could enter the action set on an assertion alone.\n \"\"\"\n\n TABLE = 'r4-13'\n EXPECT_RULES = ('action_set_classes',)\n\n\nclass Probe4EvidenceRequirementControlTests(Probe4Case):\n \"\"\"The same shape against a preserve target, caught by both rules.\"\"\"\n\n TABLE = 'r4-13ctl'\n EXPECT_RULES = ('action_set_classes', 'preserve_by_default')\n\n\nclass CopyBudgetTests(unittest.TestCase):\n def setUp(self):\n self.temp = tempfile.TemporaryDirectory()\n self.addCleanup(self.temp.cleanup)\n self.path = Path(self.temp.name) / 'copy.json'\n self.rows = CHECKER.collect_gate_rows(\n '| Target | Class | Verdict | Rule |\\n|---|---|---|---|\\n'\n '| /example/node_modules | REBUILDABLE | in action set | rebuild |\\n'\n '| /example/old.app | PROPOSABLE | in action set | retired |\\n'\n '| /example/unique | USER-DATA | not in action set | keep |\\n')\n self.data = dict(destination_parent=self.temp.name, max_total_bytes=1024,\n minimum_free_bytes=100, copies=[dict(\n target='/example/unique', source_bytes=100,\n copies_at_peak=3, reason='unique local work', basis='unique-state')])\n\n def check(self, available=5000):\n self.path.write_text(json.dumps(self.data))\n with patch.object(CHECKER.os, 'statvfs', return_value=SimpleNamespace(\n f_bavail=available, f_frsize=1)):\n return CHECKER.check_copy_budget(str(self.path), self.rows)\n\n def test_no_manifest_authorizes_no_copy(self):\n result = CHECKER.check_copy_budget(None, self.rows)\n self.assertTrue(result.passed)\n self.assertIn('no copy preparation authorized', result.render())\n\n def test_small_unique_copy_and_exact_count(self):\n result = self.check()\n self.assertTrue(result.passed)\n self.assertIn('1 copy entries examined; peak 300', result.render())\n\n def test_actual_destination_measurement(self):\n self.path.write_text(json.dumps(self.data))\n self.assertTrue(CHECKER.check_copy_budget(str(self.path), self.rows).passed)\n\n def test_disposable_backup_rejected_and_user_request_supported(self):\n for target in ('/example/node_modules', '/example/old.app'):\n with self.subTest(target=target):\n self.data['copies'][0]['target'] = target\n self.data['copies'][0]['basis'] = 'unique-state'\n self.assertIn('automatic backup', self.check().render())\n self.data['copies'][0]['basis'] = 'user-requested'\n self.data['copies'][0].pop('user_direction', None)\n self.assertFalse(self.check().passed)\n self.data['copies'][0]['user_direction'] = 'Preserve this exact artifact'\n self.assertTrue(self.check().passed)\n\n def test_all_uncompressed_copies_count_even_when_compression_expected(self):\n self.data['copies'][0]['copies_at_peak'] = 11\n self.assertIn('peak 1100 exceeds allowance 1024', self.check().render())\n\n def test_reserve_and_unknown_capacity_fail(self):\n self.assertFalse(self.check(1123).passed)\n self.assertTrue(self.check(1124).passed)\n with patch.object(CHECKER.os, 'statvfs', side_effect=OSError('unknown')):\n self.path.write_text(json.dumps(self.data))\n self.assertFalse(CHECKER.check_copy_budget(str(self.path), self.rows).passed)\n\n def test_missing_blank_null_and_boolean_budget_fields(self):\n for key in ('max_total_bytes', 'minimum_free_bytes'):\n for bad in (None, '', 0, -1, True):\n with self.subTest(key=key, bad=bad):\n original = self.data.pop(key)\n self.assertFalse(self.check().passed)\n self.data[key] = bad\n self.assertFalse(self.check().passed)\n self.data[key] = original\n for key in ('source_bytes', 'copies_at_peak', 'reason', 'basis', 'target'):\n original = self.data['copies'][0].pop(key)\n self.assertFalse(self.check().passed)\n self.data['copies'][0][key] = None\n self.assertFalse(self.check().passed)\n self.data['copies'][0][key] = original\n\n def test_invalid_manifest_and_destination(self):\n for bad in ('', '{', '[]', 'null'):\n self.path.write_text(bad)\n self.assertFalse(CHECKER.check_copy_budget(str(self.path), self.rows).passed)\n for parent in ('', '.', '/nonexistent-copy-destination'):\n self.data['destination_parent'] = parent\n self.assertFalse(self.check().passed)\n self.data['destination_parent'] = self.temp.name\n self.data['copies'] = []\n self.assertFalse(self.check().passed)\n\n\nif __name__ == '__main__':\n unittest.main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":81946,"content_sha256":"9ac63a090f26f72a000a43ea3a6379d1fd13b3f582f57e91ddb63d4cea84e57b"},{"filename":"tests/test_safe_delete.py","content":"#!/usr/bin/env python3\n\nimport importlib.util\nimport io\nimport tempfile\nimport unittest\nfrom contextlib import redirect_stdout\nfrom pathlib import Path\nfrom unittest.mock import patch\n\n\nSCRIPT_PATH = Path(__file__).resolve().parents[1] / \"scripts\" / \"safe_delete.py\"\nSPEC = importlib.util.spec_from_file_location(\"safe_delete\", SCRIPT_PATH)\nSAFE_DELETE = importlib.util.module_from_spec(SPEC)\nSPEC.loader.exec_module(SAFE_DELETE)\n\n\nclass OutputTruthTests(unittest.TestCase):\n def test_single_delete_never_calls_measured_size_freed_space(self):\n with tempfile.TemporaryDirectory() as temp_dir:\n target = Path(temp_dir) / \"candidate\"\n target.mkdir()\n output = io.StringIO()\n with (\n patch(\"sys.argv\", [\"safe_delete.py\", str(target)]),\n patch.object(SAFE_DELETE, \"confirm_delete\", return_value=True),\n patch.object(\n SAFE_DELETE,\n \"delete_path\",\n return_value=(True, \"Deleted successfully\"),\n ),\n redirect_stdout(output),\n ):\n exit_code = SAFE_DELETE.main()\n\n self.assertEqual(0, exit_code)\n self.assertIn(\"Measured size removed:\", output.getvalue())\n self.assertIn(\"verify with before/after df -k\", output.getvalue())\n self.assertNotIn(\"Freed:\", output.getvalue())\n\n def test_batch_delete_labels_sum_as_measured_not_physical(self):\n with tempfile.TemporaryDirectory() as temp_dir:\n first = Path(temp_dir) / \"first\"\n second = Path(temp_dir) / \"second\"\n first.mkdir()\n second.mkdir()\n output = io.StringIO()\n\n def approve_all(items):\n return items\n\n with (\n patch(\"sys.argv\", [\"safe_delete.py\", str(first), str(second)]),\n patch.object(SAFE_DELETE, \"batch_confirm\", side_effect=approve_all),\n patch.object(\n SAFE_DELETE,\n \"delete_path\",\n return_value=(True, \"Deleted successfully\"),\n ),\n redirect_stdout(output),\n ):\n exit_code = SAFE_DELETE.main()\n\n self.assertEqual(0, exit_code)\n self.assertIn(\"Measured size removed:\", output.getvalue())\n self.assertIn(\"Physical release:\", output.getvalue())\n self.assertNotIn(\"Total freed:\", output.getvalue())\n\n def test_missing_manifest_target_stops_before_confirmation(self):\n with tempfile.TemporaryDirectory() as temp_dir:\n missing = Path(temp_dir) / \"missing\"\n output = io.StringIO()\n with (\n patch(\"sys.argv\", [\"safe_delete.py\", str(missing)]),\n patch.object(SAFE_DELETE, \"confirm_delete\") as confirm_delete,\n redirect_stdout(output),\n ):\n exit_code = SAFE_DELETE.main()\n\n self.assertEqual(1, exit_code)\n confirm_delete.assert_not_called()\n self.assertIn(\"target set changed\", output.getvalue())\n\n def test_batch_stops_after_first_delete_failure(self):\n with tempfile.TemporaryDirectory() as temp_dir:\n first = Path(temp_dir) / \"first\"\n second = Path(temp_dir) / \"second\"\n first.mkdir()\n second.mkdir()\n output = io.StringIO()\n\n with (\n patch(\"sys.argv\", [\"safe_delete.py\", str(first), str(second)]),\n patch.object(\n SAFE_DELETE,\n \"batch_confirm\",\n side_effect=lambda items: items,\n ),\n patch.object(\n SAFE_DELETE,\n \"delete_path\",\n side_effect=[(False, \"first failed\"), (True, \"should not run\")],\n ) as delete_path,\n redirect_stdout(output),\n ):\n exit_code = SAFE_DELETE.main()\n\n self.assertEqual(1, exit_code)\n self.assertEqual(1, delete_path.call_count)\n self.assertIn(\"Stopping batch after the first failure\", output.getvalue())\n\n\nif __name__ == \"__main__\":\n unittest.main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":4269,"content_sha256":"6f363a8ec264b2041fadd7da1989a270615b863df66bc42c73238778254c870b"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"macOS Cleaner","type":"text"}]},{"type":"paragraph","content":[{"text":"Diagnose the actual source of disk pressure, reclaim only approved space, and prove that the cleanup did not damage user data or co-resident services.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Entry router","type":"text"}]},{"type":"paragraph","content":[{"text":"Choose the narrowest path that can answer the request:","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"User signal","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Route","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Identical inactive local files must retain every path and offline availability","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Read ","type":"text"},{"text":"references/apfs-shared-blocks.md","type":"text","marks":[{"type":"code_inline"}]},{"text":"; use only authorized explicit pairs, native APFS checks and the bundled guarded executor","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Apple Content Caching, ","type":"text"},{"text":"AssetCacheManagerUtil","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"CacheUsed","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"ActualCacheUsed","type":"text","marks":[{"type":"code_inline"}]},{"text":", iCloud cache, or “Caching needs more space”","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Read ","type":"text"},{"text":"references/apple_content_caching.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" completely before probing or proposing commands","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Docker, OrbStack, images, containers, or volumes","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Read ","type":"text"},{"text":"references/docker_analysis.md","type":"text","marks":[{"type":"code_inline"}]},{"text":"; inspect every object and never use prune-family commands","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Docker build cache","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Measure with ","type":"text"},{"text":"docker builder du","type":"text","marks":[{"type":"code_inline"}]},{"text":"; this skill reports it but does not delete it because Docker exposes category-wide prune controls rather than per-record intent","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Cargo ","type":"text"},{"text":"target","type":"text","marks":[{"type":"code_inline"}]},{"text":" trees or manual CI outputs","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Read ","type":"text"},{"text":"references/cleanup_targets.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" → Cargo and manual CI outputs before selecting files; requested test executables and unknown results can live inside dependency-named directories","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Chrome, ChromeDriver, Playwright, Codex Computer Use, Edge, or Chromium has large ","type":"text"},{"text":"*.code_sign_clone","type":"text","marks":[{"type":"code_inline"}]},{"text":" directories","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Read ","type":"text"},{"text":"references/chromium_code_sign_clones.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" completely; use the bundled analyzer to separate active, inactive, and unknown exact children and never equate ","type":"text"},{"text":"du","type":"text","marks":[{"type":"code_inline"}]},{"text":" with physical release","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"A named cache, directory, application, or service is already the suspect","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Inspect that target first and read the matching semantics in ","type":"text"},{"text":"references/cleanup_targets.md","type":"text","marks":[{"type":"code_inline"}]},{"text":"; do not start a home-directory or whole-disk scan","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"The user asks what has been growing or filling the disk lately, free space fell between two readings, or an older scan export exists (GrandPerspective text export, saved ","type":"text"},{"text":"du","type":"text","marks":[{"type":"code_inline"}]},{"text":" output)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Read ","type":"text"},{"text":"references/growth_attribution.md","type":"text","marks":[{"type":"code_inline"}]},{"text":"; answer with a delta table whose rows name their writer, not a size ranking","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"The source is genuinely unknown","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Use the general analysis workflow below; Mole is optional, not the universal first step","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"User-provided scope exclusions override every generic scan suggestion. Do not inspect personal directories, credentials, databases, application state, or unrelated services when the user excludes them.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Safety and authorization contract","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Separate observation from mutation.","type":"text","marks":[{"type":"strong"}]},{"text":" Complete a read-only diagnosis first. Do not delete, stop a service, edit settings, install or upgrade tools, or run a cleanup preview that may mutate state during that phase.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Confirm the exact target.","type":"text","marks":[{"type":"strong"}]},{"text":" On a remote Mac, record the current host identity before any other work. Never infer the machine from an IP, old PID, directory name, or prior report.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Plan before asking.","type":"text","marks":[{"type":"strong"}]},{"text":" Before any state change, list every command, what it changes, expected physical space reclaimed, impact, recoverability, and postconditions. Then stop if the user requested a plan-only phase.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Resolve existing authorization before asking.","type":"text","marks":[{"type":"strong"}]},{"text":" If the user supplies an exact confirmation phrase, require that phrase. Otherwise reuse an explicit cleanup instruction within its stated scope and conditions. For “clean anything proven safe,” bind exact targets to verified disposable contents, inactivity, retained source/state and restoration cost, then execute those targets without asking again. Unknown contents, unique work, active resources or wider scope need a separate decision; a peer's non-use report supplies evidence, never authorization.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Use precise supported controls — and check them for known defects first.","type":"text","marks":[{"type":"strong"}]},{"text":" Prefer an application's supported cache-management command or an exact object ID. \"Official\" and \"precise\" are not evidence of safety, so before the first supported-control command runs, do a known-issue check on that exact command at the installed version: a search of the tool's issue tracker for the command name, plus its changelog for fixes landing in a later release than the one installed. A command with an open, version-relevant defect is not a supported control for this skill — treat it like the category-wide commands in the next rule and either upgrade past the fix or use an exact-path alternative. If no supported control exists, an exact application-owned cache directory may be removed only after verifying its owner, confirming the application is stopped or the directory is otherwise inactive, explaining rebuild/redownload impact, and receiving approval. Never target a broad cache root or active application state.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Never use Docker prune-family commands.","type":"text","marks":[{"type":"strong"}]},{"text":" This includes image, container, volume, system, builder, and buildx prune. Category-wide deletion cannot express per-object user intent.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Avoid broad destructive shell forms.","type":"text","marks":[{"type":"strong"}]},{"text":" Do not recommend or execute broad ","type":"text"},{"text":"rm -rf","type":"text","marks":[{"type":"code_inline"}]},{"text":" or glob deletion. For exact approved ordinary files, prefer Finder Trash. The bundled legacy helper permanently deletes and has only the limited guards documented below; never treat it as equivalent to Trash.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Preserve valuable state.","type":"text","marks":[{"type":"strong"}]},{"text":" Never target user documents, credentials, SSH material, active databases, application configuration, or running-service state merely to increase the reported savings. Read ","type":"text"},{"text":"references/safety_rules.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" before any file deletion.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Execution follows the user's authorization.","type":"text","marks":[{"type":"strong"}]},{"text":" If the user asks only for analysis or wants to run commands personally, hand off the commands. When explicit existing authorization covers the verified targets, execute the exact approved actions and verify them. Unattended recurring deletion logic needs separate approval before it is written or enabled.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Fail fast within the affected target.","type":"text","marks":[{"type":"strong"}]},{"text":" An unexpected non-zero command, a mismatched postcondition, an unexpected target, or a changed dependency stops that action. Interpret documented probe statuses such as ","type":"text"},{"text":"lsof","type":"text","marks":[{"type":"code_inline"}]},{"text":" exit 1 with empty output before deciding they are failures. Preserve unresolved state and continue independent authorized targets. A failed optional backup is a reason to reassess whether the backup is needed before changing transfer tools or retrying.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Before promising physical release from any deletion, name the mechanism — and get it from the creating command, not from a guess.","type":"text","marks":[{"type":"strong"}]},{"text":" A ","type":"text"},{"text":"df","type":"text","marks":[{"type":"code_inline"}]},{"text":" gap between a nominal ","type":"text"},{"text":"du","type":"text","marks":[{"type":"code_inline"}]},{"text":" total and actual reclaim means one candidate mechanism is at work, and ","type":"text"},{"text":"the strongest evidence is the verbatim command that created the folder","type":"text","marks":[{"type":"strong"}]},{"text":", because the copy verb alone decides the space semantics. Find it in session history before theorizing; a folder-name or size-based inference is not a mechanism. Only if no command can be found does the gap stay ","type":"text"},{"text":"unknown","type":"text","marks":[{"type":"code_inline"}]},{"text":" — do not substitute the most plausible-sounding mechanism for one you can demonstrate.","type":"text"}]},{"type":"paragraph","content":[{"text":"The mechanisms, distinguished by what ","type":"text"},{"text":"du","type":"text","marks":[{"type":"code_inline"}]},{"text":" reports and what deletion releases (all four measured on 2026-09-19, 1 GiB source, drift-controlled):","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Created by","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"du","type":"text","marks":[{"type":"code_inline"}]},{"text":" nominal","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Deleting the copy releases","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Fingerprint","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"cp","type":"text","marks":[{"type":"code_inline"}]},{"text":" (bare) / ","type":"text"},{"text":"cp -a","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"full, counted per path","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"full","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"independent inodes, independent extents","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"cp -c","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" / ","type":"text","marks":[{"type":"strong"}]},{"text":"cp -cR","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (clonefile)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"full, counted per path","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"≈0","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"different inode, ","type":"text","marks":[{"type":"strong"}]},{"text":"nlink=1","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":", shared extent","type":"text","marks":[{"type":"strong"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"ln","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"cp -l","type":"text","marks":[{"type":"code_inline"}]},{"text":" (hard link)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"counted once","type":"text","marks":[{"type":"strong"}]},{"text":" — sibling reads ","type":"text"},{"text":"0","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"≈0","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"same inode, ","type":"text","marks":[{"type":"strong"}]},{"text":"nlink=2","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"local APFS snapshot","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"full","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"≈0 until snapshots are thinned","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"tmutil listlocalsnapshots /","type":"text","marks":[{"type":"code_inline"}]}]}]}]}]},{"type":"paragraph","content":[{"text":"Two traps this table exists to kill. ","type":"text"},{"text":"The clonefile and hard-link rows are opposite on ","type":"text","marks":[{"type":"strong"}]},{"text":"du","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" but identical on deletion","type":"text","marks":[{"type":"strong"}]},{"text":" — both release ≈0, so \"deleting it freed nothing\" cannot tell them apart, while ","type":"text"},{"text":"du","type":"text","marks":[{"type":"code_inline"}]},{"text":" can: a hard-linked sibling counts once (or as ","type":"text"},{"text":"0","type":"text","marks":[{"type":"code_inline"}]},{"text":"), a clonefile copy counts full. And ","type":"text"},{"text":"calibrating the wrong copy verb proves nothing","type":"text","marks":[{"type":"strong"}]},{"text":": a 98 GiB gap was twice attributed to the wrong mechanism because the probe used bare ","type":"text"},{"text":"cp","type":"text","marks":[{"type":"code_inline"}]},{"text":" when the folder had been built with ","type":"text"},{"text":"cp -cR","type":"text","marks":[{"type":"code_inline"}]},{"text":". Before any deletion, run the probe with the ","type":"text"},{"text":"same flags as the creating command","type":"text","marks":[{"type":"em"}]},{"text":". Clonefile is worth flagging as the common case — macOS ","type":"text"},{"text":"cp","type":"text","marks":[{"type":"code_inline"}]},{"text":" defaults to it for ","type":"text"},{"text":"-c","type":"text","marks":[{"type":"code_inline"}]},{"text":", and directories copied for a delivery/kit routinely carry it.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Phase contract","type":"text"}]},{"type":"paragraph","content":[{"text":"Use this state machine for every cleanup:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Observe — read-only.","type":"text","marks":[{"type":"strong"}]},{"text":" Capture identity, disk baseline, the suspected subsystem's status and configuration, physical allocation, and critical-service health. 1b. ","type":"text"},{"text":"Authorize stateful inspection when unavoidable.","type":"text","marks":[{"type":"strong"}]},{"text":" If deeper evidence requires creating a temporary container, pulling an image, mounting a volume, or writing a snapshot, first finish the metadata-only observation, list the exact inspection commands and their side effects, and obtain separate approval. Inspection approval is not cleanup approval.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Plan — no mutation.","type":"text","marks":[{"type":"strong"}]},{"text":" Pass the Phase 2 entry gate first, then explain findings, commands, impact, recovery, expected release, and success criteria. Stop at the confirmation gate when authorization is missing or the user requested a plan-only phase.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Execute — approved scope only.","type":"text","marks":[{"type":"strong"}]},{"text":" Re-read live state immediately before acting, then run each approved command separately and check its exit status and postcondition.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Verify — independent readback.","type":"text","marks":[{"type":"strong"}]},{"text":" Measure disk space and subsystem state again, recheck protected services, and observe long enough to detect immediate refill.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"When authorization is missing, stop between phases 2 and 3; printing a plan does not create approval. When an existing instruction already covers the verified targets and consequences, proceed after the gate without reopening the same permission question.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Choose the deletion basis before creating a backup","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Protect unique source edits, local Git state, credentials, databases and user records. A directory name, age or empty ","type":"text"},{"text":"lsof","type":"text","marks":[{"type":"code_inline"}]},{"text":" result alone does not establish disposability. A backup does not turn an unjustified deletion into a justified one.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"For an inactive, reproducible dependency directory, build cache or retired packaged test App, retain its source/lockfile or current installation and state the rebuild cost. Do not archive, duplicate, extract or hash a second full copy unless the user explicitly asks to preserve that artifact. Evidence of hand edits, unavailable private packages or embedded unique state changes the classification; an imagined possibility does not.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Treat “safe” as no loss of unique work and no disruption of live use, with restoration cost understood. Do not substitute byte-identical recovery of every disposable artifact for that condition. Do not require exact reproducibility of a retired binary the user has explicitly chosen to discard.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"For each preserved candidate, give the concrete blocking fact and the smallest next check. Resolve those checks within the task; neither “uncertain” nor a modest free-space improvement completes an authorized cleanup while known candidates remain unchecked.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Before any necessary backup/copy/extraction, run the existing plan checker with ","type":"text"},{"text":"--copy-budget \u003cmanifest.json>","type":"text","marks":[{"type":"code_inline"}]},{"text":" on the destination host. Account for all simultaneously retained archives, extracted trees, verification copies and retry partials at uncompressed source size; do not assume compression or APFS sharing will save space. Recheck destination free space before each stage; stop that copy when the declared reserve cannot be kept. Continue independent cleanup. See ","type":"text"},{"text":"references/safety_rules.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" for the manifest contract and limits.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Phase 1: read-only diagnosis","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Establish the baseline","type":"text"}]},{"type":"paragraph","content":[{"text":"At minimum, capture:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"/bin/date \"+%F %T %Z %z\"\n/usr/sbin/scutil --get ComputerName\n/usr/sbin/scutil --get LocalHostName\n/usr/bin/sw_vers\n/usr/sbin/sysctl kern.boottime vm.swapusage\n/bin/df -k /System/Volumes/Data\n/bin/df -h /System/Volumes/Data","type":"text"}]},{"type":"paragraph","content":[{"text":"Use ","type":"text"},{"text":"df -k","type":"text","marks":[{"type":"code_inline"}]},{"text":" for calculations and ","type":"text"},{"text":"df -h","type":"text","marks":[{"type":"code_inline"}]},{"text":" for the human-readable report. Treat an extension, label, or old report as a hint until the live command confirms it.","type":"text"}]},{"type":"paragraph","content":[{"text":"Copy a user-supplied free-space or capacity target exactly. A read-only inventory can proceed without one: report the current values and locate the largest occupants within the authorized read scope. Do not delay that diagnosis to ask for a cleanup target. A cleanup without an explicit target cannot claim that the user's capacity goal has been met; report measured release and remaining candidates instead.","type":"text"}]},{"type":"paragraph","content":[{"text":"Keep this first phase read-only. Do not run ","type":"text"},{"text":"scripts/cleanup_report.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" yet: it creates a local state directory and snapshot file. Preserve the command output in the report instead. On a remote target, always run the direct ","type":"text"},{"text":"df","type":"text","marks":[{"type":"code_inline"}]},{"text":" commands on that host; the local helper must not measure the controller Mac by mistake.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Follow the named suspect before broad scans","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Query the subsystem's own status and settings.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Measure physical allocation with a bounded ","type":"text"},{"text":"du","type":"text","marks":[{"type":"code_inline"}]},{"text":" on the exact data path only when permissions and user scope allow it.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Distinguish logical content size, sparse-file apparent size, purgeable space, and physically allocated bytes.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"For APFS clones, label ","type":"text"},{"text":"du","type":"text","marks":[{"type":"code_inline"}]},{"text":" as path-accounted or nominal rather than physical: shared extents can be attributed to every path, so actual release remains unknown until deletion and ","type":"text"},{"text":"df","type":"text","marks":[{"type":"code_inline"}]},{"text":" readback.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"For a suspected growing log, record exact file sizes at two or more timestamps. One large file or one recent mtime does not prove sustained growth.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Capture the current process, listeners, launch mechanism, and supported health probe of any co-resident service the user marks as critical. Re-resolve PIDs at each checkpoint.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"If the known suspect alone can meet the user's free-space target, do not scan unrelated personal or development directories “just in case.”","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"General analysis when the source is unknown","type":"text"}]},{"type":"paragraph","content":[{"text":"First separate ","type":"text"},{"text":"occupancy discovery","type":"text","marks":[{"type":"strong"}]},{"text":" from ","type":"text"},{"text":"cleanup selection","type":"text","marks":[{"type":"strong"}]},{"text":". Within each authorized root, obtain one shallow size breakdown (for example ","type":"text"},{"text":"du -x -k -d 1 \"\u003capproved-root>\"","type":"text","marks":[{"type":"code_inline"}]},{"text":"); include hidden children, record inaccessible paths as unknown, and reuse completed measurements. Show the largest occupants even when they must be preserved. Deepen the largest unexplained branches before spending another pass on small caches. Do not rescan completed siblings unless their state changed. This is read-only sizing, not permission to read file contents or delete anything; the approved-path contract below still governs content-bearing roots.","type":"text"}]},{"type":"paragraph","content":[{"text":"Then run the smallest ordered sequence below that can classify the relevant occupants. For a cleanup target, candidates need supported exact actions and defensible expected physical release before they can satisfy it. Raw allocation totals, logical cache sizes, shared Docker layers, Trash moves, and unverified “potential savings” do not satisfy the stop condition. For an inventory-only request, stop when the authorized roots' largest occupants and coverage gaps are explained; keep deletion pending authorization.","type":"text"}]},{"type":"paragraph","content":[{"text":"Do not make the physical-confidence ranking hide the user's largest visible hotspot. When a nominal/path-accounted candidate is larger than the recommended action, show both numbers and explain why its physical release is uncertain. Rank by defensible physical release, safety, and effort; report nominal size as a separate column instead of silently treating it as either zero or fully reclaimable.","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Order / signal","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Read-only action","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Stop or continue","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"1. Always","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Capture identity and ","type":"text"},{"text":"df -k/-h","type":"text","marks":[{"type":"code_inline"}]},{"text":"; inventory user exclusions","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Stop on target mismatch","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"2. Cache/log pressure, and ","type":"text"},{"text":"~/Library/Caches","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"~/Library/Logs","type":"text","marks":[{"type":"code_inline"}]},{"text":", plus the XDG dev caches are approved read scopes","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"uv run scripts/analyze_caches.py --user-only --include-dev","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Stop when measured candidates can meet the target","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"3. Developer tools are present and the script's fixed scope is approved","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"uv run scripts/analyze_dev_env.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" reads Docker/package managers plus existing ","type":"text"},{"text":"~/Projects","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"~/workspace","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"~/dev","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"~/src","type":"text","marks":[{"type":"code_inline"}]},{"text":", and ","type":"text"},{"text":"~/code","type":"text","marks":[{"type":"code_inline"}]},{"text":" roots","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Route Docker/OrbStack findings to their dedicated reference; skip this helper when any fixed root is out of scope","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"4. Uninstalled-app residue is plausible and its fixed roots are approved","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"uv run scripts/find_app_remnants.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" reads ","type":"text"},{"text":"/Applications","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"~/Applications","type":"text","marks":[{"type":"code_inline"}]},{"text":", and four documented ","type":"text"},{"text":"~/Library","type":"text","marks":[{"type":"code_inline"}]},{"text":" application-state roots","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Treat every result as a candidate, never proof of abandonment; skip when that scope is not approved","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"5. A content-bearing path is explicitly approved","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"uv run scripts/analyze_large_files.py --threshold 100 --path \"\u003capproved-path>\"","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Do not substitute ","type":"text"},{"text":"~","type":"text","marks":[{"type":"code_inline"}]},{"text":", Downloads, Documents, or the data-volume root when no path was approved","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"6. Still unknown after bounded checks, and the user explicitly approves Mole's fixed broad scan roots","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Read ","type":"text"},{"text":"references/mole_integration.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" and use ","type":"text"},{"text":"mo analyze","type":"text","marks":[{"type":"code_inline"}]},{"text":" through a TTY","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Mole cannot accept an arbitrary path scope; skip it when approval is narrower than its documented roots","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"An ","type":"text"},{"text":"\u003capproved-path>","type":"text","marks":[{"type":"code_inline"}]},{"text":" is an exact path the user named or explicitly accepted after its scope was described. If none exists, skip large-file and duplicate-content scanning, state that this evidence branch was not authorized, and continue with non-content-bearing evidence. Do not install or upgrade Mole during a read-only phase unless the user separately authorizes that change.","type":"text"}]},{"type":"paragraph","content":[{"text":"Mole's analyzer scans a fixed set that includes the home directory, application data, system libraries, applications, and volumes. Navigation inside the results does not make the underlying scan path-scoped. If that broad read scope is not approved, do not run Mole; stop with the bounded evidence already collected or ask for the missing scan authorization in the plan.","type":"text"}]},{"type":"paragraph","content":[{"text":"For an explicitly approved duplicate-file investigation, read the “Optional duplicate files” section in ","type":"text"},{"text":"references/cleanup_targets.md","type":"text","marks":[{"type":"code_inline"}]},{"text":". It is read-only and never uses an automatic-delete option.","type":"text"}]},{"type":"paragraph","content":[{"text":"A large data folder is not a cache — proposing its deletion needs an evidence chain, not a size ranking.","type":"text","marks":[{"type":"strong"}]},{"text":" When discovery surfaces a big project-asset / media / dataset directory (not a cache, not an app remnant), do NOT put it in the action set on size alone: read ","type":"text"},{"text":"references/proving-redundancy-before-deletion.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" before proposing anything. The deliverable is the evidence table; the unprovable row (purely manual usage) goes to the user, never gets papered over.","type":"text"}]},{"type":"paragraph","content":[{"text":"When discovery is fanned out to sub-agents, each returns candidates and measurements only — the classification, the acceptance, and the proposal happen in the session that runs the Phase 2 entry gate. A sub-agent's inventory is input to the classification table, never the plan.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Docker and OrbStack","type":"text"}]},{"type":"paragraph","content":[{"text":"Read ","type":"text"},{"text":"references/docker_analysis.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" before reporting Docker savings. List every image, container, and volume individually; inspect references and database-like contents; use actual sparse-file allocation rather than apparent size. A resource reported as dangling is not proof that its data is worthless. Build-cache measurement is supported, but build-cache deletion is deliberately out of scope because the available Docker controls are prune-family operations.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Phase 2: report and resolve authorization","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Phase 2 entry gate","type":"text"}]},{"type":"paragraph","content":[{"text":"Before starting or extending a long batch, read ","type":"text"},{"text":"references/batch-selection.md","type":"text","marks":[{"type":"code_inline"}]},{"text":". Use ","type":"text"},{"text":"scripts/batch_budget.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" to relate the measured pilot, remaining reclaim ceiling and time to the actual free-space gap; an item count is not capacity success.","type":"text"}]},{"type":"paragraph","content":[{"text":"A real 2026-09-19 run followed the Phase 1 machinery to the letter and still shipped three bad plans: it proposed preserve-by-default caches (npm ","type":"text"},{"text":"_cacache","type":"text","marks":[{"type":"code_inline"}]},{"text":", Playwright browsers, Homebrew) as a \"low-risk combo\" because the rule lives in ","type":"text"},{"text":"references/cleanup_targets.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" — a file the discovery workflow never opens; it proposed ","type":"text"},{"text":"uv cache prune","type":"text","marks":[{"type":"code_inline"}]},{"text":" as zero-impact from a ","type":"text"},{"text":"--help","type":"text","marks":[{"type":"code_inline"}]},{"text":" line plus size ratios, never verifying semantics or the installed version; and it led with 2 GB items beside a 91 GB candidate because no free-space target had been set. The rules that would have caught all three already existed in this skill — in a reference the procedure never opened, which makes them not rules in practice. A prose checklist is only one level better than the reference it summarizes: an independent review of the first draft of this gate found thirteen blocking defects, the deepest being that the gate itself had no mechanical enforcement. Step 4 is therefore a script, not a promise — run it, or the plan does not exist.","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"State the outcome being reported.","type":"text","marks":[{"type":"strong"}]},{"text":" For a cleanup target, use the user's exact value. Without one, deliver the occupancy inventory or ranked decision list; do not invent a capacity target or claim it has been met.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Open ","type":"text","marks":[{"type":"strong"}]},{"text":"references/cleanup_targets.md","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" and classify every candidate the discovery produced into this table, written to a file (step 4 parses it):","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Target","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Nominal size","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Physical confidence","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Class","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Governing rule (verbatim quote)","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Expected physical release + basis","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Restoration cost","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Verdict","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"exact target: a path, a service setting (e.g. ","type":"text"},{"text":"AssetCacheManagerUtil","type":"text","marks":[{"type":"code_inline"}]},{"text":" or a plist key), or a supported control (e.g. ","type":"text"},{"text":"chrome://settings/clearBrowserData","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"du output","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"path-accounted (Phase 1's label for APFS-shared or nominal du) / sparse-aware / engine-reported (a subsystem's own accounting, e.g. Docker ","type":"text"},{"text":"system df","type":"text","marks":[{"type":"code_inline"}]},{"text":") / unknown — never enter a nominal number as release","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"PRESERVE / PROPOSABLE / REBUILDABLE / USER-DATA / USER-DECISION","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"a quote from ","type":"text"},{"text":"cleanup_targets.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", the route's dedicated reference, or this skill's own SKILL.md (the checker verifies every quote against the whole bundle and prints per-file provenance, so SKILL.md-only quotes stay visible); \"no rule found\" is itself a finding","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"an estimate plus how it was derived; unknown is an allowed value","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"redownload/rebuild cost, or n/a","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"in action set / not in action set / unlocked by user (quote the direction)","type":"text"}]}]}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"PRESERVE","type":"text","marks":[{"type":"strong"}]},{"text":" — every row of the preserve-by-default table in ","type":"text"},{"text":"cleanup_targets.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" (opened in this step, quoted in the table — step 4 verifies the quotes, so a fabricated quote fails). Out of the action set unless (a) verified never-used evidence exists for the target, or (b) the user explicitly names that target for cleaning knowing the cost. Accepting a cost you stated is not (b): the user must name the target themselves.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"PROPOSABLE","type":"text","marks":[{"type":"strong"}]},{"text":" — verified never-used evidence (a dead project, an explicit user statement, an artifact check). Size ratios are not evidence: \"the cache holds 586 environments, 28 project venvs exist\" proves nothing about the other 558.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"REBUILDABLE","type":"text","marks":[{"type":"strong"}]},{"text":" — not in the preserve-by-default table, has a supported management command or cheap rebuild (Homebrew, pip), restoration cost stated in the row.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"USER-DATA","type":"text","marks":[{"type":"strong"}]},{"text":" — never in an action set; report the location, never a command.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"USER-DECISION","type":"text","marks":[{"type":"strong"}]},{"text":" — the user's workflow or ownership knowledge decides; ask, don't propose. Also the home for targets no reference covers (\"no rule found\"): the plan asks before proposing anything for them.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"If no reference anywhere covers a target, say \"no rule found\" and classify it USER-DECISION — do not stretch an unrelated rule to cover it.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Apply the gate rules.","type":"text","marks":[{"type":"strong"}]},{"text":" Step 4's checker enforces each one mechanically — that is what makes them rules rather than reminders:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The action set contains exactly the rows marked \"in action set\" plus rows marked \"unlocked by user\" with the user's direction quoted. No state-changing command may name a target absent from the table. PRESERVE rows enter the action set only via never-used evidence or explicit user direction (step 2).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The plan leads with the in-action-set row holding the largest expected physical release — unless the table marks another row as the user-visible hotspot or bottleneck (the thing the disk pressure is actually about) with a stated reason. Any other lead row fails this gate; that is the small-fish failure, not conservatism. Never rank by nominal size alone: nominal is not release, and for shared-extent candidates the release is unknown until deletion and ","type":"text"},{"text":"df","type":"text","marks":[{"type":"code_inline"}]},{"text":" readback (step 2's labels).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Every destructive command records: the owning tool's installed version (for an application control or a service command, the owning application or service); the semantics source (the tool's own help or documentation — if the only available source is this skill's transcription, say so and verify cheaply against the live tool); and for category-wide commands — scope is an entire class of objects with no per-object selection; the skill's existing term is prune-family — a known-issue check. ","type":"text"},{"text":"uv cache prune","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"npm cache clean","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"brew cleanup --prune","type":"text","marks":[{"type":"code_inline"}]},{"text":", and the Docker prune family are category-wide by definition.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Commands the skill declares unsanctioned (","type":"text"},{"text":"uv cache prune","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"npm cache clean","type":"text","marks":[{"type":"code_inline"}]},{"text":", the Docker prune family) may not appear in the action set even when every other rule passes — not for any size, not for any version. ","type":"text"},{"text":"npm cache clean","type":"text","marks":[{"type":"code_inline"}]},{"text":" joins the list because it wipes the preserve-by-default ","type":"text"},{"text":"_cacache","type":"text","marks":[{"type":"code_inline"}]},{"text":" wholesale. Only an explicit user instruction for that exact command moves it, and it runs in Phase 3 as a directed action, never as a proposal.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Zero in-action-set rows is a legitimate outcome: deliver the ranked decision list (PRESERVE and USER-DECISION rows with their unlock costs and evidence) and ask the user which to unlock. An empty action set with a decision list passes; inventing rows or downgrading PRESERVE to fill it does not.","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Write the plan to a file, then run the checker before sending it:","type":"text","marks":[{"type":"strong"}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"uv run scripts/check_gate_plan.py --table \u003cgate-table.md> --plan \u003cplan.md>","type":"text"}]},{"type":"paragraph","content":[{"text":"Exit 0 is necessary, but does not certify an unrecognized command. Read the examined-command count and every coverage diagnostic: a proposed deletion with zero commands examined is unchecked. Use a recognized exact form or independently resolve that command's target coverage and semantics before execution; do not treat a PASS beside “coverage incomplete” as approval. The checker verifies table classification, verbatim rule quotes, recognized destructive-command targets, action classes and unlock rules, preserve-by-default protections, category-wide exclusions, release ranking, and tool verification. It recognizes ","type":"text"},{"text":"uv run","type":"text","marks":[{"type":"code_inline"}]},{"text":" and Python calls to ","type":"text"},{"text":"safe_delete.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" with relative or absolute script paths and checks every literal target separately; unresolved batch files and shell expansions fail coverage. A failing run names the violated rule — fix the plan; do not weaken the checker.","type":"text"}]},{"type":"paragraph","content":[{"text":"Five failure shapes the checker reliably catches, measured while getting a real 18-target plan to exit 0 — all five are plan-side fixes, never checker-side:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Path form mismatch","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"target_coverage","type":"text","marks":[{"type":"code_inline"}]},{"text":"): the table must carry the same full absolute path the command uses; a ","type":"text"},{"text":"~/","type":"text","marks":[{"type":"code_inline"}]},{"text":" shorthand row does not match a command that names ","type":"text"},{"text":"/Users/…","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Two targets on one row","type":"text","marks":[{"type":"strong"}]},{"text":": a row naming two paired targets fails to parse — split into one row per target.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"\"du nominal\" in the release column","type":"text","marks":[{"type":"strong"}]},{"text":" reads as a nominal number and is rejected; state how the expected release was derived instead, or enter ","type":"text"},{"text":"unknown","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"A single-line osascript inside a table cell","type":"text","marks":[{"type":"strong"}]},{"text":" gets cut into pipe-delimited chunks the command matcher cannot reassemble — put the canonical multi-line osascript in the plan's code block, not in a table row.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Naming the command in prose","type":"text","marks":[{"type":"strong"}]},{"text":" (an intro sentence mentioning osascript) is parsed as a command start with no matching row — keep command names out of prose, and start table rows with a path, not a label (a row leading with ","type":"text"},{"text":"OrbStack","type":"text","marks":[{"type":"code_inline"}]},{"text":" fails).","type":"text"}]}]}]}]}]},{"type":"paragraph","content":[{"text":"Report observed values rather than inferred properties. Use ","type":"text"},{"text":"references/report_templates.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" for the long-form layout and include these fields for every proposed action:","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Field","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Required content","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Current state","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Timestamp, target identity, disk used/free/capacity, and subsystem status","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Evidence","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"The command and observed value; state whether the number is logical or physical","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Exact command","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"The command that would change state, with the exact target or object ID","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Change","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"What the command modifies or removes","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Recoverability","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Reversible command, Trash recovery, backup restore, or redownload-only","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Expected release","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Physical-space estimate with unit and assumptions","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Service impact","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"User-visible effects and protected-service invariants","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Postconditions","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Values that must be true before the action is called successful","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"Classify findings by consequence, not by how tempting the number is:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Rebuildable cache:","type":"text","marks":[{"type":"strong"}]},{"text":" deletion loses only a local copy, but state the redownload or rebuild cost. ","type":"text"},{"text":"Rebuildable is not proposable by default:","type":"text","marks":[{"type":"strong"}]},{"text":" the preserve-by-default table in ","type":"text"},{"text":"references/cleanup_targets.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" (opened and quoted in the Phase 2 entry gate) decides; those targets stay out of the action set until never-used evidence or explicit user direction moves them.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"User decision required:","type":"text","marks":[{"type":"strong"}]},{"text":" value depends on the user's workflow or ownership knowledge.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Preserve:","type":"text","marks":[{"type":"strong"}]},{"text":" user data, credentials, database state, active configuration, or anything whose role is uncertain.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Do not call a cache “absolutely safe” merely because software can regenerate it. Regeneration time, bandwidth, authentication, and offline availability are real costs.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Phase 3: execute the confirmed plan","type":"text"}]},{"type":"paragraph","content":[{"text":"Immediately before the first state-changing command:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Reconfirm the target identity and current disk state.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Re-query the objects or subsystem status; cleanup plans expire when live state changes.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Recheck protected-service health.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Compare the approved commands and exact target set with the commands about to run. Any wider or different target set requires new approval. A separately preserved item becoming inactive does not invalidate the plan when it remains explicitly excluded and the approved target-set hash still matches.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Keep approvals and exclusions attached to the original inventory. “Delete these; keep the rest” approves only those targets; “continue looking” resumes read-only discovery. If some approved paths have disappeared, stop the batch and report original, remaining and missing counts. Proceed with a smaller set only after proving it is a subset of that inventory with unchanged identity/content and consequences; the existing authorization can cover that verified remainder. A newly generated replacement at the same path is a changed target. Do not substitute a peer's inactivity report for user approval, or report already-absent paths as deleted by this run. The legacy helper still stops on missing targets; reconcile the plan before invoking it again.","type":"text"}]},{"type":"paragraph","content":[{"text":"If the approved plan includes creation of a local before/after report artifact, capture the before snapshot now, after approval and before the first cleanup command:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"uv run scripts/cleanup_report.py --snapshot before","type":"text"}]},{"type":"paragraph","content":[{"text":"This writes under ","type":"text"},{"text":"~/.macos-cleaner","type":"text","marks":[{"type":"code_inline"}]},{"text":", so list it in the plan. It is optional and local-target only; direct ","type":"text"},{"text":"df -k/-h","type":"text","marks":[{"type":"code_inline"}]},{"text":" readings remain the source of truth. Never run it on the controller Mac as a substitute for measuring a remote target.","type":"text"}]},{"type":"paragraph","content":[{"text":"Run one state-changing command at a time. Read the result before sending the next command. After each command, run the postcondition that can distinguish success from partial success.","type":"text"}]},{"type":"paragraph","content":[{"text":"For an exact ordinary file that is not user data, application state, a database, or a protected path, prefer a recoverable Finder Trash move after the user confirms the exact path:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"/usr/bin/osascript \\\n -e 'on run argv' \\\n -e 'tell application \"Finder\" to delete (POSIX file (item 1 of argv))' \\\n -e 'end run' -- \"\u003cexact-path>\"","type":"text"}]},{"type":"paragraph","content":[{"text":"Moving to Trash usually releases no physical space until Trash is emptied; state that in the plan.","type":"text"}]},{"type":"paragraph","content":[{"text":"Remote and headless targets: the Finder channel needs Automation consent, and ssh-originated AppleEvents hang without it.","type":"text","marks":[{"type":"strong"}]},{"text":" Measured 2026-10-07 on a headless worker: an ssh session driving the osascript Trash move above blocked indefinitely — nine targets in, target one still present, Trash unchanged, and even a harmless Finder probe (","type":"text"},{"text":"count of items","type":"text","marks":[{"type":"code_inline"}]},{"text":") never returned. There is no error to catch; the tell is the silence. Before relying on the Finder channel over ssh, run a bounded probe first and treat a hang as the channel being closed, not as a slow Finder. Stock macOS has no ","type":"text"},{"text":"timeout(1)","type":"text","marks":[{"type":"code_inline"}]},{"text":", so bound it in AppleScript itself:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"/usr/bin/osascript \\\n -e 'with timeout of 1 second' \\\n -e 'tell application \"Finder\" to count of items of trash' \\\n -e 'end timeout'","type":"text"}]},{"type":"paragraph","content":[{"text":"The same-volume fallback is a quarantine move, not Trash: ","type":"text"},{"text":"mv","type":"text","marks":[{"type":"code_inline"}]},{"text":" each exact approved target to a directory on the same volume (","type":"text"},{"text":"\u003capproved-dir>/_quarantine-\u003cdate>/","type":"text","marks":[{"type":"code_inline"}]},{"text":"), writing a MANIFEST (original path, size, timestamp) beside it — the original path is what an ","type":"text"},{"text":"mv","type":"text","marks":[{"type":"code_inline"}]},{"text":"-back recovery needs. It shares Trash's safety properties — same-volume ","type":"text"},{"text":"mv","type":"text","marks":[{"type":"code_inline"}]},{"text":" is metadata-only, fully recoverable, and releases no space until a separately approved permanent pass — and it needs no Automation consent. Keep the two phases split exactly as with Trash: the quarantine move and the later permanent deletion are separate approvals, and the plan must name the quarantine location and the MANIFEST. The Phase 2 plan checker recognizes the quarantine form specifically (a leading ","type":"text"},{"text":"mv","type":"text","marks":[{"type":"code_inline"}]},{"text":", tolerating a command-table cell's backtick, whose first argument is a quoted or path-ish token and whose command carries the ","type":"text"},{"text":"_quarantine-","type":"text","marks":[{"type":"code_inline"}]},{"text":" marker) and binds each such command to its table row exactly as it does the osascript Trash form; an ordinary ","type":"text"},{"text":"mv","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"git mv","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"sudo mv","type":"text","marks":[{"type":"code_inline"}]},{"text":", a non-quarantine rename, or a prose sentence that merely starts with the word \"mv\" is deliberately not recognized, so a quarantine command still states each target explicitly in the table.","type":"text"}]},{"type":"paragraph","content":[{"text":"scripts/safe_delete.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" is a legacy permanent-deletion helper with an interactive prompt and a limited system/credential denylist. It does not move to Trash, check every user-data root, detect open files, or independently prove reclaimed bytes. Passing multiple paths switches it to one batch confirmation accepting ","type":"text"},{"text":"all","type":"text","marks":[{"type":"code_inline"}]},{"text":", an index list, or ","type":"text"},{"text":"none","type":"text","marks":[{"type":"code_inline"}]},{"text":" — piping ","type":"text"},{"text":"yes","type":"text","marks":[{"type":"code_inline"}]},{"text":" feeds ","type":"text"},{"text":"y","type":"text","marks":[{"type":"code_inline"}]},{"text":", which the batch parser rejects as an invalid selection and then cancels the whole batch deleting nothing (measured 2026-10-23); a pre-authorized batch answers ","type":"text"},{"text":"all","type":"text","marks":[{"type":"code_inline"}]},{"text":". Use it only when the exact non-user-data target and irreversible deletion were explicitly approved:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"uv run scripts/safe_delete.py \u003cexact-path> [\u003cexact-path> ...]","type":"text"}]},{"type":"paragraph","content":[{"text":"Do not use this helper for user documents, application-managed caches such as Apple Content Caching, databases, credentials, or any target whose role is uncertain.","type":"text"}]},{"type":"paragraph","content":[{"text":"Two narrow Finder-Trash branches remain available without weakening those exclusions:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"For an exact inactive application cache with no verified supported management control, follow the named-cache discovery/in-use protocol in ","type":"text"},{"text":"references/cleanup_targets.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", explain the rebuild cost, and obtain explicit approval for that exact cache path.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"For duplicate files inside an exact approved user-data root, first show each duplicate set's size/hash and every path. The user must name the copy to keep and each copy to remove. Move only those confirmed files to Finder Trash; never use the permanent helper, an automatic duplicate-selection flag, ","type":"text"},{"text":"all","type":"text","marks":[{"type":"code_inline"}]},{"text":", a glob, or a directory-level target. Verify the source paths moved to Trash, and state that physical space is not released until Trash is separately reviewed and emptied.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Phase 4: verify and observe","type":"text"}]},{"type":"paragraph","content":[{"text":"Verification must cover all clauses of the approved plan:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Re-read ","type":"text"},{"text":"df -k","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"df -h","type":"text","marks":[{"type":"code_inline"}]},{"text":"; report the net change over the measured window separately from the path-accounted size removed. Check timestamps and ","type":"text"},{"text":"kern.boottime","type":"text","marks":[{"type":"code_inline"}]},{"text":": a reboot, swap release or concurrent writer makes this a mixed window, not proof that deletion caused the full change. Report the unexplained remainder rather than forcing it onto a directory.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Re-query the cleaned subsystem's activation, configuration, and physical usage.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Re-resolve each protected service's PID, listeners, launch mechanism, and health probe. A healthy disk does not prove the service survived. Report a failed invariant and stop the affected action; disk cleanup does not authorize restarting services, terminating other sessions, or repairing an unrelated application.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Observe at bounded intervals when APFS accounting can lag or the source may refill. Record each timestamp and value.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"If the free-space target is missed, stop all deletion. Begin a second read-only analysis and rank remaining sources by measured physical allocation.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Never report “fixed” when only the command exit code is known. A successful cleanup requires both the intended state and the protected invariants. Use the ","type":"text"},{"text":"Phase-4 result fields and representative cases","type":"text","marks":[{"type":"link","attrs":{"href":"references/report_templates.md#phase-4-result","title":null}}]},{"text":" to distinguish emergency recovery from an unfinished capacity investigation and to close already-explained work without extra scans.","type":"text"}]},{"type":"paragraph","content":[{"text":"For a local target whose before snapshot was captured by the helper, generate the comparison with:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"uv run scripts/cleanup_report.py --snapshot after --compare","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Resources","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/batch-selection.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"scripts/batch_budget.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" — select a bounded next batch using supplied capacity and pilot evidence; unknown ceilings stay unknown.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/apfs-shared-blocks.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"scripts/apfs_cow.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" — preserve exact inactive local paths with guarded APFS cloning, pinned rollback, per-item lock release and an exact remainder.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"scripts/stream_accounting.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" — calculate internal full-clone allocation upper bounds from supplied native observations; not a probe or physical-release measurement.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Load only the branch relevant to the current task:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/apple_content_caching.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" — Apple Content Caching diagnosis, unit interpretation, supported remote controls, confirmation plan, and post-cleanup verification.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/chromium_code_sign_clones.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" — Chrome/Chromium/Edge code-sign-clone semantics, nominal-versus-physical reporting, exact inactive-target manifests, cleanup verification, and recurrence prevention.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/cleanup_targets.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" — cache, log, application, developer, large-file, and Time Machine target semantics.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/proving-redundancy-before-deletion.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" — Load BEFORE proposing deletion of any big project-asset / media / dataset directory; a size ranking is not evidence.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/growth_attribution.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" — \"what grew lately\" questions: finding and calibrating a baseline export, the not-scanned trap, which file timestamp answers \"arrived since\", reconciling path-accounted deltas with ","type":"text"},{"text":"df","type":"text","marks":[{"type":"code_inline"}]},{"text":" (swap, snapshots, short-lived copies), and naming the writer.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/docker_analysis.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" — per-object Docker and OrbStack analysis, database-volume safeguards, and refill root-cause diagnosis.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/mole_integration.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" — TTY workflow for interactive Mole analysis and preview.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/report_templates.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" — long-form general and Docker report templates.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/safety_rules.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" — blocked paths, confirmation, recovery, and file-deletion safety checks.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"scripts/analyze_caches.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" — bounded cache inventory; retain ","type":"text"},{"text":"unknown","type":"text","marks":[{"type":"code_inline"}]},{"text":" measurements and diagnostics, treat incomplete subtotals as known lower bounds, and interpret exit 1 as incomplete measurement or unresolved developer paths rather than zero usage.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"scripts/check_gate_plan.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" — Phase 2 entry gate checker: parses the classification table and the plan, verifies governing-rule quotes against the references, destructive-command target coverage (with unrecognized command forms counted and reported), action-set class and unlock rules, the preserve-by-default downgrade cross-check, category-wide exclusions, the lead-row ranking rule, and tool verification. Exit 0 is required before a plan may be sent. Its command whitelist is a declared limitation: forms outside the list are reported as unrecognized rather than silently ignored.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"scripts/analyze_code_sign_clones.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" — read-only current-user code-sign-clone inventory; after approval it can revalidate an exact candidate SHA and write a non-overwriting batch manifest.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"scripts/find_app_remnants.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" — application-remnant candidates; reads its fixed Applications and ","type":"text"},{"text":"~/Library","type":"text","marks":[{"type":"code_inline"}]},{"text":" roots, so require that scope first.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"scripts/analyze_large_files.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" — large-file discovery inside an approved path; exit 1 marks an incomplete scan (find errors, timeout, unreadable files), never a clean result. \"No single file above the threshold\" does not make the path small: many below-threshold files can make one directory the dominant cost, so read directory aggregates independently of top single files. Preserving content on a NAS is not the same as local residency. Use an installed, authorized NAS transfer owner (","type":"text"},{"text":"nas-use","type":"text","marks":[{"type":"code_inline"}]},{"text":" where available); it is not bundled here. Without one, retain the assets and report migration as unverified instead of improvising a copy.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"scripts/analyze_dev_env.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" — Docker/package-manager inventory plus fixed common-project-root ","type":"text"},{"text":".git","type":"text","marks":[{"type":"code_inline"}]},{"text":" sizing; require that full read scope first.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"scripts/safe_delete.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" — legacy guarded permanent deletion for exact approved non-user-data targets; it is not a Trash or recovery tool.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"scripts/cleanup_report.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" — local-target before/after reporting for ","type":"text"},{"text":"/System/Volumes/Data","type":"text","marks":[{"type":"code_inline"}]},{"text":" by default, with an explicit ","type":"text"},{"text":"--volume","type":"text","marks":[{"type":"code_inline"}]},{"text":" override.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Do not use this skill when","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The target is Windows or Linux.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The requested action requires disabling SIP or bypassing macOS protections.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The user asks for silent or automatic deletion without an auditable scope and confirmation gate.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The task is only to tune application behavior and has no disk-space diagnosis or recovery goal.","type":"text"}]}]}]}]},"metadata":{"date":"2026-11-02","name":"macos-cleaner","author":"@skillopedia","source":{"stars":1511,"repo_name":"claude-code-skills","origin_url":"https://github.com/daymade/claude-code-skills/blob/HEAD/daymade-macos/macos-cleaner/SKILL.md","repo_owner":"daymade","body_sha256":"71f97f1b1ec9e7b8d812121ae34a5b9ae7273ac3a62b18761a237018d76391b8","cluster_key":"5d7263e230c04bd5cf51851f2de75358d003007b12f0d31c8185cc2a6d4ae000","clean_bundle":{"format":"clean-skill-bundle-v1","source":"daymade/claude-code-skills/daymade-macos/macos-cleaner/SKILL.md","text_bytes":614780,"source_commit":"0633c92cdf1192ebed0b550384ea2a56f0abd520","skills_sh_rank":8921,"attachment_count":100,"text_attachments":100,"attachment_storage":"skill_attachments","binary_attachments":0},"cluster_size":1,"skill_md_path":"daymade-macos/macos-cleaner/SKILL.md","import_metadata":{"date":"2026-11-02","author":"@skillopedia","version":"v1","category":"productivity-workflow","category_label":"Productivity"},"exact_dupes_collapsed_into_this":0},"version":"v1","category":"productivity-workflow","import_tag":"skills-sh-top20k-2026-11","description":"Diagnoses and safely reclaims macOS disk space: caches, logs, app remnants, large/duplicate files, Docker/OrbStack, Chromium code-sign clones, developer caches. Use when a Mac is low on storage, shows “Caching needs more space”, or before deleting anything to avoid data loss. Not for Windows/Linux or permission issues (use macos-permissions)."}},"renderedAt":1793661731882}

macOS Cleaner Diagnose the actual source of disk pressure, reclaim only approved space, and prove that the cleanup did not damage user data or co-resident services. Entry router Choose the narrowest path that can answer the request: | User signal | Route | |---|---| | Identical inactive local files must retain every path and offline availability | Read ; use only authorized explicit pairs, native APFS checks and the bundled guarded executor | | Apple Content Caching, , , , iCloud cache, or “Caching needs more space” | Read completely before probing or proposing commands | | Docker, OrbStack,…