@tyroneross/build-loop 0.36.0 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (270) hide show
  1. package/.agents/plugins/marketplace.json +2 -2
  2. package/.claude-plugin/marketplace.json +3 -3
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +1 -1
  5. package/.cursor/rules/build-loop-surface.mdc +12 -11
  6. package/.cursor/rules/skill-index.mdc +33 -0
  7. package/AGENTS.md +213 -34
  8. package/README.md +99 -31
  9. package/agents/advisor.md +4 -4
  10. package/agents/alignment-checker.md +2 -2
  11. package/agents/architecture-scout.md +4 -4
  12. package/agents/build-orchestrator.md +38 -36
  13. package/agents/database-assessor.md +11 -5
  14. package/agents/design-contract-specialist.md +8 -8
  15. package/agents/fact-checker.md +13 -3
  16. package/agents/fix-critique.md +2 -2
  17. package/agents/independent-auditor.md +60 -7
  18. package/agents/leak-scanner.md +82 -0
  19. package/agents/overfitting-reviewer.md +2 -2
  20. package/agents/plan-critic.md +1 -1
  21. package/agents/promotion-reviewer.md +5 -5
  22. package/agents/retrospective-synthesizer.md +138 -35
  23. package/agents/scope-auditor.md +82 -11
  24. package/agents/security-reviewer.md +56 -2
  25. package/agents/self-improvement-architect.md +17 -3
  26. package/agents/transcript-pattern-miner.md +5 -5
  27. package/agents/ui-validator.md +1 -1
  28. package/bin/build-loop-debugger.js +143 -0
  29. package/bin/build-loop-install.js +1 -4
  30. package/bin/build-loop-load-probe.js +345 -0
  31. package/codex-skills/build-loop/SKILL.md +28 -6
  32. package/commands/feedback.md +37 -0
  33. package/dist/src/interactive-verifier.d.ts +1 -14
  34. package/dist/src/interactive-verifier.d.ts.map +1 -1
  35. package/dist/src/interactive-verifier.js +6 -113
  36. package/dist/src/interactive-verifier.js.map +1 -1
  37. package/dist/src/quality.d.ts +5 -0
  38. package/dist/src/quality.d.ts.map +1 -0
  39. package/dist/src/quality.js +81 -0
  40. package/dist/src/quality.js.map +1 -0
  41. package/dist/src/storage.d.ts.map +1 -1
  42. package/dist/src/storage.js +37 -3
  43. package/dist/src/storage.js.map +1 -1
  44. package/docs/agent-surface-policy.md +35 -31
  45. package/docs/memory-setup.md +19 -0
  46. package/hooks/git/pre-push +65 -4
  47. package/hooks/hooks.json +95 -38
  48. package/hooks/pre-commit +20 -1
  49. package/hooks/pre-edit-rally-point.sh +10 -3
  50. package/hooks/session-start-codex-hook-trust.sh +30 -0
  51. package/hooks/session-start-git-hooks.sh +3 -1
  52. package/hooks/session-start-rally-point.sh +52 -4
  53. package/hooks/session-start-worktree-gc.sh +47 -94
  54. package/hooks/stop-transcript-sweep.sh +173 -0
  55. package/hooks/test_closeout.sh +14 -2
  56. package/package.json +8 -7
  57. package/scripts/README.md +1 -1
  58. package/scripts/_paths.py +65 -0
  59. package/scripts/groundwork_exchange.py +1012 -0
  60. package/scripts/install_memory.py +33 -1
  61. package/scripts/lessons_index/ingest.py +13 -2
  62. package/scripts/lessons_index/query.py +36 -13
  63. package/scripts/memory_context/__init__.py +108 -14
  64. package/scripts/memory_graph/__init__.py +5 -1
  65. package/scripts/project_resolver.py +42 -36
  66. package/scripts/sync_plugin_cache.py +37 -2
  67. package/skills/agent-rally-point/SKILL.md +46 -0
  68. package/skills/api-registry-bridge/SKILL.md +1 -1
  69. package/skills/architecture/dead/SKILL.md +1 -1
  70. package/skills/architecture/impact/SKILL.md +1 -1
  71. package/skills/architecture/review/SKILL.md +1 -1
  72. package/skills/architecture/rules/SKILL.md +3 -3
  73. package/skills/architecture/scan/SKILL.md +1 -1
  74. package/skills/architecture/trace/SKILL.md +1 -1
  75. package/skills/attribution-standard/SKILL.md +6 -6
  76. package/skills/auto-decision-capture/SKILL.md +31 -2
  77. package/skills/auto-finding-capture/SKILL.md +28 -1
  78. package/skills/build-loop/SKILL.md +131 -23
  79. package/skills/build-loop/fallbacks.md +16 -21
  80. package/skills/build-loop/phases/ui-validation.md +2 -2
  81. package/skills/build-loop/references/advisor-dispatch-ladder.md +1 -1
  82. package/skills/build-loop/references/apple-native-planning.md +1 -1
  83. package/skills/build-loop/references/autonomous-and-per-commit-modes.md +11 -5
  84. package/skills/build-loop/references/autonomy-dashboard.md +115 -0
  85. package/skills/build-loop/references/capability-routing.md +24 -2
  86. package/skills/build-loop/references/coordination.md +24 -6
  87. package/skills/build-loop/references/experiment-results-template.md +15 -3
  88. package/skills/build-loop/references/leadership.md +1 -1
  89. package/skills/build-loop/references/memory.md +14 -3
  90. package/skills/build-loop/references/modular-systems-pack.md +8 -0
  91. package/skills/build-loop/references/output-style.md +86 -0
  92. package/skills/build-loop/references/phase-1-assess.md +102 -2
  93. package/skills/build-loop/references/phase-2-plan.md +9 -1
  94. package/skills/build-loop/references/phase-3-execute.md +5 -2
  95. package/skills/build-loop/references/phase-4-review.md +85 -8
  96. package/skills/build-loop/references/phase-5-iterate.md +76 -8
  97. package/skills/build-loop/references/phase-6-learn.md +10 -17
  98. package/skills/build-loop/references/privileged-request-broker.md +254 -0
  99. package/skills/build-loop/references/resource-aware-execution.md +183 -0
  100. package/skills/build-loop/references/self-recursive-dev.md +2 -2
  101. package/skills/build-loop/references/status-output-format.md +207 -0
  102. package/skills/build-loop/references/verify-dispatch.md +56 -2
  103. package/skills/building-with-deepagents/SKILL.md +1 -1
  104. package/skills/claim-scope/SKILL.md +185 -0
  105. package/skills/color-engine/SKILL.md +103 -0
  106. package/skills/color-engine/_core.py +464 -0
  107. package/skills/color-engine/color_engine.py +175 -0
  108. package/skills/cost-rca/SKILL.md +61 -0
  109. package/skills/data-plane-worktrees/SKILL.md +139 -0
  110. package/skills/data-plane-worktrees/agents/openai.yaml +4 -0
  111. package/skills/database-practice/SKILL.md +200 -0
  112. package/skills/database-practice/references/diagnostic-queries.sql +126 -0
  113. package/skills/database-practice/references/vector-and-graph-tuning.md +208 -0
  114. package/skills/database-practice/scripts/db_table_map.py +1244 -0
  115. package/skills/database-practice/scripts/test_db_table_map.py +514 -0
  116. package/skills/debug-loop/SKILL.md +36 -6
  117. package/skills/debugging-memory/SKILL.md +32 -430
  118. package/skills/debugging-memory/references/pattern-extraction.md +4 -4
  119. package/skills/debugging-memory/references/search.md +32 -120
  120. package/skills/debugging-memory/references/store.md +32 -126
  121. package/skills/debugging-memory/references/subagent-integration.md +1 -1
  122. package/skills/decision-queue/SKILL.md +251 -0
  123. package/skills/decision-queue/assets/template.html +1242 -0
  124. package/skills/decision-queue/references/example-large-queue-batching.md +164 -0
  125. package/skills/decision-queue/scripts/regen_template_constants.py +160 -0
  126. package/skills/defenseclaw-bridge/SKILL.md +2 -2
  127. package/skills/defenseclaw-bridge/references/dc-config-mapping.md +2 -9
  128. package/skills/drain-proposals/SKILL.md +53 -0
  129. package/skills/focused-loop-builder/SKILL.md +31 -0
  130. package/skills/focused-loop-builder/references/spec-format.md +27 -0
  131. package/skills/handoff/SKILL.md +169 -8
  132. package/skills/ibr-bridge/SKILL.md +4 -1
  133. package/skills/knowledge/SKILL.md +26 -14
  134. package/skills/knowledge/references/review-mode.md +2 -3
  135. package/skills/knowledge/templates/madr-minimal.md +1 -1
  136. package/skills/mcp-builder/SKILL.md +1 -1
  137. package/skills/model-bakeoff/SKILL.md +48 -10
  138. package/skills/model-tiering/SKILL.md +92 -31
  139. package/skills/native-ax-driver/SKILL.md +38 -5
  140. package/skills/native-ax-driver/scripts/native_driver.py +278 -22
  141. package/skills/native-ax-driver/scripts/test_native_driver.py +227 -0
  142. package/skills/optimize/SKILL.md +1 -1
  143. package/skills/plugin-builder/SKILL.md +48 -1
  144. package/skills/plugin-builder/references/build-loop-phase-guidance.md +3 -4
  145. package/skills/plugin-builder/references/distribution.md +13 -2
  146. package/skills/plugin-builder/references/plugin-hygiene-lessons.md +2 -2
  147. package/skills/plugin-tests/SKILL.md +2 -2
  148. package/skills/recursive-retrospective/SKILL.md +1 -1
  149. package/skills/repo-closeout/SKILL.md +17 -0
  150. package/skills/repo-closeout/agents/openai.yaml +4 -0
  151. package/skills/repo-maintenance/SKILL.md +179 -0
  152. package/skills/repo-maintenance/agents/openai.yaml +4 -0
  153. package/skills/repo-maintenance/references/pre-public-hygiene.md +134 -0
  154. package/skills/repo-maintenance/references/repository-taxonomy.md +161 -0
  155. package/skills/repo-maintenance/references/safety-protocol.md +106 -0
  156. package/skills/repo-maintenance/references/stack-profiles.md +138 -0
  157. package/skills/repo-maintenance/scripts/audit_repo_maintenance.py +1198 -0
  158. package/skills/repo-maintenance/scripts/test_audit_repo_maintenance.py +506 -0
  159. package/skills/repository-intelligence/SKILL.md +189 -0
  160. package/skills/repository-intelligence/agents/openai.yaml +4 -0
  161. package/skills/repository-intelligence/references/assessment-rubric.md +88 -0
  162. package/skills/repository-intelligence/scripts/repository_inventory.py +347 -0
  163. package/skills/research/SKILL.md +12 -2
  164. package/skills/root-cause-analysis/SKILL.md +1 -1
  165. package/skills/runtime-parity-verification/SKILL.md +36 -1
  166. package/skills/security-methodology/SKILL.md +23 -10
  167. package/skills/security-methodology/references/agentic-handoff-templates.md +220 -0
  168. package/skills/security-methodology/references/cross-source-matrix.md +1 -1
  169. package/skills/security-methodology/references/owasp-agentic-top-10.md +1 -1
  170. package/skills/security-scan/SKILL.md +55 -15
  171. package/skills/self-improve/SKILL.md +70 -50
  172. package/skills/silent-assumptions/SKILL.md +341 -0
  173. package/skills/silent-assumptions/references/elicitation-detectors.md +342 -0
  174. package/skills/spec-writing/SKILL.md +128 -24
  175. package/skills/spec-writing/scripts/check_checklist.py +114 -15
  176. package/skills/ui-design/SKILL.md +6 -4
  177. package/skills/ui-design/references/color-engine.md +132 -0
  178. package/skills/ui-design/references/design-preferences-from-owned-apps.md +8 -8
  179. package/skills/ui-design/references/ui-guidance-sources.md +1 -1
  180. package/skills/ui-design/references/universal-design-principles.alt.md +2 -2
  181. package/plugin-artifacts/codex/.codex-plugin/plugin.json +0 -41
  182. package/plugin-artifacts/codex/AGENTS.md +0 -560
  183. package/plugin-artifacts/codex/BUILD-ARTIFACT.md +0 -5
  184. package/plugin-artifacts/codex/LICENSE +0 -202
  185. package/plugin-artifacts/codex/README.md +0 -313
  186. package/plugin-artifacts/codex/assets/build-loop-plugin-icon.png +0 -0
  187. package/plugin-artifacts/codex/docs/agent-surface-policy.md +0 -63
  188. package/plugin-artifacts/codex/references/advisor-dispatch-ladder.md +0 -62
  189. package/plugin-artifacts/codex/references/agent-role-taxonomy.md +0 -135
  190. package/plugin-artifacts/codex/references/autonomous-and-per-commit-modes.md +0 -161
  191. package/plugin-artifacts/codex/references/autonomy-config.md +0 -231
  192. package/plugin-artifacts/codex/references/backlog-system.md +0 -285
  193. package/plugin-artifacts/codex/references/capability-routing.md +0 -231
  194. package/plugin-artifacts/codex/references/codex-subagents.md +0 -106
  195. package/plugin-artifacts/codex/references/coordination-file-template.md +0 -181
  196. package/plugin-artifacts/codex/references/coordination-rules.md +0 -552
  197. package/plugin-artifacts/codex/references/dogfood-reload-checkpoint.md +0 -112
  198. package/plugin-artifacts/codex/references/halt-and-ask-protocol.md +0 -102
  199. package/plugin-artifacts/codex/references/implementer-envelope-schema.md +0 -302
  200. package/plugin-artifacts/codex/references/intent-capability-pack.md +0 -257
  201. package/plugin-artifacts/codex/references/intent-exploration-prompts.md +0 -96
  202. package/plugin-artifacts/codex/references/leadership.md +0 -72
  203. package/plugin-artifacts/codex/references/memory-systems.md +0 -261
  204. package/plugin-artifacts/codex/references/memory.md +0 -313
  205. package/plugin-artifacts/codex/references/model-tier-mapping.md +0 -296
  206. package/plugin-artifacts/codex/references/modular-systems-pack.md +0 -96
  207. package/plugin-artifacts/codex/references/phase-1-assess.md +0 -249
  208. package/plugin-artifacts/codex/references/phase-2-plan.md +0 -86
  209. package/plugin-artifacts/codex/references/phase-3-execute.md +0 -49
  210. package/plugin-artifacts/codex/references/phase-4-review.md +0 -341
  211. package/plugin-artifacts/codex/references/phase-5-iterate.md +0 -72
  212. package/plugin-artifacts/codex/references/phase-6-learn.md +0 -58
  213. package/plugin-artifacts/codex/references/recent-design-structures.md +0 -274
  214. package/plugin-artifacts/codex/references/research-trigger-policy.md +0 -140
  215. package/plugin-artifacts/codex/references/runtime-smoke-triggers.md +0 -42
  216. package/plugin-artifacts/codex/references/self-review.md +0 -234
  217. package/plugin-artifacts/codex/references/single-writer-commit-protocol.md +0 -90
  218. package/plugin-artifacts/codex/references/task-capture-policy.md +0 -68
  219. package/plugin-artifacts/codex/references/ui-io-contract.md +0 -116
  220. package/plugin-artifacts/codex/references/ui-spotcheck-protocol.md +0 -65
  221. package/plugin-artifacts/codex/references/verify-dispatch.md +0 -85
  222. package/plugin-artifacts/codex/skills/build-loop/SKILL.md +0 -381
  223. package/plugin-artifacts/codex/skills/build-loop/detect-plugins.mjs +0 -82
  224. package/plugin-artifacts/codex/skills/build-loop/eval-guide.md +0 -65
  225. package/plugin-artifacts/codex/skills/build-loop/fallbacks.md +0 -549
  226. package/plugin-artifacts/codex/skills/build-loop/phases/fact-check.md +0 -42
  227. package/plugin-artifacts/codex/skills/build-loop/phases/ui-validation.md +0 -267
  228. package/plugin-artifacts/codex/skills/build-loop/references/advisor-dispatch-ladder.md +0 -62
  229. package/plugin-artifacts/codex/skills/build-loop/references/apple-native-planning.md +0 -439
  230. package/plugin-artifacts/codex/skills/build-loop/references/autonomous-and-per-commit-modes.md +0 -161
  231. package/plugin-artifacts/codex/skills/build-loop/references/capability-routing.md +0 -231
  232. package/plugin-artifacts/codex/skills/build-loop/references/codex-subagents.md +0 -106
  233. package/plugin-artifacts/codex/skills/build-loop/references/coordination.md +0 -161
  234. package/plugin-artifacts/codex/skills/build-loop/references/correction-aware-capture.md +0 -177
  235. package/plugin-artifacts/codex/skills/build-loop/references/experiment-results-template.md +0 -101
  236. package/plugin-artifacts/codex/skills/build-loop/references/independent-auditor.md +0 -72
  237. package/plugin-artifacts/codex/skills/build-loop/references/intent-capability-pack.md +0 -257
  238. package/plugin-artifacts/codex/skills/build-loop/references/intent-exploration-prompts.md +0 -96
  239. package/plugin-artifacts/codex/skills/build-loop/references/leadership.md +0 -72
  240. package/plugin-artifacts/codex/skills/build-loop/references/memory.md +0 -313
  241. package/plugin-artifacts/codex/skills/build-loop/references/modular-systems-pack.md +0 -96
  242. package/plugin-artifacts/codex/skills/build-loop/references/output-style.md +0 -222
  243. package/plugin-artifacts/codex/skills/build-loop/references/pay-it-forward-arch.md +0 -98
  244. package/plugin-artifacts/codex/skills/build-loop/references/phase-1-assess.md +0 -249
  245. package/plugin-artifacts/codex/skills/build-loop/references/phase-2-plan.md +0 -86
  246. package/plugin-artifacts/codex/skills/build-loop/references/phase-3-execute.md +0 -49
  247. package/plugin-artifacts/codex/skills/build-loop/references/phase-4-review.md +0 -341
  248. package/plugin-artifacts/codex/skills/build-loop/references/phase-5-iterate.md +0 -72
  249. package/plugin-artifacts/codex/skills/build-loop/references/phase-6-learn.md +0 -58
  250. package/plugin-artifacts/codex/skills/build-loop/references/recent-design-structures.md +0 -274
  251. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/ASSESSMENT.md +0 -85
  252. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/STANDALONE_TEST_RUN.md +0 -149
  253. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/01-simple-bugfix.md +0 -32
  254. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/02-ui-build-with-iteration.md +0 -48
  255. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/03-multi-failure-escalation.md +0 -60
  256. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/04-ui-build-ibr-absent.md +0 -51
  257. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/05-refactor-navgator-absent.md +0 -71
  258. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/06-recurring-bug-debugger-absent.md +0 -52
  259. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/trace-comparison.md +0 -202
  260. package/plugin-artifacts/codex/skills/build-loop/references/self-recursive-dev.md +0 -77
  261. package/plugin-artifacts/codex/skills/build-loop/references/self-review.md +0 -234
  262. package/plugin-artifacts/codex/skills/build-loop/references/ui-io-contract.md +0 -116
  263. package/plugin-artifacts/codex/skills/build-loop/references/verify-dispatch.md +0 -85
  264. package/plugin-artifacts/codex/skills/build-loop/scanners/audit-design-rules.mjs +0 -476
  265. package/plugin-artifacts/codex/skills/build-loop/scanners/require-visual-evidence.mjs +0 -239
  266. package/plugin-artifacts/codex/skills/build-loop/templates/backlog-item.md +0 -35
  267. package/plugin-artifacts/codex/skills/build-loop/templates/codex-worker-prompt.md +0 -100
  268. package/plugin-artifacts/codex/skills/build-loop/templates/ui-subagent-prompt.md +0 -179
  269. package/plugin-artifacts/codex/skills/build-loop/templates/ux-fix-plan.md +0 -40
  270. package/scripts/build_codex_plugin_artifact.py +0 -321
