Scope Creep Detector A one-line fix should not require a reviewer to reverse-engineer fourteen files across three subsystems. This skill compares a git diff with its stated intent, surfaces scope signals, and turns them into keep, split, or justify decisions. Everything runs locally. The script makes no network calls and does not change the working tree, index, commits, or branches. When to use - Before opening a pull request whose diff may have grown beyond its intent - When a bug fix touches unexpected files or subsystems - When the user asks whether staged changes are too broad - When a di…

)\nPYPROJECT_STRING_RE = re.compile(r'^\\s*[\"\\']([A-Za-z0-9][A-Za-z0-9_.-]*)[^\"\\']*[\"\\']\\s*,?\\s*

Scope Creep Detector A one-line fix should not require a reviewer to reverse-engineer fourteen files across three subsystems. This skill compares a git diff with its stated intent, surfaces scope signals, and turns them into keep, split, or justify decisions. Everything runs locally. The script makes no network calls and does not change the working tree, index, commits, or branches. When to use - Before opening a pull request whose diff may have grown beyond its intent - When a bug fix touches unexpected files or subsystems - When the user asks whether staged changes are too broad - When a di…

)\nPYPROJECT_ASSIGN_RE = re.compile(r\"^\\s*([A-Za-z0-9][A-Za-z0-9_.-]*)\\s*=\\s*(.+)$\")\n\n\ndef clean_path(value):\n \"\"\"Normalize a git header path without touching the filesystem.\"\"\"\n value = value.strip()\n if \"\\t\" in value:\n value = value.split(\"\\t\", 1)[0].rstrip()\n if value.startswith('\"') and value.endswith('\"'):\n try:\n value = json.loads(value)\n except json.JSONDecodeError:\n value = value[1:-1]\n if value in {\"/dev/null\", \"dev/null\"}:\n return None\n if value.startswith((\"a/\", \"b/\")):\n value = value[2:]\n return value\n\n\ndef new_file(old_path=None, new_path=None):\n return {\n \"old_path\": old_path,\n \"new_path\": new_path,\n \"path\": new_path or old_path or \"unknown\",\n \"hunks\": [],\n }\n\n\ndef parse_diff(text):\n \"\"\"Parse the file and hunk structure needed by the classifiers.\"\"\"\n files = []\n current = None\n hunk = None\n old_remaining = new_remaining = 0\n\n for raw in text.splitlines():\n if raw.startswith(\"diff --git \"):\n try:\n parts = shlex.split(raw)\n except ValueError:\n parts = raw.split()\n old_path = clean_path(parts[2]) if len(parts) > 2 else None\n new_path = clean_path(parts[3]) if len(parts) > 3 else None\n current = new_file(old_path, new_path)\n files.append(current)\n hunk = None\n continue\n\n if current is None:\n if not raw.startswith(\"--- \"):\n continue\n current = new_file()\n files.append(current)\n\n match = HUNK_RE.match(raw)\n if match:\n old_remaining = int(match.group(2) or \"1\")\n new_remaining = int(match.group(4) or \"1\")\n hunk = {\"header\": raw, \"lines\": []}\n current[\"hunks\"].append(hunk)\n continue\n\n if hunk is not None:\n if not raw:\n continue\n marker = raw[0]\n if marker not in {\" \", \"+\", \"-\"}:\n continue\n entry = {\n \"marker\": marker,\n \"text\": raw[1:],\n \"index\": len(hunk[\"lines\"]),\n }\n hunk[\"lines\"].append(entry)\n if marker != \"+\":\n old_remaining -= 1\n if marker != \"-\":\n new_remaining -= 1\n if old_remaining \u003c= 0 and new_remaining \u003c= 0:\n hunk = None\n continue\n\n if raw.startswith(\"--- \") and current[\"hunks\"]:\n current = new_file()\n files.append(current)\n if raw.startswith(\"rename from \"):\n current[\"old_path\"] = clean_path(raw[len(\"rename from \"):])\n current[\"path\"] = current[\"new_path\"] or current[\"old_path\"]\n continue\n if raw.startswith(\"rename to \"):\n current[\"new_path\"] = clean_path(raw[len(\"rename to \"):])\n current[\"path\"] = current[\"new_path\"] or current[\"old_path\"]\n continue\n if raw.startswith(\"--- \"):\n current[\"old_path\"] = clean_path(raw[4:])\n current[\"path\"] = current[\"new_path\"] or current[\"old_path\"] or \"unknown\"\n continue\n if raw.startswith(\"+++ \"):\n current[\"new_path\"] = clean_path(raw[4:])\n current[\"path\"] = current[\"new_path\"] or current[\"old_path\"] or \"unknown\"\n continue\n\n return files\n\n\ndef changed_lines(file_change, marker):\n return [\n entry[\"text\"]\n for hunk in file_change[\"hunks\"]\n for entry in hunk[\"lines\"]\n if entry[\"marker\"] == marker\n ]\n\n\ndef tokenize(value):\n value = re.sub(r\"([a-z0-9])([A-Z])\", r\"\\1 \\2\", value)\n value = re.sub(r\"[^A-Za-z0-9]+\", \" \", value).lower()\n return {\n token for token in value.split()\n if len(token) >= 3 and token not in STOP_WORDS\n }\n\n\ndef subsystem(path):\n parts = path.split(\"/\")\n return parts[0] if len(parts) > 1 else \"(root)\"\n\n\ndef config_kind(path):\n normalized = path.lower()\n name = os.path.basename(normalized)\n extension = os.path.splitext(name)[1]\n if normalized.startswith(\".github/\"):\n return \"ci\"\n if name in BUILD_FILES or name.startswith(\"dockerfile.\"):\n return \"build\"\n if extension in CONFIG_EXTENSIONS:\n return \"config\"\n return None\n\n\ndef requirement_dependency(line):\n stripped = line.strip()\n if not stripped or stripped.startswith((\"#\", \"-\", \".\")):\n return None\n match = REQUIREMENTS_RE.match(stripped)\n if not match:\n return None\n return match.group(1).lower().replace(\"_\", \"-\"), stripped\n\n\ndef package_json_dependencies(file_change, marker):\n dependencies = []\n for hunk in file_change[\"hunks\"]:\n in_dependencies = False\n for entry in hunk[\"lines\"]:\n line = entry[\"text\"]\n top_key = re.match(r'^\\s{0,2}\"([^\"]+)\"\\s*:', line)\n if top_key:\n in_dependencies = top_key.group(1) in {\n \"dependencies\", \"devDependencies\", \"optionalDependencies\",\n \"peerDependencies\",\n }\n continue\n if entry[\"marker\"] != marker or not in_dependencies:\n continue\n match = JSON_PROPERTY_RE.match(line)\n if match:\n dependencies.append((match.group(1).lower(), line.strip()))\n return dependencies\n\n\ndef pyproject_dependencies(file_change, marker):\n dependencies = []\n section = None\n in_project_list = False\n for hunk in file_change[\"hunks\"]:\n for entry in hunk[\"lines\"]:\n line = entry[\"text\"]\n section_match = re.match(r\"^\\s*\\[([^]]+)\\]\\s*$\", line)\n if section_match:\n section = section_match.group(1).lower()\n in_project_list = False\n continue\n if section == \"project\" and re.match(r\"^\\s*dependencies\\s*=\\s*\\[\", line):\n in_project_list = True\n continue\n if in_project_list and \"]\" in line:\n in_project_list = False\n if entry[\"marker\"] != marker:\n continue\n if in_project_list:\n match = PYPROJECT_STRING_RE.match(line)\n if match:\n dependencies.append((match.group(1).lower().replace(\"_\", \"-\"), line.strip()))\n elif section in {\"tool.poetry.dependencies\", \"tool.poetry.group.dev.dependencies\"}:\n match = PYPROJECT_ASSIGN_RE.match(line)\n if match and match.group(1).lower() != \"python\":\n dependencies.append((match.group(1).lower().replace(\"_\", \"-\"), line.strip()))\n return dependencies\n\n\ndef dependency_lines(file_change, marker):\n path = file_change[\"path\"].lower()\n name = os.path.basename(path)\n if name.startswith(\"requirements\") and name.endswith(\".txt\"):\n found = []\n for line in changed_lines(file_change, marker):\n parsed = requirement_dependency(line)\n if parsed:\n found.append(parsed)\n return found\n if name == \"package.json\":\n return package_json_dependencies(file_change, marker)\n if name == \"pyproject.toml\":\n return pyproject_dependencies(file_change, marker)\n return []\n\n\ndef find_new_dependencies(files):\n found = []\n for file_change in files:\n removed = {name for name, _line in dependency_lines(file_change, \"-\")}\n for name, line in dependency_lines(file_change, \"+\"):\n if name not in removed:\n found.append({\"path\": file_change[\"path\"], \"name\": name, \"line\": line})\n unique = {(item[\"path\"], item[\"name\"]): item for item in found}\n return [unique[key] for key in sorted(unique)]\n\n\ndef declaration(entry):\n match = SYMBOL_RE.match(entry[\"text\"])\n if not match:\n return None\n name = match.group(1) or match.group(2)\n if name.startswith(\"_\"):\n return None\n kind = \"function\" if match.group(1) else \"class\"\n return {\"kind\": kind, \"name\": name, \"entry\": entry}\n\n\ndef find_api_renames(files):\n renames = []\n for file_change in files:\n for hunk in file_change[\"hunks\"]:\n removed = [declaration(e) for e in hunk[\"lines\"] if e[\"marker\"] == \"-\"]\n added = [declaration(e) for e in hunk[\"lines\"] if e[\"marker\"] == \"+\"]\n removed = [item for item in removed if item]\n added = [item for item in added if item]\n used = set()\n for old in removed:\n candidates = [\n (abs(old[\"entry\"][\"index\"] - new[\"entry\"][\"index\"]), index, new)\n for index, new in enumerate(added)\n if index not in used\n and new[\"kind\"] == old[\"kind\"]\n and new[\"name\"] != old[\"name\"]\n ]\n if not candidates:\n continue\n distance, index, new = min(candidates)\n if distance > 12:\n continue\n used.add(index)\n renames.append({\n \"path\": file_change[\"path\"],\n \"kind\": old[\"kind\"],\n \"from\": old[\"name\"],\n \"to\": new[\"name\"],\n \"hunk\": hunk[\"header\"],\n })\n return sorted(renames, key=lambda item: (item[\"path\"], item[\"from\"], item[\"to\"]))\n\n\ndef lines_are_formatting_only(removed, added):\n if not removed or len(removed) != len(added):\n return False\n normalize = lambda line: re.sub(r\"\\s+\", \"\", line)\n return [normalize(line) for line in removed] == [normalize(line) for line in added]\n\n\ndef classify(files, intent, hunk_threshold):\n intent_words = tokenize(intent)\n new_deps = find_new_dependencies(files)\n api_renames = find_api_renames(files)\n dep_paths = {item[\"path\"] for item in new_deps}\n rename_paths = {item[\"path\"] for item in api_renames}\n\n config_edits = []\n oversized = []\n formatting_only = []\n subsystems = collections.Counter()\n additions = deletions = 0\n file_metrics = {}\n\n for file_change in files:\n path = file_change[\"path\"]\n subsystems[subsystem(path)] += 1\n added_lines = changed_lines(file_change, \"+\")\n removed_lines = changed_lines(file_change, \"-\")\n file_additions = len(added_lines)\n file_deletions = len(removed_lines)\n file_metrics[id(file_change)] = (file_additions, file_deletions)\n additions += file_additions\n deletions += file_deletions\n kind = config_kind(path)\n if kind:\n config_edits.append({\n \"path\": path,\n \"kind\": kind,\n \"churn\": file_additions + file_deletions,\n })\n if lines_are_formatting_only(removed_lines, added_lines):\n formatting_only.append(path)\n for hunk in file_change[\"hunks\"]:\n churn = sum(1 for entry in hunk[\"lines\"] if entry[\"marker\"] in {\"+\", \"-\"})\n if churn > hunk_threshold:\n oversized.append({\"path\": path, \"hunk\": hunk[\"header\"], \"churn\": churn})\n\n config_paths = {item[\"path\"] for item in config_edits}\n oversized_paths = {item[\"path\"] for item in oversized}\n formatting_paths = set(formatting_only)\n in_scope = []\n likely_creep = []\n\n for file_change in sorted(files, key=lambda item: item[\"path\"]):\n path = file_change[\"path\"]\n overlap = sorted(intent_words & tokenize(path))\n score = round(len(overlap) / max(1, len(intent_words)), 3)\n signals = []\n if path in dep_paths:\n signals.append(\"new_dependency\")\n if path in rename_paths:\n signals.append(\"public_api_rename\")\n if path in config_paths:\n signals.append(\"config_edit\")\n if path in oversized_paths:\n signals.append(\"oversized_hunk\")\n if path in formatting_paths:\n signals.append(\"formatting_only\")\n\n additions_for_file, deletions_for_file = file_metrics[id(file_change)]\n reasons = []\n if overlap:\n reasons.append(\"intent/path overlap: %s\" % \", \".join(overlap))\n elif intent_words:\n reasons.append(\"no intent/path keyword overlap\")\n else:\n reasons.append(\"intent has no usable keywords\")\n reasons.extend(signal.replace(\"_\", \" \") for signal in signals)\n item = {\n \"path\": path,\n \"subsystem\": subsystem(path),\n \"relatedness\": score,\n \"overlap\": overlap,\n \"signals\": signals,\n \"reasons\": reasons,\n \"additions\": additions_for_file,\n \"deletions\": deletions_for_file,\n }\n (in_scope if overlap else likely_creep).append(item)\n\n return {\n \"intent\": intent,\n \"in_scope\": in_scope,\n \"likely_creep\": likely_creep,\n \"new_deps\": new_deps,\n \"api_renames\": api_renames,\n \"config_edits\": sorted(config_edits, key=lambda item: item[\"path\"]),\n \"stats\": {\n \"files_touched\": len(files),\n \"additions\": additions,\n \"deletions\": deletions,\n \"subsystems\": dict(sorted(subsystems.items())),\n \"oversized_hunks\": oversized,\n \"formatting_only_files\": sorted(formatting_only),\n \"hunk_threshold\": hunk_threshold,\n },\n }\n\n\ndef run_git(repo, args):\n result = subprocess.run(\n [\"git\", \"-C\", repo, *args],\n capture_output=True,\n text=True,\n # Decode git output as UTF-8. With text=True and no encoding, Python uses\n # the locale default (cp1252 on Windows), which raises UnicodeDecodeError\n # on non-Latin-1 bytes in diffs or commit messages (emoji, em-dashes, …).\n encoding=\"utf-8\",\n errors=\"replace\",\n timeout=30,\n )\n if result.returncode != 0:\n raise RuntimeError(result.stderr.strip() or \"git command failed\")\n return result.stdout\n\n\ndef read_diff(args):\n if args.diff == \"-\":\n return sys.stdin.read()\n if args.diff:\n with open(args.diff, encoding=\"utf-8\", errors=\"replace\") as handle:\n return handle.read()\n command = [\"diff\", \"--no-ext-diff\", \"--no-textconv\", \"--unified=3\"]\n if args.staged:\n command.append(\"--cached\")\n elif args.base:\n command.append(\"%s...HEAD\" % args.base)\n command.append(\"--\")\n return run_git(args.repo, command)\n\n\ndef fallback_intent(repo):\n try:\n branch = run_git(repo, [\"branch\", \"--show-current\"]).strip()\n except RuntimeError:\n branch = \"\"\n return branch or \"detached HEAD\"\n\n\ndef print_human(report):\n stats = report[\"stats\"]\n print(\"SCOPE REPORT\")\n print(\"intent: %s\" % report[\"intent\"])\n print(\n \"files: %(files_touched)d additions: %(additions)d deletions: %(deletions)d\"\n % stats\n )\n print(\"subsystems: %s\" % (\", \".join(\n \"%s=%d\" % item for item in stats[\"subsystems\"].items()\n ) or \"none\"))\n print(\"in scope: %d\" % len(report[\"in_scope\"]))\n for item in report[\"in_scope\"]:\n print(\" KEEP? %s (%s)\" % (item[\"path\"], \"; \".join(item[\"reasons\"])))\n print(\"likely creep: %d\" % len(report[\"likely_creep\"]))\n for item in report[\"likely_creep\"]:\n print(\" SPLIT OR JUSTIFY: %s (%s)\" % (item[\"path\"], \"; \".join(item[\"reasons\"])))\n\n\ndef build_parser():\n parser = argparse.ArgumentParser(description=\"Detect scope signals in a git unified diff.\")\n parser.add_argument(\"--repo\", default=\".\", help=\"Target git repository (default: current directory).\")\n parser.add_argument(\"--diff\", help=\"Read a unified diff from a file, or '-' for stdin.\")\n modes = parser.add_mutually_exclusive_group()\n modes.add_argument(\"--staged\", action=\"store_true\", help=\"Analyze the staged diff.\")\n modes.add_argument(\"--base\", help=\"Analyze BASE...HEAD for a branch diff.\")\n parser.add_argument(\"--intent\", help=\"One-line intent; defaults to the current branch name.\")\n parser.add_argument(\n \"--hunk-threshold\",\n type=int,\n default=DEFAULT_HUNK_THRESHOLD,\n help=\"Flag hunks with churn above this value (default: 80).\",\n )\n parser.add_argument(\"--json\", action=\"store_true\", help=\"Emit the full JSON report.\")\n return parser\n\n\ndef main(argv=None):\n parser = build_parser()\n args = parser.parse_args(argv)\n if args.diff and (args.staged or args.base):\n parser.error(\"--diff cannot be combined with --staged or --base\")\n if args.hunk_threshold \u003c 1:\n parser.error(\"--hunk-threshold must be at least 1\")\n\n try:\n diff_text = read_diff(args)\n intent = args.intent.strip() if args.intent and args.intent.strip() else fallback_intent(args.repo)\n report = classify(parse_diff(diff_text), intent, args.hunk_threshold)\n except (OSError, RuntimeError, subprocess.TimeoutExpired) as error:\n print(\"scope_creep.py: error: %s\" % error, file=sys.stderr)\n return 2\n\n if args.json:\n json.dump(report, sys.stdout, indent=2, sort_keys=True)\n sys.stdout.write(\"\\n\")\n else:\n print_human(report)\n return 0\n\n\nif __name__ == \"__main__\":\n sys.exit(main())\n","content_type":"text/x-python; charset=utf-8","language":"python","size":18609,"content_sha256":"d2b9c5f5931617b0065170b1e6a2780b1b0491e5942569acf73a2c6e2c6fb28b"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"Scope Creep Detector","type":"text"}]},{"type":"paragraph","content":[{"text":"A one-line fix should not require a reviewer to reverse-engineer fourteen files across three subsystems. This skill compares a git diff with its stated intent, surfaces scope signals, and turns them into keep, split, or justify decisions.","type":"text"}]},{"type":"paragraph","content":[{"text":"Everything runs locally. The script makes no network calls and does not change the working tree, index, commits, or branches.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"When to use","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Before opening a pull request whose diff may have grown beyond its intent","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"When a bug fix touches unexpected files or subsystems","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"When the user asks whether staged changes are too broad","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"When a diff includes dependency, public API, config, CI, or build changes","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"When the user wants a concrete split plan for a mixed change","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"When not to use","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Formatting code, running a linter, or writing a commit message","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Reviewing correctness, security, or test quality inside an agreed scope","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Measuring historical project growth across many commits","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Editing or reverting files without the user's approval","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Establish the intent","type":"text"}]},{"type":"paragraph","content":[{"text":"Use the user's one-line intent when available. Keep it concrete, such as ","type":"text"},{"text":"fix null dereference in parser","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"add retry limit to webhook delivery","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"If no intent was given, the script falls back to the current branch name. If that name is generic, detached, or unrelated to the work, ask for one line of intent before treating relatedness as meaningful.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Run the classifier","type":"text"}]},{"type":"paragraph","content":[{"text":"Run from this skill directory and point ","type":"text"},{"text":"--repo","type":"text","marks":[{"type":"code_inline"}]},{"text":" at the target repository.","type":"text"}]},{"type":"paragraph","content":[{"text":"Working tree diff:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"python3 scripts/scope_creep.py --repo /path/to/repo \\\n --intent \"fix null dereference in parser\" --json","type":"text"}]},{"type":"paragraph","content":[{"text":"Staged diff:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"python3 scripts/scope_creep.py --repo /path/to/repo --staged \\\n --intent \"fix null dereference in parser\" --json","type":"text"}]},{"type":"paragraph","content":[{"text":"Branch diff against a merge base:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"python3 scripts/scope_creep.py --repo /path/to/repo --base main \\\n --intent \"fix null dereference in parser\" --json","type":"text"}]},{"type":"paragraph","content":[{"text":"Saved diff or stdin:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"python3 scripts/scope_creep.py --diff change.diff --intent \"parser fix\" --json\ngit diff --staged | python3 scripts/scope_creep.py --diff - \\\n --intent \"parser fix\" --json","type":"text"}]},{"type":"paragraph","content":[{"text":"Use ","type":"text"},{"text":"--hunk-threshold","type":"text","marks":[{"type":"code_inline"}]},{"text":" only when the repository has a documented reason to change the default churn threshold. Do not tune the threshold merely to make a warning disappear.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Interpret the JSON","type":"text"}]},{"type":"paragraph","content":[{"text":"Read ","type":"text"},{"text":"references/scope-signals.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/scope-signals.md","title":null}}]},{"text":" before making a recommendation. Treat the classifier as triage evidence, not proof of authorial intent.","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"in_scope","type":"text","marks":[{"type":"code_inline"}]},{"text":": file paths with at least one intent/path keyword overlap","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"likely_creep","type":"text","marks":[{"type":"code_inline"}]},{"text":": paths without overlap, with the reason and detected signals","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"new_deps","type":"text","marks":[{"type":"code_inline"}]},{"text":": dependencies introduced in supported manifest formats","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"api_renames","type":"text","marks":[{"type":"code_inline"}]},{"text":": nearby removed and added public function or class declarations","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"config_edits","type":"text","marks":[{"type":"code_inline"}]},{"text":": CI, container, build, YAML, and TOML changes","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"stats","type":"text","marks":[{"type":"code_inline"}]},{"text":": churn, subsystem counts, oversized hunks, and formatting-only files","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Empty arrays are evidence too. Say that no signal was detected, not that the diff is guaranteed to be in scope.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Recommend keep, split, or justify","type":"text"}]},{"type":"paragraph","content":[{"text":"Give every item in ","type":"text"},{"text":"likely_creep","type":"text","marks":[{"type":"code_inline"}]},{"text":" one disposition:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Keep","type":"text","marks":[{"type":"strong"}]},{"text":" when the path is necessary for the stated intent and the connection is direct. Explain the connection in one sentence.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Split","type":"text","marks":[{"type":"strong"}]},{"text":" when it can land independently, belongs to another subsystem, or introduces a dependency, API rename, config edit, or large hunk that is not required for the intent. Name the files or hunks for the follow-up change.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Justify","type":"text","marks":[{"type":"strong"}]},{"text":" when a cross-cutting edit cannot be separated safely. State the invariant or build constraint that requires it and call out reviewer risk.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Prefer split when evidence is ambiguous. Never claim that a zero overlap score proves a file is unrelated. Path vocabulary is a cheap, deterministic proxy.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Write the scope report","type":"text"}]},{"type":"paragraph","content":[{"text":"Use this compact structure:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Intent and diff source","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Files and subsystems touched, with total additions and deletions","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"In-scope changes","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Likely creep with signal evidence","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Keep, split, or justify table","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Proposed follow-up grouping, if any","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Name file paths and hunk headers. For an oversized mixed hunk, explain that the script cannot split it automatically and describe the smallest coherent edit. Ask before applying any split, revert, staging, or commit operation.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Files","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"scripts/scope_creep.py","type":"text","marks":[{"type":"code_inline"}]},{"text":": deterministic unified-diff parser and classifier","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/scope-signals.md","type":"text","marks":[{"type":"code_inline"}]},{"text":": signal definitions, thresholds, and limits","type":"text"}]}]}]}]},"metadata":{"date":"2026-11-02","name":"scope-creep-detector","author":"@skillopedia","source":{"stars":143747,"repo_name":"awesome-llm-apps","origin_url":"https://github.com/Shubhamsaboo/awesome-llm-apps/blob/HEAD/agent_skills/scope-creep-detector/SKILL.md","repo_owner":"Shubhamsaboo","body_sha256":"a21a28ce59694554c16ddc19cb3b27620b1e3e5040803df0e40b22f6a34da159","cluster_key":"518de7c40c535af525231f9c9694eac77c0762c8e7252ea91a2673d1da1fba2d","clean_bundle":{"format":"clean-skill-bundle-v1","source":"Shubhamsaboo/awesome-llm-apps/agent_skills/scope-creep-detector/SKILL.md","text_bytes":23108,"source_commit":"6c16b23f1bacd0fdf240bb5141ef45e59e82c306","skills_sh_rank":17090,"attachment_count":3,"text_attachments":3,"attachment_storage":"skill_attachments","binary_attachments":0},"cluster_size":1,"skill_md_path":"agent_skills/scope-creep-detector/SKILL.md","import_metadata":{"date":"2026-11-02","author":"@skillopedia","version":"v1","category":"software-engineering","category_label":"Engineering"},"exact_dupes_collapsed_into_this":0},"license":"Apache-2.0","version":"v1","category":"software-engineering","metadata":{"author":"Matt Van Horn","source":"https://github.com/Shubhamsaboo/awesome-llm-apps","version":"1.0.0"},"import_tag":"skills-sh-top20k-2026-11","description":"Analyzes git diffs against a stated intent to detect scope creep, unrelated files, broad pull requests, changes that grew beyond a fix, dependency additions, public API renames, config or CI edits, oversized hunks, and formatting-only files. Use when the user asks whether a change grew beyond the fix, a PR is too broad, or what unrelated stuff they touched, and wants keep, split, or justify guidance. Operates locally and offline."}},"renderedAt":1793671603343}

Scope Creep Detector A one-line fix should not require a reviewer to reverse-engineer fourteen files across three subsystems. This skill compares a git diff with its stated intent, surfaces scope signals, and turns them into keep, split, or justify decisions. Everything runs locally. The script makes no network calls and does not change the working tree, index, commits, or branches. When to use - Before opening a pull request whose diff may have grown beyond its intent - When a bug fix touches unexpected files or subsystems - When the user asks whether staged changes are too broad - When a di…