@@ -0,0 +1,1198 @@
1
+ #!/usr/bin/env python3
2
+ """Read-only inventory for repository maintenance, structure, and closeout."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import fnmatch
8
+ import json
9
+ import os
10
+ import shlex
11
+ import subprocess
12
+ import sys
13
+ import time
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+
18
+ DEFAULT_ARTIFACT_PATTERNS = (
19
+ "build",
20
+ "build-*",
21
+ "build_*",
22
+ "build-rust",
23
+ "target",
24
+ ".build",
25
+ "DerivedData",
26
+ "dist",
27
+ "coverage",
28
+ ".pytest_cache",
29
+ ".mypy_cache",
30
+ ".ruff_cache",
31
+ )
32
+
33
+ DEFAULT_PROTECTED_ARTIFACTS = frozenset({"build", "build-rust"})
34
+ DISCOVERY_PRUNE_NAMES = frozenset(
35
+ {".git", ".nox", ".tox", ".venv", "node_modules", "site-packages", "venv"}
36
+ )
37
+ RELEASE_ARTIFACT_SUFFIXES = (
38
+ ".aab",
39
+ ".apk",
40
+ ".appx",
41
+ ".deb",
42
+ ".dmg",
43
+ ".exe",
44
+ ".ipa",
45
+ ".msi",
46
+ ".msix",
47
+ ".pkg",
48
+ ".rpm",
49
+ ".tar.gz",
50
+ ".xcarchive",
51
+ ".zip",
52
+ )
53
+ RELEASE_CONTAINER_NAMES = frozenset({"artifacts", "dist", "release", "releases"})
54
+ RELEASE_BUNDLE_SUFFIXES = (".app",)
55
+ RELEASE_EVIDENCE_LIMIT = 20
56
+
57
+ LANGUAGE_EXTENSIONS = {
58
+ ".c": "C/C++",
59
+ ".cc": "C/C++",
60
+ ".cpp": "C/C++",
61
+ ".cs": "C#",
62
+ ".dart": "Dart",
63
+ ".go": "Go",
64
+ ".h": "C/C++",
65
+ ".hpp": "C/C++",
66
+ ".java": "Java",
67
+ ".js": "JavaScript",
68
+ ".jsx": "JavaScript",
69
+ ".kt": "Kotlin",
70
+ ".kts": "Kotlin",
71
+ ".m": "Objective-C",
72
+ ".mm": "Objective-C",
73
+ ".php": "PHP",
74
+ ".py": "Python",
75
+ ".rb": "Ruby",
76
+ ".rs": "Rust",
77
+ ".sh": "Shell",
78
+ ".swift": "Swift",
79
+ ".tf": "Terraform",
80
+ ".ts": "TypeScript",
81
+ ".tsx": "TypeScript",
82
+ }
83
+
84
+ PROFILE_EVIDENCE_LIMIT = 20
85
+ MANIFEST_CONTENT_SCAN_LIMIT = 200
86
+
87
+
88
+ def run_git(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
89
+ result = subprocess.run(
90
+ ["git", "-C", str(repo), *args],
91
+ text=True,
92
+ stdout=subprocess.PIPE,
93
+ stderr=subprocess.PIPE,
94
+ check=False,
95
+ )
96
+ if check and result.returncode != 0:
97
+ raise RuntimeError(f"git {' '.join(args)} failed: {result.stderr.strip()}")
98
+ return result
99
+
100
+
101
+ def resolve_root(repo: Path) -> Path:
102
+ result = run_git(repo, "rev-parse", "--show-toplevel")
103
+ return Path(result.stdout.strip()).resolve()
104
+
105
+
106
+ def ref_exists(repo: Path, ref: str) -> bool:
107
+ commit_ref = f"{ref}^{{commit}}"
108
+ return run_git(repo, "rev-parse", "--verify", "--quiet", commit_ref, check=False).returncode == 0
109
+
110
+
111
+ def is_ancestor(repo: Path, ancestor: str, descendant: str) -> bool:
112
+ return run_git(repo, "merge-base", "--is-ancestor", ancestor, descendant, check=False).returncode == 0
113
+
114
+
115
+ def ahead_behind(repo: Path, left: str, right: str) -> tuple[int, int]:
116
+ result = run_git(repo, "rev-list", "--left-right", "--count", f"{left}...{right}")
117
+ left_only, right_only = result.stdout.split()
118
+ return int(left_only), int(right_only)
119
+
120
+
121
+ def parse_worktrees(repo: Path) -> list[dict[str, Any]]:
122
+ output = run_git(repo, "worktree", "list", "--porcelain").stdout
123
+ records: list[dict[str, Any]] = []
124
+ current: dict[str, Any] = {}
125
+ for line in output.splitlines() + [""]:
126
+ if not line:
127
+ if current:
128
+ branch = current.get("branch")
129
+ if isinstance(branch, str) and branch.startswith("refs/heads/"):
130
+ current["branch"] = branch.removeprefix("refs/heads/")
131
+ path = Path(str(current["worktree"]))
132
+ current["exists"] = path.exists()
133
+ git_root = (
134
+ run_git(path, "rev-parse", "--show-toplevel", check=False)
135
+ if path.exists()
136
+ else None
137
+ )
138
+ current["git_available"] = bool(
139
+ git_root
140
+ and git_root.returncode == 0
141
+ and git_root.stdout.strip()
142
+ and Path(git_root.stdout.strip()).resolve() == path.resolve()
143
+ )
144
+ current["dirty_paths"] = (
145
+ parse_status(repo=path) if current["git_available"] else []
146
+ )
147
+ current["dirty"] = bool(current["dirty_paths"])
148
+ records.append(current)
149
+ current = {}
150
+ continue
151
+ key, _, value = line.partition(" ")
152
+ current[key] = value if value else True
153
+ return records
154
+
155
+
156
+ def parse_status(repo: Path) -> list[str]:
157
+ output = run_git(repo, "status", "--porcelain=v1", "-z", "--untracked-files=all").stdout
158
+ fields = output.split("\0")
159
+ paths: list[str] = []
160
+ index = 0
161
+ while index < len(fields):
162
+ field = fields[index]
163
+ index += 1
164
+ if not field:
165
+ continue
166
+ status = field[:2]
167
+ paths.append(field[3:])
168
+ if "R" in status or "C" in status:
169
+ index += 1
170
+ return paths
171
+
172
+
173
+ def branch_inventory(repo: Path, base: str, worktrees: list[dict[str, Any]]) -> list[dict[str, Any]]:
174
+ checked_out = {str(w.get("branch")): str(w["worktree"]) for w in worktrees if w.get("branch")}
175
+ fmt = "%(refname:short)%00%(objectname)%00%(upstream:short)"
176
+ output = run_git(repo, "for-each-ref", f"--format={fmt}", "refs/heads").stdout
177
+ branches: list[dict[str, Any]] = []
178
+ for line in output.splitlines():
179
+ name, head, upstream = line.split("\0")
180
+ item: dict[str, Any] = {
181
+ "name": name,
182
+ "head": head,
183
+ "upstream": upstream or None,
184
+ "worktree": checked_out.get(name),
185
+ }
186
+ if ref_exists(repo, base):
187
+ behind, ahead = ahead_behind(repo, base, name)
188
+ item.update(
189
+ {
190
+ "behind_base": behind,
191
+ "ahead_base": ahead,
192
+ "merged_into_base": is_ancestor(repo, name, base),
193
+ }
194
+ )
195
+ if name != base and ahead:
196
+ cherry = run_git(repo, "cherry", base, name, check=False)
197
+ item["cherry"] = cherry.stdout.splitlines() if cherry.returncode == 0 else []
198
+ if upstream and ref_exists(repo, upstream):
199
+ upstream_behind, upstream_ahead = ahead_behind(repo, upstream, name)
200
+ item["behind_upstream"] = upstream_behind
201
+ item["ahead_upstream"] = upstream_ahead
202
+ branches.append(item)
203
+ return branches
204
+
205
+
206
+ def stash_inventory(repo: Path) -> list[dict[str, Any]]:
207
+ output = run_git(repo, "stash", "list", "--format=%gd%x00%H%x00%gs").stdout
208
+ stashes: list[dict[str, Any]] = []
209
+ for line in output.splitlines():
210
+ ref, commit, subject = line.split("\0", 2)
211
+ parents = run_git(repo, "show", "-s", "--format=%P", commit).stdout.split()
212
+ names = run_git(repo, "stash", "show", "--include-untracked", "--name-only", ref).stdout.splitlines()
213
+ stashes.append(
214
+ {
215
+ "ref": ref,
216
+ "commit": commit,
217
+ "subject": subject,
218
+ "parents": parents,
219
+ "has_untracked_parent": len(parents) >= 3,
220
+ "paths": names,
221
+ }
222
+ )
223
+ return stashes
224
+
225
+
226
+ def operation_state(repo: Path) -> list[str]:
227
+ git_dir = Path(run_git(repo, "rev-parse", "--git-dir").stdout.strip())
228
+ if not git_dir.is_absolute():
229
+ git_dir = repo / git_dir
230
+ markers = {
231
+ "merge": "MERGE_HEAD",
232
+ "cherry-pick": "CHERRY_PICK_HEAD",
233
+ "revert": "REVERT_HEAD",
234
+ "rebase-merge": "rebase-merge",
235
+ "rebase-apply": "rebase-apply",
236
+ "bisect": "BISECT_LOG",
237
+ }
238
+ return [name for name, marker in markers.items() if (git_dir / marker).exists()]
239
+
240
+
241
+ def tracked_structure(repo: Path) -> dict[str, Any]:
242
+ paths = run_git(repo, "ls-files", "-z").stdout.split("\0")
243
+ paths = [path for path in paths if path]
244
+ top_level: dict[str, int] = {}
245
+ for path in paths:
246
+ root = path.split("/", 1)[0]
247
+ top_level[root] = top_level.get(root, 0) + 1
248
+ nested_git_roots = [
249
+ child.name
250
+ for child in repo.iterdir()
251
+ if child.is_dir() and not child.is_symlink() and (child / ".git").exists()
252
+ ]
253
+ return {
254
+ "tracked_file_count": len(paths),
255
+ "tracked_files_by_top_level": dict(sorted(top_level.items())),
256
+ "nested_git_roots": sorted(nested_git_roots),
257
+ }
258
+
259
+
260
+ def tracked_paths_at_ref(repo: Path, ref: str) -> list[str]:
261
+ output = run_git(repo, "ls-tree", "-r", "--name-only", "-z", ref).stdout
262
+ return sorted(path for path in output.split("\0") if path)
263
+
264
+
265
+ def read_text_at_ref(repo: Path, ref: str, path: str) -> str | None:
266
+ result = run_git(repo, "show", f"{ref}:{path}", check=False)
267
+ return result.stdout if result.returncode == 0 else None
268
+
269
+
270
+ def limited_evidence(paths: list[str] | set[str] | tuple[str, ...]) -> list[str]:
271
+ return sorted(set(paths))[:PROFILE_EVIDENCE_LIMIT]
272
+
273
+
274
+ def package_metadata(
275
+ repo: Path, ref: str, paths: list[str]
276
+ ) -> tuple[set[str], list[str], list[str], dict[str, Any]]:
277
+ dependencies: set[str] = set()
278
+ workspace_manifests: list[str] = []
279
+ script_manifests: list[str] = []
280
+ manifests = sorted(
281
+ (item for item in paths if Path(item).name == "package.json"),
282
+ key=lambda item: (item.count("/"), item),
283
+ )
284
+ scanned = manifests[:MANIFEST_CONTENT_SCAN_LIMIT]
285
+ for path in scanned:
286
+ raw = read_text_at_ref(repo, ref, path)
287
+ if raw is None:
288
+ continue
289
+ try:
290
+ manifest = json.loads(raw)
291
+ except json.JSONDecodeError:
292
+ continue
293
+ if not isinstance(manifest, dict):
294
+ continue
295
+ if manifest.get("workspaces"):
296
+ workspace_manifests.append(path)
297
+ if manifest.get("scripts"):
298
+ script_manifests.append(path)
299
+ for field in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
300
+ values = manifest.get(field, {})
301
+ if isinstance(values, dict):
302
+ dependencies.update(str(name) for name in values)
303
+ return (
304
+ dependencies,
305
+ workspace_manifests,
306
+ script_manifests,
307
+ {
308
+ "package_manifests_found": len(manifests),
309
+ "package_manifests_scanned": len(scanned),
310
+ "package_manifest_scan_truncated": len(manifests) > len(scanned),
311
+ },
312
+ )
313
+
314
+
315
+ def detect_profile_signals(repo: Path, ref: str) -> dict[str, Any]:
316
+ """Return evidence signals without claiming a final architecture classification."""
317
+
318
+ if not ref_exists(repo, ref):
319
+ return {
320
+ "source_ref": None,
321
+ "inference_policy": (
322
+ "Signals are evidence, not a final application, topology, deployment, or ownership decision."
323
+ ),
324
+ "languages": [],
325
+ "build_systems": [],
326
+ "application_signals": [],
327
+ "composition_signals": [],
328
+ "layout_roots": [],
329
+ "scan_summary": {
330
+ "tracked_paths": 0,
331
+ "package_manifests_found": 0,
332
+ "package_manifests_scanned": 0,
333
+ "package_manifest_scan_truncated": False,
334
+ "cargo_manifests_found": 0,
335
+ "cargo_manifests_scanned": 0,
336
+ "cargo_manifest_scan_truncated": False,
337
+ },
338
+ }
339
+
340
+ paths = tracked_paths_at_ref(repo, ref)
341
+ language_paths: dict[str, list[str]] = {}
342
+ for path in paths:
343
+ language = LANGUAGE_EXTENSIONS.get(Path(path).suffix.lower())
344
+ if language:
345
+ language_paths.setdefault(language, []).append(path)
346
+ languages = [
347
+ {
348
+ "name": name,
349
+ "file_count": len(language_paths[name]),
350
+ "sample_paths": limited_evidence(language_paths[name])[:5],
351
+ }
352
+ for name in sorted(language_paths, key=lambda item: (-len(language_paths[item]), item))
353
+ ]
354
+
355
+ by_name: dict[str, list[str]] = {}
356
+ for path in paths:
357
+ by_name.setdefault(Path(path).name, []).append(path)
358
+
359
+ build_systems: list[dict[str, Any]] = []
360
+
361
+ def add_build(name: str, role: str, evidence: list[str]) -> None:
362
+ if evidence:
363
+ build_systems.append(
364
+ {"name": name, "role": role, "evidence": limited_evidence(evidence)}
365
+ )
366
+
367
+ cargo_manifests = by_name.get("Cargo.toml", [])
368
+ swift_manifests = by_name.get("Package.swift", [])
369
+ xcode_projects = [path for path in paths if path.endswith(".xcodeproj/project.pbxproj")]
370
+ xcodegen_manifests = by_name.get("project.yml", [])
371
+ package_manifests = by_name.get("package.json", [])
372
+ gradle_manifests = [
373
+ path
374
+ for path in paths
375
+ if Path(path).name
376
+ in {"build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"}
377
+ ]
378
+ dockerfiles = [path for path in paths if Path(path).name.startswith("Dockerfile")]
379
+ terraform_files = [path for path in paths if path.endswith(".tf")]
380
+ (
381
+ dependencies,
382
+ node_workspace_manifests,
383
+ node_script_manifests,
384
+ scan_summary,
385
+ ) = package_metadata(repo, ref, paths)
386
+
387
+ add_build("cargo", "language-package-build", cargo_manifests)
388
+ add_build("swiftpm", "language-package-build", swift_manifests)
389
+ add_build("xcodegen", "project-generator", xcodegen_manifests)
390
+ add_build("xcodebuild", "platform-build", xcode_projects or xcodegen_manifests)
391
+ add_build("gradle", "language-package-build", gradle_manifests)
392
+ add_build("maven", "language-package-build", by_name.get("pom.xml", []))
393
+ add_build("go", "language-package-build", by_name.get("go.mod", []))
394
+ add_build("python-build", "language-package-build", by_name.get("pyproject.toml", []))
395
+ add_build(
396
+ "msbuild",
397
+ "language-package-build",
398
+ [path for path in paths if path.endswith((".csproj", ".sln"))],
399
+ )
400
+ add_build("cmake", "project-generator", by_name.get("CMakeLists.txt", []))
401
+ add_build("nextjs", "framework-compiler", package_manifests if "next" in dependencies else [])
402
+ add_build("vite", "framework-bundler", package_manifests if "vite" in dependencies else [])
403
+ add_build("webpack", "framework-bundler", package_manifests if "webpack" in dependencies else [])
404
+ add_build("turborepo", "workspace-coordinator", by_name.get("turbo.json", []))
405
+ add_build("nx", "workspace-coordinator", by_name.get("nx.json", []))
406
+ add_build("pnpm", "package-manager", by_name.get("pnpm-lock.yaml", []))
407
+ add_build("yarn", "package-manager", by_name.get("yarn.lock", []))
408
+ add_build("npm", "package-manager", by_name.get("package-lock.json", []))
409
+ add_build(
410
+ "node-scripts",
411
+ "task-orchestration",
412
+ node_script_manifests,
413
+ )
414
+ add_build("make", "task-orchestration", by_name.get("Makefile", []))
415
+ add_build("shell-build", "task-orchestration", by_name.get("build.sh", []))
416
+ add_build("docker", "container-package", dockerfiles)
417
+ add_build("terraform", "infrastructure", terraform_files)
418
+ add_build(
419
+ "github-actions",
420
+ "ci-release-automation",
421
+ [path for path in paths if path.startswith(".github/workflows/")],
422
+ )
423
+
424
+ root_cargo_workspace = []
425
+ cargo_scanned = sorted(
426
+ cargo_manifests, key=lambda item: (item.count("/"), item)
427
+ )[:MANIFEST_CONTENT_SCAN_LIMIT]
428
+ for path in cargo_scanned:
429
+ content = read_text_at_ref(repo, ref, path) or ""
430
+ if "[workspace]" in content:
431
+ root_cargo_workspace.append(path)
432
+ workspace_evidence = [
433
+ *root_cargo_workspace,
434
+ *by_name.get("pnpm-workspace.yaml", []),
435
+ *node_workspace_manifests,
436
+ *by_name.get("turbo.json", []),
437
+ *by_name.get("nx.json", []),
438
+ ]
439
+
440
+ layout_names = {
441
+ "apps",
442
+ "clients",
443
+ "crates",
444
+ "daemon",
445
+ "deploy",
446
+ "infrastructure",
447
+ "libs",
448
+ "modules",
449
+ "packages",
450
+ "plugins",
451
+ "services",
452
+ "Sources",
453
+ "src",
454
+ "tools",
455
+ "workers",
456
+ }
457
+ top_level = {path.split("/", 1)[0] for path in paths}
458
+ layout_roots = sorted(top_level & layout_names)
459
+
460
+ application_signals: list[dict[str, Any]] = []
461
+
462
+ def add_application(
463
+ name: str,
464
+ evidence: list[str],
465
+ *,
466
+ confidence: str,
467
+ interpretation: str,
468
+ ) -> None:
469
+ if evidence:
470
+ application_signals.append(
471
+ {
472
+ "name": name,
473
+ "confidence": confidence,
474
+ "evidence": limited_evidence(evidence),
475
+ "interpretation": interpretation,
476
+ }
477
+ )
478
+
479
+ swift_evidence = language_paths.get("Swift", [])
480
+ rust_evidence = language_paths.get("Rust", [])
481
+ android_manifests = by_name.get("AndroidManifest.xml", [])
482
+ web_frameworks = dependencies & {
483
+ "@angular/core",
484
+ "next",
485
+ "nuxt",
486
+ "react",
487
+ "remix",
488
+ "svelte",
489
+ "vue",
490
+ }
491
+ runtime_roots = [
492
+ root for root in ("daemon", "services", "workers") if root in top_level
493
+ ]
494
+ runtime_evidence = [
495
+ path
496
+ for path in paths
497
+ if any(path == root or path.startswith(f"{root}/") for root in runtime_roots)
498
+ and Path(path).name
499
+ in {
500
+ "Cargo.toml",
501
+ "Dockerfile",
502
+ "go.mod",
503
+ "main.py",
504
+ "main.rs",
505
+ "package.json",
506
+ "pyproject.toml",
507
+ }
508
+ ]
509
+ rust_component_evidence = [
510
+ path for path in paths if path.endswith(("/src/main.rs", "/src/lib.rs"))
511
+ ]
512
+ plugin_evidence = [
513
+ path
514
+ for path in paths
515
+ if path.endswith(".claude-plugin/plugin.json")
516
+ or path == ".claude-plugin/plugin.json"
517
+ or path.endswith("/manifest.json") and "extension" in path.lower()
518
+ ]
519
+ agent_overlay_evidence = [
520
+ path
521
+ for path in paths
522
+ if path.startswith((".claude/agents/", ".codex/skills/"))
523
+ or path.endswith("/SKILL.md")
524
+ ]
525
+ data_ml_evidence = [
526
+ path
527
+ for path in paths
528
+ if path.endswith(".ipynb")
529
+ or path.startswith(("models/", "training/", "evals/", "notebooks/"))
530
+ ]
531
+ infra_evidence = [
532
+ *terraform_files,
533
+ *[path for path in paths if path.startswith(("infrastructure/", "deploy/", "charts/"))],
534
+ ]
535
+
536
+ add_application(
537
+ "apple-native",
538
+ [*xcode_projects, *xcodegen_manifests, *swift_evidence[:5]]
539
+ if swift_evidence and (xcode_projects or xcodegen_manifests)
540
+ else [],
541
+ confidence="high",
542
+ interpretation="Native Apple source and project/build configuration are present.",
543
+ )
544
+ add_application(
545
+ "android-native",
546
+ [*android_manifests, *gradle_manifests] if android_manifests else [],
547
+ confidence="high" if gradle_manifests else "medium",
548
+ interpretation="Android manifests and Gradle project evidence are present.",
549
+ )
550
+ add_application(
551
+ "web-application",
552
+ package_manifests if web_frameworks else [],
553
+ confidence="medium",
554
+ interpretation=f"Web framework dependencies detected: {', '.join(sorted(web_frameworks))}.",
555
+ )
556
+ add_application(
557
+ "service-or-daemon",
558
+ runtime_evidence or runtime_roots,
559
+ confidence="medium",
560
+ interpretation="Long-lived runtime layout is present; confirm independent deployment from release configuration.",
561
+ )
562
+ add_application(
563
+ "rust-cli-or-library-components",
564
+ [*cargo_manifests, *rust_component_evidence] if rust_evidence and rust_component_evidence else [],
565
+ confidence="medium",
566
+ interpretation="Rust binary and/or library component entrypoints are present.",
567
+ )
568
+ add_application(
569
+ "plugin-or-extension",
570
+ plugin_evidence,
571
+ confidence="high",
572
+ interpretation="A host-discovered plugin or extension manifest is tracked.",
573
+ )
574
+ add_application(
575
+ "agent-tooling-overlay",
576
+ agent_overlay_evidence,
577
+ confidence="high",
578
+ interpretation="Repository-local agent configuration exists; this is tooling, not product architecture.",
579
+ )
580
+ add_application(
581
+ "data-or-ml",
582
+ data_ml_evidence,
583
+ confidence="medium",
584
+ interpretation="Notebook, model, training, or evaluation paths are present.",
585
+ )
586
+ add_application(
587
+ "infrastructure",
588
+ infra_evidence,
589
+ confidence="medium",
590
+ interpretation="Infrastructure or deployment definitions are tracked.",
591
+ )
592
+ if swift_evidence and rust_evidence and (xcode_projects or xcodegen_manifests) and runtime_roots:
593
+ add_application(
594
+ "mixed-native-product",
595
+ [*xcodegen_manifests, *xcode_projects, *cargo_manifests, *runtime_roots],
596
+ confidence="high",
597
+ interpretation="Native Apple application and Rust runtime components coexist in one repository.",
598
+ )
599
+
600
+ composition_signals: list[dict[str, Any]] = []
601
+
602
+ def add_composition(name: str, evidence: list[str], interpretation: str) -> None:
603
+ if evidence:
604
+ composition_signals.append(
605
+ {
606
+ "name": name,
607
+ "evidence": limited_evidence(evidence),
608
+ "interpretation": interpretation,
609
+ }
610
+ )
611
+
612
+ package_roots = [root for root in ("packages", "crates", "modules", "libs") if root in top_level]
613
+ app_roots = [root for root in ("apps", "clients") if root in top_level]
614
+ add_composition(
615
+ "workspace",
616
+ workspace_evidence,
617
+ "One or more build tools coordinate multiple packages in this checkout.",
618
+ )
619
+ add_composition(
620
+ "package-family-layout",
621
+ package_roots,
622
+ "Top-level package/module collection exists; confirm enforceable package boundaries.",
623
+ )
624
+ add_composition(
625
+ "multi-app-layout",
626
+ app_roots,
627
+ "Top-level application/client collection exists; confirm the number of runnable applications.",
628
+ )
629
+ add_composition(
630
+ "runtime-layout",
631
+ runtime_roots,
632
+ "Service, worker, or daemon roots exist; confirm deployment independence.",
633
+ )
634
+ substantial_languages = [
635
+ item["name"] for item in languages if item["name"] not in {"Shell", "Terraform"}
636
+ ]
637
+ add_composition(
638
+ "mixed-language",
639
+ [sample for name in substantial_languages for sample in language_paths[name][:1]]
640
+ if len(substantial_languages) > 1
641
+ else [],
642
+ f"Multiple implementation languages are tracked: {', '.join(substantial_languages)}.",
643
+ )
644
+
645
+ return {
646
+ "source_ref": ref,
647
+ "inference_policy": (
648
+ "Signals are evidence, not a final application, topology, deployment, or ownership decision."
649
+ ),
650
+ "languages": languages,
651
+ "build_systems": build_systems,
652
+ "application_signals": application_signals,
653
+ "composition_signals": composition_signals,
654
+ "layout_roots": layout_roots,
655
+ "scan_summary": {
656
+ "tracked_paths": len(paths),
657
+ **scan_summary,
658
+ "cargo_manifests_found": len(cargo_manifests),
659
+ "cargo_manifests_scanned": len(cargo_scanned),
660
+ "cargo_manifest_scan_truncated": len(cargo_manifests) > len(cargo_scanned),
661
+ },
662
+ }
663
+
664
+
665
+ def process_snapshot() -> list[tuple[int, str]]:
666
+ result = subprocess.run(
667
+ ["ps", "-axo", "pid=,command="],
668
+ text=True,
669
+ stdout=subprocess.PIPE,
670
+ stderr=subprocess.PIPE,
671
+ check=False,
672
+ )
673
+ if result.returncode != 0:
674
+ return []
675
+ processes: list[tuple[int, str]] = []
676
+ for line in result.stdout.splitlines():
677
+ line = line.strip()
678
+ if not line:
679
+ continue
680
+ pid_text, _, command = line.partition(" ")
681
+ if pid_text.isdigit():
682
+ processes.append((int(pid_text), command.strip()))
683
+ return processes
684
+
685
+
686
+ def matches_artifact_pattern(name: str, patterns: tuple[str, ...]) -> bool:
687
+ return any(fnmatch.fnmatch(name, pattern) for pattern in patterns)
688
+
689
+
690
+ def discover_artifact_paths(repo: Path, patterns: tuple[str, ...]) -> list[Path]:
691
+ """Find artifact roots recursively without counting descendants twice."""
692
+ discovered: list[Path] = []
693
+ for current_root, directories, _files in os.walk(repo, followlinks=False):
694
+ current = Path(current_root)
695
+ retained: list[str] = []
696
+ for name in sorted(directories):
697
+ candidate = current / name
698
+ if (
699
+ candidate.is_symlink()
700
+ or name in DISCOVERY_PRUNE_NAMES
701
+ or (candidate / "pyvenv.cfg").is_file()
702
+ ):
703
+ continue
704
+ if matches_artifact_pattern(name, patterns):
705
+ discovered.append(candidate)
706
+ continue
707
+ retained.append(name)
708
+ directories[:] = retained
709
+ return sorted(discovered, key=lambda path: path.relative_to(repo).as_posix())
710
+
711
+
712
+ def command_references_path(command: str, path: Path) -> bool:
713
+ target = str(path)
714
+ start = 0
715
+ while True:
716
+ index = command.find(target, start)
717
+ if index < 0:
718
+ return False
719
+ end = index + len(target)
720
+ before = command[index - 1] if index else " "
721
+ after = command[end] if end < len(command) else " "
722
+ if before in " \t\"'=:([" and after in " \t\"'/)]":
723
+ return True
724
+ start = index + 1
725
+
726
+
727
+ def command_artifact_paths(
728
+ repo: Path, command: str, patterns: tuple[str, ...]
729
+ ) -> set[Path]:
730
+ """Extract artifact-root prefixes under repo from a process command."""
731
+ try:
732
+ tokens = shlex.split(command)
733
+ except ValueError:
734
+ tokens = command.split()
735
+ repo_prefix = f"{repo}{os.sep}"
736
+ roots: set[Path] = set()
737
+ for token in tokens:
738
+ index = token.find(repo_prefix)
739
+ if index < 0:
740
+ continue
741
+ path_text = token[index:].rstrip("\"'`,;:)]}")
742
+ try:
743
+ relative = Path(path_text).relative_to(repo)
744
+ except ValueError:
745
+ continue
746
+ current = repo
747
+ for part in relative.parts:
748
+ current /= part
749
+ if matches_artifact_pattern(part, patterns):
750
+ roots.add(current)
751
+ break
752
+ return roots
753
+
754
+
755
+ def active_missing_artifacts(
756
+ repo: Path,
757
+ patterns: tuple[str, ...],
758
+ processes: list[tuple[int, str]],
759
+ ) -> list[dict[str, Any]]:
760
+ refs: dict[str, list[dict[str, Any]]] = {}
761
+ for pid, command in processes:
762
+ for path in command_artifact_paths(repo, command, patterns):
763
+ if path.exists():
764
+ continue
765
+ relative = path.relative_to(repo).as_posix()
766
+ refs.setdefault(relative, []).append(
767
+ {"pid": pid, "command": command[:300]}
768
+ )
769
+ return [
770
+ {
771
+ "path": relative,
772
+ "exists": False,
773
+ "disposition": "active-missing-artifact",
774
+ "active_process_refs": process_refs,
775
+ }
776
+ for relative, process_refs in sorted(refs.items())
777
+ ]
778
+
779
+
780
+ def directory_stats(path: Path) -> tuple[int, int, int, float]:
781
+ allocated_bytes = 0
782
+ apparent_bytes = 0
783
+ file_count = 0
784
+ latest_mtime = path.stat().st_mtime
785
+ for current_root, directories, files in os.walk(path, followlinks=False):
786
+ directories[:] = [
787
+ name for name in directories if not (Path(current_root) / name).is_symlink()
788
+ ]
789
+ for name in files:
790
+ candidate = Path(current_root) / name
791
+ try:
792
+ stat = candidate.stat(follow_symlinks=False)
793
+ except OSError:
794
+ continue
795
+ apparent_bytes += stat.st_size
796
+ allocated_bytes += getattr(stat, "st_blocks", 0) * 512
797
+ file_count += 1
798
+ latest_mtime = max(latest_mtime, stat.st_mtime)
799
+ return allocated_bytes, apparent_bytes, file_count, latest_mtime
800
+
801
+
802
+ def release_artifact_evidence(repo: Path, path: Path) -> list[str]:
803
+ evidence: list[str] = []
804
+ release_container = (
805
+ path.name.lower() in RELEASE_CONTAINER_NAMES
806
+ or path.name.lower().endswith(RELEASE_ARTIFACT_SUFFIXES)
807
+ or path.name.lower().endswith(RELEASE_BUNDLE_SUFFIXES)
808
+ )
809
+ for current_root, directories, files in os.walk(path, followlinks=False):
810
+ current = Path(current_root)
811
+ directories[:] = [
812
+ name
813
+ for name in directories
814
+ if not (current / name).is_symlink()
815
+ ]
816
+ for name in [*directories, *files]:
817
+ lower_name = name.lower()
818
+ high_confidence = lower_name.endswith(RELEASE_ARTIFACT_SUFFIXES)
819
+ bundle_in_release_container = (
820
+ release_container and lower_name.endswith(RELEASE_BUNDLE_SUFFIXES)
821
+ )
822
+ if not high_confidence and not bundle_in_release_container:
823
+ continue
824
+ evidence.append((current / name).relative_to(repo).as_posix())
825
+ if len(evidence) >= RELEASE_EVIDENCE_LIMIT:
826
+ return evidence
827
+ return evidence
828
+
829
+
830
+ def artifact_inventory(
831
+ repo: Path,
832
+ patterns: tuple[str, ...],
833
+ protected: set[str],
834
+ stale_days: float,
835
+ *,
836
+ use_default_protections: bool = True,
837
+ ) -> dict[str, Any]:
838
+ processes = process_snapshot()
839
+ now = time.time()
840
+ paths = discover_artifact_paths(repo, patterns)
841
+ artifacts: list[dict[str, Any]] = []
842
+ for path in paths:
843
+ relative = path.relative_to(repo).as_posix()
844
+ allocated_bytes, apparent_bytes, file_count, latest_mtime = directory_stats(path)
845
+ active_refs = [
846
+ {"pid": pid, "command": command[:300]}
847
+ for pid, command in processes
848
+ if command_references_path(command, path)
849
+ ]
850
+ ignored = run_git(repo, "check-ignore", "-q", "--", relative, check=False).returncode == 0
851
+ age_days = max(0.0, (now - latest_mtime) / 86_400)
852
+ default_protected = use_default_protections and relative in DEFAULT_PROTECTED_ARTIFACTS
853
+ is_protected = default_protected or relative in protected or path.name in protected
854
+ release_evidence = release_artifact_evidence(repo, path)
855
+ contains_release_artifact = bool(release_evidence)
856
+ stale = age_days >= stale_days
857
+ cleanup_candidate = (
858
+ ignored
859
+ and stale
860
+ and not is_protected
861
+ and not active_refs
862
+ and not contains_release_artifact
863
+ )
864
+ if is_protected:
865
+ disposition = "protected"
866
+ elif active_refs:
867
+ disposition = "active"
868
+ elif contains_release_artifact:
869
+ disposition = "release-artifact"
870
+ elif not ignored:
871
+ disposition = "review-tracked-or-unignored"
872
+ elif not stale:
873
+ disposition = "recent-cache"
874
+ else:
875
+ disposition = "cleanup-candidate"
876
+ artifacts.append(
877
+ {
878
+ "path": relative,
879
+ "bytes": allocated_bytes,
880
+ "allocated_bytes": allocated_bytes,
881
+ "apparent_bytes": apparent_bytes,
882
+ "file_count": file_count,
883
+ "latest_mtime": latest_mtime,
884
+ "age_days": round(age_days, 2),
885
+ "ignored_by_git": ignored,
886
+ "protected": is_protected,
887
+ "default_protected": default_protected,
888
+ "active_process_refs": active_refs,
889
+ "contains_release_artifact": contains_release_artifact,
890
+ "release_artifact_evidence": release_evidence,
891
+ "stale": stale,
892
+ "cleanup_candidate": cleanup_candidate,
893
+ "disposition": disposition,
894
+ }
895
+ )
896
+ return {
897
+ "patterns": list(patterns),
898
+ "default_protected": (
899
+ sorted(DEFAULT_PROTECTED_ARTIFACTS) if use_default_protections else []
900
+ ),
901
+ "protected": sorted(set(protected) | (
902
+ set(DEFAULT_PROTECTED_ARTIFACTS) if use_default_protections else set()
903
+ )),
904
+ "stale_days": stale_days,
905
+ "total_bytes": sum(item["bytes"] for item in artifacts),
906
+ "total_apparent_bytes": sum(item["apparent_bytes"] for item in artifacts),
907
+ "cleanup_candidate_bytes": sum(
908
+ item["bytes"] for item in artifacts if item["cleanup_candidate"]
909
+ ),
910
+ "cleanup_candidate_apparent_bytes": sum(
911
+ item["apparent_bytes"] for item in artifacts if item["cleanup_candidate"]
912
+ ),
913
+ "active_missing_artifacts": active_missing_artifacts(repo, patterns, processes),
914
+ "artifacts": artifacts,
915
+ }
916
+
917
+
918
+ def tree_entries(repo: Path, ref: str, prefix: str | None = None) -> dict[str, dict[str, str]]:
919
+ args = ["ls-tree", "-r", "-z", ref]
920
+ normalized_prefix = prefix.strip("/") if prefix else None
921
+ if normalized_prefix:
922
+ args.extend(["--", normalized_prefix])
923
+ output = run_git(repo, *args).stdout
924
+ entries: dict[str, dict[str, str]] = {}
925
+ for record in output.split("\0"):
926
+ if not record:
927
+ continue
928
+ metadata, path = record.split("\t", 1)
929
+ mode, kind, object_id = metadata.split(" ", 2)
930
+ if normalized_prefix:
931
+ prefix_with_slash = f"{normalized_prefix}/"
932
+ if path.startswith(prefix_with_slash):
933
+ path = path.removeprefix(prefix_with_slash)
934
+ entries[path] = {"mode": mode, "kind": kind, "object": object_id}
935
+ return entries
936
+
937
+
938
+ def compare_source_tree(
939
+ repo: Path,
940
+ base: str,
941
+ source_repo: Path,
942
+ source_ref: str,
943
+ target_prefix: str,
944
+ ) -> dict[str, Any]:
945
+ source_root = resolve_root(source_repo)
946
+ source_head = run_git(source_root, "rev-parse", source_ref).stdout.strip()
947
+ source_entries = tree_entries(source_root, source_ref)
948
+ target_entries = tree_entries(repo, base, target_prefix)
949
+ source_paths = set(source_entries)
950
+ target_paths = set(target_entries)
951
+ common = source_paths & target_paths
952
+ changed = sorted(path for path in common if source_entries[path] != target_entries[path])
953
+ source_only = sorted(source_paths - target_paths)
954
+ target_only = sorted(target_paths - source_paths)
955
+ source_head_known = ref_exists(repo, source_head)
956
+ source_head_ancestor = source_head_known and is_ancestor(repo, source_head, base)
957
+ return {
958
+ "source_repo": str(source_root),
959
+ "source_ref": source_ref,
960
+ "source_head": source_head,
961
+ "target_ref": base,
962
+ "target_prefix": target_prefix.strip("/"),
963
+ "source_tracked_paths": len(source_paths),
964
+ "target_tracked_paths": len(target_paths),
965
+ "matching_paths": len(common) - len(changed),
966
+ "changed_paths": changed,
967
+ "source_only_paths": source_only,
968
+ "target_only_paths": target_only,
969
+ "exact_tree_match": bool(source_entries) and source_entries == target_entries,
970
+ "source_head_known_to_target_repo": source_head_known,
971
+ "source_head_ancestor_of_target_ref": source_head_ancestor,
972
+ }
973
+
974
+
975
+ def audit(
976
+ repo: Path,
977
+ base: str = "main",
978
+ *,
979
+ include_artifacts: bool = False,
980
+ artifact_patterns: tuple[str, ...] = DEFAULT_ARTIFACT_PATTERNS,
981
+ protected_artifacts: set[str] | None = None,
982
+ use_default_artifact_protections: bool = True,
983
+ stale_days: float = 7,
984
+ compare_repo: Path | None = None,
985
+ compare_prefix: str | None = None,
986
+ compare_ref: str = "HEAD",
987
+ ) -> dict[str, Any]:
988
+ root = resolve_root(repo)
989
+ scan_head_start = run_git(root, "rev-parse", "HEAD", check=False).stdout.strip() or None
990
+ base_exists = ref_exists(root, base)
991
+ worktrees = parse_worktrees(root)
992
+ branches = branch_inventory(root, base, worktrees) if base_exists else []
993
+ current_branch = run_git(root, "branch", "--show-current").stdout.strip() or None
994
+ base_upstream_result = run_git(root, "rev-parse", "--abbrev-ref", f"{base}@{{upstream}}", check=False)
995
+ base_upstream = base_upstream_result.stdout.strip() if base_upstream_result.returncode == 0 else None
996
+ upstream_state = None
997
+ if base_upstream and ref_exists(root, base_upstream):
998
+ behind, ahead = ahead_behind(root, base_upstream, base)
999
+ upstream_state = {"ref": base_upstream, "behind": behind, "ahead": ahead}
1000
+ candidates = [
1001
+ branch["name"]
1002
+ for branch in branches
1003
+ if branch["name"] != base and not branch.get("merged_into_base", False)
1004
+ ]
1005
+ removable = [
1006
+ branch["name"]
1007
+ for branch in branches
1008
+ if branch["name"] != base and branch.get("merged_into_base", False)
1009
+ ]
1010
+ report: dict[str, Any] = {
1011
+ "schema_version": 5,
1012
+ "repo_root": str(root),
1013
+ "base": base,
1014
+ "base_exists": base_exists,
1015
+ "base_head": run_git(root, "rev-parse", base).stdout.strip() if base_exists else None,
1016
+ "base_upstream": upstream_state,
1017
+ "current_branch": current_branch,
1018
+ "canonical_dirty_paths": parse_status(root),
1019
+ "operations_in_progress": operation_state(root),
1020
+ "worktrees": worktrees,
1021
+ "missing_worktrees": [
1022
+ str(worktree["worktree"])
1023
+ for worktree in worktrees
1024
+ if not worktree.get("git_available", False)
1025
+ ],
1026
+ "branches": branches,
1027
+ "stashes": stash_inventory(root),
1028
+ "unmerged_candidates": candidates,
1029
+ "merged_branch_candidates": removable,
1030
+ "archive_tags": run_git(root, "tag", "-l", "archive/pre-closeout-*").stdout.splitlines(),
1031
+ "structure": tracked_structure(root),
1032
+ "profile_signals": detect_profile_signals(root, base if base_exists else "HEAD"),
1033
+ }
1034
+ if include_artifacts:
1035
+ report["artifacts"] = artifact_inventory(
1036
+ root,
1037
+ artifact_patterns,
1038
+ protected_artifacts or set(),
1039
+ stale_days,
1040
+ use_default_protections=use_default_artifact_protections,
1041
+ )
1042
+ if compare_repo is not None or compare_prefix is not None:
1043
+ if compare_repo is None or not compare_prefix:
1044
+ raise ValueError("--compare-repo and --compare-prefix must be used together")
1045
+ report["source_comparison"] = compare_source_tree(
1046
+ root,
1047
+ base,
1048
+ compare_repo,
1049
+ compare_ref,
1050
+ compare_prefix,
1051
+ )
1052
+ scan_head_end = run_git(root, "rev-parse", "HEAD", check=False).stdout.strip() or None
1053
+ report["scan_head"] = {
1054
+ "start": scan_head_start,
1055
+ "end": scan_head_end,
1056
+ "moved": bool(scan_head_start and scan_head_end and scan_head_start != scan_head_end),
1057
+ }
1058
+ return report
1059
+
1060
+
1061
+ def render_text(report: dict[str, Any]) -> str:
1062
+ upstream = report.get("base_upstream")
1063
+ upstream_text = "none"
1064
+ if upstream:
1065
+ upstream_text = f"{upstream['ref']} ahead={upstream['ahead']} behind={upstream['behind']}"
1066
+ lines = [
1067
+ f"repo: {report['repo_root']}",
1068
+ f"base: {report['base']} {report['base_head'] or 'missing'}",
1069
+ f"upstream: {upstream_text}",
1070
+ f"canonical dirty paths: {len(report['canonical_dirty_paths'])}",
1071
+ f"worktrees: {len(report['worktrees'])}",
1072
+ f"missing worktrees: {', '.join(report['missing_worktrees']) or 'none'}",
1073
+ f"local branches: {len(report['branches'])}",
1074
+ f"unmerged candidates: {', '.join(report['unmerged_candidates']) or 'none'}",
1075
+ f"merged branch candidates: {', '.join(report['merged_branch_candidates']) or 'none'}",
1076
+ f"stashes: {len(report['stashes'])}",
1077
+ f"operations in progress: {', '.join(report['operations_in_progress']) or 'none'}",
1078
+ f"archive tags: {len(report['archive_tags'])}",
1079
+ f"tracked files: {report['structure']['tracked_file_count']}",
1080
+ ]
1081
+ scan_head = report.get("scan_head") or {}
1082
+ if scan_head.get("moved"):
1083
+ lines.append(
1084
+ f"HEAD MOVED DURING SCAN: {scan_head['start']} -> {scan_head['end']} "
1085
+ "— every count in this report is a snapshot of a tree that changed under it"
1086
+ )
1087
+ elif scan_head.get("start"):
1088
+ lines.append(f"scan head: {scan_head['start']} (stable)")
1089
+ profile = report["profile_signals"]
1090
+ lines.extend(
1091
+ [
1092
+ "languages: "
1093
+ + (", ".join(item["name"] for item in profile["languages"]) or "none"),
1094
+ "build systems: "
1095
+ + (", ".join(item["name"] for item in profile["build_systems"]) or "none"),
1096
+ "application signals: "
1097
+ + (", ".join(item["name"] for item in profile["application_signals"]) or "none"),
1098
+ "composition signals: "
1099
+ + (", ".join(item["name"] for item in profile["composition_signals"]) or "none"),
1100
+ ]
1101
+ )
1102
+ artifacts = report.get("artifacts")
1103
+ if artifacts:
1104
+ candidates = [
1105
+ item["path"] for item in artifacts["artifacts"] if item["cleanup_candidate"]
1106
+ ]
1107
+ lines.extend(
1108
+ [
1109
+ f"artifact directories: {len(artifacts['artifacts'])}",
1110
+ f"artifact bytes: {artifacts['total_bytes']}",
1111
+ f"artifact cleanup candidates: {', '.join(candidates) or 'none'}",
1112
+ "active missing artifacts: "
1113
+ + (
1114
+ ", ".join(item["path"] for item in artifacts["active_missing_artifacts"])
1115
+ or "none"
1116
+ ),
1117
+ ]
1118
+ )
1119
+ comparison = report.get("source_comparison")
1120
+ if comparison:
1121
+ lines.extend(
1122
+ [
1123
+ f"source comparison: {comparison['source_repo']} -> {comparison['target_prefix']}",
1124
+ f"source head ancestor of {comparison['target_ref']}: "
1125
+ f"{comparison['source_head_ancestor_of_target_ref']}",
1126
+ f"source tree exact match: {comparison['exact_tree_match']}",
1127
+ f"source/changed/target-only paths: "
1128
+ f"{len(comparison['source_only_paths'])}/"
1129
+ f"{len(comparison['changed_paths'])}/"
1130
+ f"{len(comparison['target_only_paths'])}",
1131
+ ]
1132
+ )
1133
+ return "\n".join(lines)
1134
+
1135
+
1136
+ def main() -> int:
1137
+ parser = argparse.ArgumentParser(description=__doc__)
1138
+ parser.add_argument("--repo", default=".", help="Path inside the Git repository")
1139
+ parser.add_argument("--base", default="main", help="Local integration branch")
1140
+ parser.add_argument(
1141
+ "--artifacts",
1142
+ action="store_true",
1143
+ help="Inventory common build, cache, and generated directories recursively",
1144
+ )
1145
+ parser.add_argument(
1146
+ "--artifact-pattern",
1147
+ action="append",
1148
+ default=[],
1149
+ help="Additional artifact-directory glob (repeatable)",
1150
+ )
1151
+ parser.add_argument(
1152
+ "--protect-artifact",
1153
+ action="append",
1154
+ default=[],
1155
+ help="Artifact path or name that must not be a cleanup candidate (repeatable)",
1156
+ )
1157
+ parser.add_argument(
1158
+ "--no-default-artifact-protection",
1159
+ action="store_true",
1160
+ help="Do not automatically protect canonical top-level build and build-rust roots",
1161
+ )
1162
+ parser.add_argument(
1163
+ "--stale-days",
1164
+ type=float,
1165
+ default=7,
1166
+ help="Minimum age for an ignored artifact cleanup candidate (default: 7)",
1167
+ )
1168
+ parser.add_argument("--compare-repo", help="Sibling/source Git repository to compare")
1169
+ parser.add_argument(
1170
+ "--compare-prefix",
1171
+ help="Path inside --base that should represent --compare-repo",
1172
+ )
1173
+ parser.add_argument("--compare-ref", default="HEAD", help="Source repository ref")
1174
+ parser.add_argument("--json", action="store_true", help="Emit JSON")
1175
+ args = parser.parse_args()
1176
+ try:
1177
+ patterns = tuple(dict.fromkeys((*DEFAULT_ARTIFACT_PATTERNS, *args.artifact_pattern)))
1178
+ report = audit(
1179
+ Path(args.repo),
1180
+ base=args.base,
1181
+ include_artifacts=args.artifacts,
1182
+ artifact_patterns=patterns,
1183
+ protected_artifacts=set(args.protect_artifact),
1184
+ use_default_artifact_protections=not args.no_default_artifact_protection,
1185
+ stale_days=args.stale_days,
1186
+ compare_repo=Path(args.compare_repo) if args.compare_repo else None,
1187
+ compare_prefix=args.compare_prefix,
1188
+ compare_ref=args.compare_ref,
1189
+ )
1190
+ except (RuntimeError, OSError, ValueError) as error:
1191
+ print(f"repository maintenance audit failed: {error}", file=sys.stderr)
1192
+ return 2
1193
+ print(json.dumps(report, indent=2, sort_keys=True) if args.json else render_text(report))
1194
+ return 0
1195
+
1196
+
1197
+ if __name__ == "__main__":
1198
+ raise SystemExit(main())