dos-kernel 0.22.0__py3-none-win_amd64.whl

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 (178) hide show
  1. dos/__init__.py +261 -0
  2. dos/_bin/dos-hook.exe +0 -0
  3. dos/_filelock.py +255 -0
  4. dos/_job_policy.py +97 -0
  5. dos/_tree.py +145 -0
  6. dos/admission.py +433 -0
  7. dos/answer_shape.py +299 -0
  8. dos/arbiter.py +859 -0
  9. dos/archive_lock.py +266 -0
  10. dos/arg_provenance.py +814 -0
  11. dos/attest.py +472 -0
  12. dos/breaker.py +311 -0
  13. dos/churn.py +226 -0
  14. dos/claim_extract.py +229 -0
  15. dos/claim_ttl.py +150 -0
  16. dos/cli.py +8721 -0
  17. dos/commit_audit.py +666 -0
  18. dos/completion.py +466 -0
  19. dos/concurrency_class.py +154 -0
  20. dos/config.py +1380 -0
  21. dos/config_lint.py +464 -0
  22. dos/cooldown.py +390 -0
  23. dos/coverage.py +387 -0
  24. dos/dangling_intent.py +287 -0
  25. dos/data_class.py +397 -0
  26. dos/decisions.py +1274 -0
  27. dos/decisions_tui.py +251 -0
  28. dos/dispatch_top.py +740 -0
  29. dos/dispatch_top_tui.py +116 -0
  30. dos/drivers/__init__.py +40 -0
  31. dos/drivers/ci_status.py +630 -0
  32. dos/drivers/citation_resolve.py +703 -0
  33. dos/drivers/decision_stop.py +98 -0
  34. dos/drivers/export_file.py +173 -0
  35. dos/drivers/export_otlp.py +275 -0
  36. dos/drivers/export_statsd.py +242 -0
  37. dos/drivers/hook_dialects.py +391 -0
  38. dos/drivers/job.py +47 -0
  39. dos/drivers/llm_judge.py +360 -0
  40. dos/drivers/memory_recall.py +1231 -0
  41. dos/drivers/notify_slack.py +373 -0
  42. dos/drivers/notify_webhook.py +251 -0
  43. dos/drivers/operator_judge.py +114 -0
  44. dos/drivers/os_acceptance.py +228 -0
  45. dos/drivers/paste_log.py +132 -0
  46. dos/drivers/plan_scope.py +133 -0
  47. dos/drivers/self_improve.py +375 -0
  48. dos/drivers/similarity_judge.py +249 -0
  49. dos/drivers/state_diff.py +274 -0
  50. dos/drivers/supervisor.py +347 -0
  51. dos/drivers/watchdog.py +363 -0
  52. dos/drivers/workshop.py +160 -0
  53. dos/durable_schema.py +344 -0
  54. dos/effect_witness.py +393 -0
  55. dos/efficiency.py +318 -0
  56. dos/enforce.py +414 -0
  57. dos/enumerate.py +776 -0
  58. dos/env_print.py +378 -0
  59. dos/event_severity.py +258 -0
  60. dos/evidence.py +692 -0
  61. dos/exec_capability.py +256 -0
  62. dos/export_cursor.py +143 -0
  63. dos/exporter.py +320 -0
  64. dos/firing_label.py +353 -0
  65. dos/fleet_roll.py +226 -0
  66. dos/gate_classify.py +827 -0
  67. dos/gh4_coverage.py +179 -0
  68. dos/git_delta.py +122 -0
  69. dos/guard.py +215 -0
  70. dos/health.py +552 -0
  71. dos/help_summary.py +519 -0
  72. dos/home.py +934 -0
  73. dos/hook_binary.py +194 -0
  74. dos/hook_dialect.py +271 -0
  75. dos/hook_exit.py +191 -0
  76. dos/hook_install.py +437 -0
  77. dos/id_alloc.py +304 -0
  78. dos/improve.py +499 -0
  79. dos/intent_ledger.py +635 -0
  80. dos/interpret.py +176 -0
  81. dos/intervention.py +769 -0
  82. dos/intervention_eval.py +371 -0
  83. dos/journal_delta.py +308 -0
  84. dos/judge_eval.py +328 -0
  85. dos/judges.py +366 -0
  86. dos/lane_infer.py +127 -0
  87. dos/lane_journal.py +1001 -0
  88. dos/lane_lease.py +952 -0
  89. dos/lane_overlap.py +228 -0
  90. dos/lease_health.py +282 -0
  91. dos/lifecycle.py +211 -0
  92. dos/liveness.py +352 -0
  93. dos/lock_modes.py +185 -0
  94. dos/log_source.py +395 -0
  95. dos/loop_decide.py +1746 -0
  96. dos/marker_gate.py +254 -0
  97. dos/marker_sensor.py +396 -0
  98. dos/noop_streak.py +280 -0
  99. dos/notify.py +479 -0
  100. dos/observe.py +175 -0
  101. dos/oracle.py +1661 -0
  102. dos/overlap_eval.py +214 -0
  103. dos/overlap_policy.py +342 -0
  104. dos/packet_sidecar.py +267 -0
  105. dos/phase_shipped.py +1985 -0
  106. dos/pick_priority.py +225 -0
  107. dos/pickable.py +369 -0
  108. dos/picker_oracle.py +1037 -0
  109. dos/plan_board.py +513 -0
  110. dos/plan_board_tui.py +113 -0
  111. dos/plan_source.py +455 -0
  112. dos/posttool_sensor.py +528 -0
  113. dos/precursor_gate.py +499 -0
  114. dos/precursor_gate_eval.py +239 -0
  115. dos/preflight.py +825 -0
  116. dos/pretool_sensor.py +490 -0
  117. dos/proc_delta.py +181 -0
  118. dos/productivity.py +296 -0
  119. dos/provider_limit.py +242 -0
  120. dos/py.typed +4 -0
  121. dos/reason_morphology.py +299 -0
  122. dos/reasons.py +449 -0
  123. dos/reconcile.py +173 -0
  124. dos/recurring_wedge.py +206 -0
  125. dos/render.py +393 -0
  126. dos/result_state.py +468 -0
  127. dos/resume.py +578 -0
  128. dos/resume_evidence.py +293 -0
  129. dos/retention.py +344 -0
  130. dos/reward.py +372 -0
  131. dos/rewind.py +587 -0
  132. dos/rewind_evidence.py +168 -0
  133. dos/rewind_tokens.py +252 -0
  134. dos/run_id.py +342 -0
  135. dos/scope.py +520 -0
  136. dos/scope_source.py +382 -0
  137. dos/scout.py +982 -0
  138. dos/self_modify.py +209 -0
  139. dos/sibling_scan.py +569 -0
  140. dos/skills/EXAMPLES.md +584 -0
  141. dos/skills/dos-class-cycle/SKILL.md +107 -0
  142. dos/skills/dos-dispatch/SKILL.md +177 -0
  143. dos/skills/dos-dispatch-loop/SKILL.md +254 -0
  144. dos/skills/dos-goal-gate/SKILL.md +269 -0
  145. dos/skills/dos-next-up/SKILL.md +231 -0
  146. dos/skills/dos-promote/SKILL.md +114 -0
  147. dos/skills/dos-replan/SKILL.md +159 -0
  148. dos/skills/dos-replan-loop/SKILL.md +114 -0
  149. dos/skills/dos-self-improve/SKILL.md +213 -0
  150. dos/skills/dos-supervise-loop/SKILL.md +180 -0
  151. dos/skills/dos-unstick/SKILL.md +108 -0
  152. dos/skills/dos-witness-claim/SKILL.md +251 -0
  153. dos/stamp.py +1002 -0
  154. dos/state_health.py +387 -0
  155. dos/status.py +114 -0
  156. dos/stop_policy.py +334 -0
  157. dos/supervise.py +1014 -0
  158. dos/testwitness.py +392 -0
  159. dos/timeline.py +1027 -0
  160. dos/tokens.py +485 -0
  161. dos/tool_stream.py +393 -0
  162. dos/tool_stream_eval.py +226 -0
  163. dos/trace.py +524 -0
  164. dos/verdict.py +140 -0
  165. dos/verdict_cli.py +189 -0
  166. dos/verdict_journal.py +497 -0
  167. dos/verdict_rollup.py +217 -0
  168. dos/verdicts.py +181 -0
  169. dos/wedge_reason.py +282 -0
  170. dos_kernel-0.22.0.dist-info/METADATA +859 -0
  171. dos_kernel-0.22.0.dist-info/RECORD +178 -0
  172. dos_kernel-0.22.0.dist-info/WHEEL +5 -0
  173. dos_kernel-0.22.0.dist-info/entry_points.txt +39 -0
  174. dos_kernel-0.22.0.dist-info/licenses/LICENSE +21 -0
  175. dos_kernel-0.22.0.dist-info/top_level.txt +2 -0
  176. dos_mcp/__init__.py +52 -0
  177. dos_mcp/py.typed +2 -0
  178. dos_mcp/server.py +779 -0
dos/commit_audit.py ADDED
@@ -0,0 +1,666 @@
1
+ """commit-audit — does a commit's *claim* match what its *diff* actually did?
2
+
3
+ The kernel's whole thesis is **byte-author ≠ claimant**: the words in a commit
4
+ *subject* are authored by whoever wrote the message (forgeable); the *files the
5
+ commit touched* are authored by the commit machinery itself (not forgeable). That
6
+ split has nothing to do with whether a **human** or an **agent** wrote the
7
+ message — so this verdict is **author-neutral.** A person who writes
8
+ `fix: resolve the auth race` in a commit that touches only `README.md` has made a
9
+ claim their own diff cannot witness, exactly as an agent's `--allow-empty
10
+ "phase shipped"` does (docs/206 §5 E3, `benchmark/fleet_horizon/forge.py`).
11
+
12
+ Unlike `oracle.is_shipped`, this needs **no plan, no phase, no DOS vocabulary** —
13
+ it reads a single commit and grades the *relationship between the claim's KIND and
14
+ the diff's SHAPE*. That makes it the universal, zero-config form of the floor: a
15
+ `git log` audit / pre-commit lint / CI gate any git project can run.
16
+
17
+ It is PURE (`classify(CommitClaim, DiffFacts, policy) -> ClaimVerdict`): the git
18
+ read happens at the caller boundary (the `dos commit-audit` CLI / `audit_commit`
19
+ reader below), exactly like `liveness.classify` over `git_delta` — the arbiter
20
+ discipline. And it is **advisory**: it reports a rung, it does not block. A
21
+ doc-only "fix" is *sometimes* legitimate (a comment fix); the verdict says
22
+ "the claim rests on the message text, not the diff," and the consumer decides.
23
+
24
+ ## What it can witness soundly (the non-forgeable facts)
25
+
26
+ 1. **EMPTY_CLAIM** — the subject uses an *effect verb* ("fix/add/implement/
27
+ remove/optimize/…") but the commit touched **zero source files** (it is empty,
28
+ or touches only docs/config/generated/binary paths). The message claims a code
29
+ change; the diff shows none. Sound: "touched 0 source files" is a fact about
30
+ the commit, not the author's word.
31
+ 2. **TEST_CLAIM_NO_TEST** — the subject claims tests ("add tests", "tests pass",
32
+ "fix the failing test") but the diff touches **no test file**, or **net-deletes**
33
+ test lines (the delete-the-assertion shape). Sound from the diff alone.
34
+ 3. **the forgeability RUNG of every commit** — `diff-witnessed` (the diff touches
35
+ source the subject plausibly refers to) vs `subject-only` (the claim rests on
36
+ message text alone). The generalization of `oracle._grade_grep_source` to "does
37
+ the diff corroborate the subject."
38
+
39
+ A claim that STRUCTURALLY scopes itself is witnessed by its own location kind:
40
+ `docs: …` by a doc file, and a CI-scoped claim (`ci: …` / `fix(ci): …`, the
41
+ conventional-commit type/scope) by a canonical CI-config path
42
+ (`.github/workflows/*.yml` & friends — `_is_ci_config`). The scope names where
43
+ the claimed effect LIVES, so the natural diff is corroboration, not contradiction
44
+ — without this, every honest `fix(ci):` touching only its workflow read as
45
+ "code claim, no source" (the 5b2b940/e5debd1 over-fire). The suppression needs
46
+ the CONJUNCTION (ci-shaped claim AND ci-config diff); either alone changes
47
+ nothing, so the widening is monotone fire-reducing.
48
+
49
+ ## What it CANNOT witness — and so ABSTAINS on (state it; do not pretend)
50
+
51
+ - **Correctness.** A real fix to the *wrong* bug touches source and passes here.
52
+ This grades *did the diff do the KIND of thing claimed*, never *was it right*
53
+ (Wall 3, `project-dos-wall-presence-not-goal`).
54
+ - **Issue resolution.** "closes #42" needs an issue→files map this module does not
55
+ have; it never claims to verify the *content* of an issue reference.
56
+ - **A subject with no checkable claim** ("wip", "misc", "address review") →
57
+ `NO_CLAIM`, abstain. Most commits are honest and uncheckable; the verdict only
58
+ *fires* where a concrete claim and a contradicting diff coexist.
59
+
60
+ The conservative direction is load-bearing: a false WARN on a legitimate doc-only
61
+ fix is annoying, so the verb taxonomy is deliberately tight and the source/non-
62
+ source split errs toward calling things source (fewer false EMPTY_CLAIM fires).
63
+ """
64
+ from __future__ import annotations
65
+
66
+ import dataclasses
67
+ import re
68
+ import subprocess
69
+ from enum import Enum
70
+ from pathlib import Path
71
+
72
+ _GIT_TIMEOUT_S = 15
73
+
74
+
75
+ class ClaimKind(Enum):
76
+ """The KIND of claim a commit subject makes — what we can check the diff against."""
77
+ CODE_EFFECT = "code_effect" # fix/add/implement/remove/refactor/optimize/…
78
+ TEST = "test" # add/fix tests, "tests pass/green"
79
+ DOC = "doc" # docs/comment/typo/readme — a doc claim
80
+ NONE = "none" # wip/misc/merge/bump — no checkable behavioral claim
81
+
82
+
83
+ class Witness(Enum):
84
+ """How the diff stands relative to the claim."""
85
+ DIFF_WITNESSED = "diff-witnessed" # the diff corroborates the claim (non-forgeable)
86
+ SUBJECT_ONLY = "subject-only" # the claim rests on message text alone (forgeable)
87
+ ABSTAIN = "abstain" # no checkable claim, or no evidence either way
88
+
89
+
90
+ class Verdict(Enum):
91
+ OK = "OK" # claim witnessed by the diff (or honestly doc-only)
92
+ CLAIM_UNWITNESSED = "CLAIM_UNWITNESSED" # a concrete claim the diff contradicts
93
+ ABSTAIN = "ABSTAIN" # no checkable claim
94
+
95
+
96
+ # Effect verbs that assert a *code* change. Tight on purpose (false WARNs are the
97
+ # cost). Matched at the START of the subject (conventional-commit style) OR as a
98
+ # leading word, case-insensitive. `refactor`/`rename`/`move` are CODE_EFFECT too
99
+ # but are guarded specially below (they legitimately touch many files / can be
100
+ # pure-move with no net line change).
101
+ _CODE_VERBS = (
102
+ "fix", "fixes", "fixed", "add", "adds", "added", "implement", "implements",
103
+ "implemented", "remove", "removes", "removed", "delete", "optimize",
104
+ "optimise", "refactor", "rename", "move", "support", "handle", "resolve",
105
+ "resolves", "patch", "correct", "introduce", "enable", "disable", "drop",
106
+ "migrate", "upgrade", "downgrade", "wire", "hook",
107
+ )
108
+ # Verbs/markers that claim TESTS specifically.
109
+ _TEST_MARKERS = (
110
+ "test", "tests", "testing", "unit test", "unit tests", "coverage",
111
+ "assertion", "assertions", "spec", "specs",
112
+ )
113
+ _TEST_PASS_PHRASES = (
114
+ "tests pass", "test pass", "tests green", "all tests", "passing tests",
115
+ "tests passing", "green build", "fix the test", "fix failing test",
116
+ "fix the failing test", "fixes the test",
117
+ )
118
+ # Doc-only claim markers (a subject that HONESTLY scopes itself to docs).
119
+ _DOC_MARKERS = (
120
+ "doc", "docs", "documentation", "readme", "comment", "comments", "typo",
121
+ "changelog", "wording", "rephrase", "clarify",
122
+ )
123
+ # Subjects with no checkable behavioral claim → NO_CLAIM (abstain).
124
+ _NOCLAIM_MARKERS = (
125
+ "wip", "misc", "cleanup", "chore", "bump", "version", "release", "merge",
126
+ "revert", "format", "lint", "whitespace", "style", "address review",
127
+ "review feedback", "nit", "nits", "rename variable",
128
+ )
129
+
130
+ # Source-file suffixes — the universal "this is code" set. Erring toward INCLUSION
131
+ # (more suffixes = fewer false EMPTY_CLAIM fires on a real code change in an exotic
132
+ # language). A commit that touches one of these counts as touching source.
133
+ _SOURCE_SUFFIXES = (
134
+ ".py", ".pyi", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".go", ".rs",
135
+ ".java", ".kt", ".kts", ".scala", ".c", ".h", ".cc", ".cpp", ".cxx", ".hpp",
136
+ ".hh", ".cs", ".rb", ".php", ".swift", ".m", ".mm", ".sh", ".bash", ".zsh",
137
+ ".ps1", ".psm1", ".psd1", ".bat", ".cmd",
138
+ ".pl", ".pm", ".lua", ".r", ".jl", ".dart", ".ex", ".exs", ".erl", ".clj",
139
+ ".cljs", ".hs", ".ml", ".fs", ".vb", ".sql", ".proto", ".tf", ".gradle",
140
+ ".cmake", ".mk", ".vue", ".svelte", ".elm", ".nim", ".zig", ".v",
141
+ )
142
+ # Non-source: docs, config, data, binaries, lockfiles. A commit touching ONLY
143
+ # these (and no _SOURCE_SUFFIXES file) has not touched code.
144
+ _DOC_SUFFIXES = (".md", ".rst", ".txt", ".adoc", ".org")
145
+ _BINARY_OR_DATA_SUFFIXES = (
146
+ ".png", ".jpg", ".jpeg", ".gif", ".svg", ".pdf", ".ico", ".woff", ".woff2",
147
+ ".ttf", ".zip", ".tar", ".gz", ".tgz", ".whl", ".egg", ".so", ".dll",
148
+ ".dylib", ".exe", ".o", ".a", ".jar", ".class", ".bin", ".lock", ".sum",
149
+ ".csv", ".tsv", ".parquet",
150
+ )
151
+
152
+ _TEST_PATH_RE = re.compile(
153
+ r"(^|/)(tests?|spec|specs|__tests__)(/|$)|"
154
+ r"(^|/)(test_[^/]+|[^/]+_test|[^/]+\.test|[^/]+\.spec)\.[A-Za-z0-9]+$",
155
+ re.IGNORECASE,
156
+ )
157
+
158
+ # CI-config locations — the canonical places CI behavior is DECLARED. A ci-scoped
159
+ # claim (see `_scope_is_ci`) is witnessed by a diff touching one of these, the way
160
+ # a doc-scoped claim is witnessed by a doc file. CLOSED set, matched structurally
161
+ # (the workflows dir prefix + a yaml suffix, or an exact well-known filename) —
162
+ # never a substring, never "any .yml" (a stray config.yml is not CI).
163
+ _CI_WORKFLOW_DIR = ".github/workflows/"
164
+ _CI_CONFIG_FILES = (
165
+ ".gitlab-ci.yml", ".travis.yml", "azure-pipelines.yml", "appveyor.yml",
166
+ ".appveyor.yml", ".circleci/config.yml", "bitbucket-pipelines.yml",
167
+ )
168
+
169
+
170
+ @dataclasses.dataclass(frozen=True)
171
+ class CommitClaim:
172
+ """The claim side: what the commit SUBJECT asserts (author-authored, forgeable)."""
173
+ sha: str
174
+ subject: str
175
+
176
+
177
+ @dataclasses.dataclass(frozen=True)
178
+ class DiffFacts:
179
+ """The witness side: what the commit's DIFF actually did (machinery-authored).
180
+
181
+ `files` is the set of repo-relative paths the commit touched. `is_empty` is
182
+ True for a `--allow-empty` commit (zero files). `test_lines_added` /
183
+ `test_lines_removed` are the net test-file line deltas when known (−1/−1 means
184
+ "not computed"; the verdict then falls back to test-file presence alone).
185
+ """
186
+ files: tuple[str, ...]
187
+ is_empty: bool
188
+ test_lines_added: int = -1
189
+ test_lines_removed: int = -1
190
+
191
+
192
+ @dataclasses.dataclass(frozen=True)
193
+ class ClaimPolicy:
194
+ """Knobs (all conservative by default). Kept tiny — the point is zero-config."""
195
+ # Treat a commit touching only docs as a doc claim even if the verb is a code
196
+ # verb? Off by default = we DO fire EMPTY_CLAIM on `fix: ...` touching only a
197
+ # .md (the common "claimed code, edited a doc" case). A repo that commits code
198
+ # fixes as markdown (rare) can turn this on to silence it.
199
+ docs_satisfy_code_claim: bool = False
200
+
201
+
202
+ DEFAULT_POLICY = ClaimPolicy()
203
+
204
+
205
+ @dataclasses.dataclass(frozen=True)
206
+ class ClaimVerdict:
207
+ """The graded relationship between a commit's claim and its diff."""
208
+ sha: str
209
+ verdict: Verdict
210
+ claim_kind: ClaimKind
211
+ witness: Witness
212
+ reason: str
213
+ # the files that made the witness call (touched source, tests, or CI config),
214
+ # for an honest, inspectable verdict — never just a boolean.
215
+ source_files: tuple[str, ...] = ()
216
+ test_files: tuple[str, ...] = ()
217
+ ci_files: tuple[str, ...] = ()
218
+
219
+ def to_dict(self) -> dict:
220
+ return {
221
+ "sha": self.sha,
222
+ "verdict": self.verdict.value,
223
+ "claim_kind": self.claim_kind.value,
224
+ "witness": self.witness.value,
225
+ "reason": self.reason,
226
+ "source_files": list(self.source_files),
227
+ "test_files": list(self.test_files),
228
+ "ci_files": list(self.ci_files),
229
+ }
230
+
231
+
232
+ # ---------------------------------------------------------------------------
233
+ # Pure helpers — classify the claim, classify the diff. No I/O.
234
+ # ---------------------------------------------------------------------------
235
+
236
+
237
+ def _norm(path: str) -> str:
238
+ return path.replace("\\", "/").strip()
239
+
240
+
241
+ def _is_source(path: str) -> bool:
242
+ p = _norm(path).lower()
243
+ if p.endswith(_SOURCE_SUFFIXES):
244
+ return True
245
+ if p.endswith(_DOC_SUFFIXES) or p.endswith(_BINARY_OR_DATA_SUFFIXES):
246
+ return False
247
+ # Unknown suffix / extensionless (Makefile, Dockerfile, a bare script): treat
248
+ # as source. Erring toward source keeps EMPTY_CLAIM conservative.
249
+ base = p.rsplit("/", 1)[-1]
250
+ if base in ("makefile", "dockerfile", "rakefile", "gemfile", "procfile"):
251
+ return True
252
+ if "." not in base:
253
+ return True
254
+ return False
255
+
256
+
257
+ def _is_test(path: str) -> bool:
258
+ return bool(_TEST_PATH_RE.search(_norm(path)))
259
+
260
+
261
+ def _is_ci_config(path: str) -> bool:
262
+ p = _norm(path).lower()
263
+ if p.startswith(_CI_WORKFLOW_DIR) and p.endswith((".yml", ".yaml")):
264
+ return True
265
+ return p in _CI_CONFIG_FILES
266
+
267
+
268
+ def _leading_words(subject: str) -> list[str]:
269
+ """The words that can LEAD a claim — at most one per meaningful segment. Pure.
270
+
271
+ A commit subject puts the verb in one of two places:
272
+ * the conventional-commit TYPE — `fix(parser): …` / `fix: …` → verb `fix`;
273
+ * the REMAINDER after a scope/path prefix that has no verb of its own —
274
+ `benchmark/foo: implement X` / `mcp: add Y` → verb `implement`/`add`. DOS's
275
+ own `path/scope: <verb> …` grammar lives here, and the old parser missed it
276
+ (it only read the pre-colon head), so every scoped subject false-ABSTAINed.
277
+
278
+ Returns the FIRST word of the prefix AND the FIRST word of the remainder (when
279
+ the subject is `<scope-shaped-prefix>: <rest>`), else just the subject's first
280
+ word. Returning only the *leading* word of each segment — not a window — is what
281
+ keeps the widening conservative: a verb BURIED later (`kernel: report the fix
282
+ status` → leads are `kernel`, `report`; `fix` is not a lead) does NOT fire. Only
283
+ a verb that genuinely begins the type or the scoped message is seen.
284
+ """
285
+ s = subject.strip().lower()
286
+ leads: list[str] = []
287
+ if ":" in s:
288
+ head, rest = s.split(":", 1)
289
+ if len(head) < 30 and " " not in head.strip():
290
+ hw = re.findall(r"[a-z][a-z']*", head)
291
+ rw = re.findall(r"[a-z][a-z']*", rest)
292
+ if hw:
293
+ leads.append(hw[0])
294
+ if rw:
295
+ leads.append(rw[0])
296
+ return leads
297
+ w = re.findall(r"[a-z][a-z']*", s)
298
+ return w[:1]
299
+
300
+
301
+ def _phrase_window(subject: str, n: int = 8) -> str:
302
+ """The lowercased first-n-words string, for multi-word marker matching
303
+ (`unit test`, `tests pass`) that is not position-locked to a single lead."""
304
+ s = subject.strip().lower()
305
+ # drop a scope-shaped prefix so "tests pass" in the remainder is reached
306
+ if ":" in s:
307
+ head, rest = s.split(":", 1)
308
+ if len(head) < 30 and " " not in head.strip():
309
+ s = rest.strip()
310
+ words = re.findall(r"[a-z][a-z']*", s)[:n]
311
+ return " ".join(words)
312
+
313
+
314
+ def classify_claim(subject: str) -> ClaimKind:
315
+ """What KIND of claim does this subject make? Pure, conservative.
316
+
317
+ Order matters: an explicit no-claim marker wins (so `chore: bump deps` is
318
+ NONE, not CODE_EFFECT on `bump`); then a TEST claim; then a DOC claim; then a
319
+ CODE_EFFECT verb; else NONE.
320
+ """
321
+ s = subject.strip().lower()
322
+ if not s:
323
+ return ClaimKind.NONE
324
+ # Merge/revert/bump/wip → no checkable behavioral claim. Match WHOLE WORDS, never
325
+ # substrings: a substring match made "u**nit** tests" hit the `nit` marker (and
326
+ # would make "con**version**" hit `version`, "life**style**" hit `style`). Build
327
+ # the subject's word set; single-word markers match a word, multi-word markers
328
+ # ("address review") still match as a phrase.
329
+ word_set = set(re.findall(r"[a-z][a-z']*", s))
330
+ single = {m for m in _NOCLAIM_MARKERS if " " not in m}
331
+ multi = [m for m in _NOCLAIM_MARKERS if " " in m]
332
+ if (word_set & single) or any(m in s for m in multi):
333
+ return ClaimKind.NONE
334
+ leads = _leading_words(s) # ≤1 verb-candidate per segment (conservative)
335
+ window = _phrase_window(s) # the remainder, for multi-word marker phrases
336
+ # TEST claim: a "tests pass" phrase, a test marker LEADING a segment, or a
337
+ # test noun in the first few words of the message ("add tests", "write specs",
338
+ # "unit test the engine") — checked BEFORE the code verb so `add tests` is a
339
+ # TEST claim, not a generic CODE_EFFECT (they differ: the test path also gates
340
+ # the delete-the-assertion shape).
341
+ if any(p in s for p in _TEST_PASS_PHRASES):
342
+ return ClaimKind.TEST
343
+ if any(w in _TEST_MARKERS for w in leads):
344
+ return ClaimKind.TEST
345
+ window_words = window.split()
346
+ if any(w in ("test", "tests", "testing", "spec", "specs", "coverage")
347
+ for w in window_words[:3]):
348
+ return ClaimKind.TEST
349
+ # DOC claim: a doc marker LEADING a segment (honestly scoped to docs), OR the
350
+ # scope-prefix itself NAMES a doc target (`CLAUDE.md: add a glossary`,
351
+ # `README: …`, `docs/206: …`). A `<doc-file>: <action verb> …` subject is a
352
+ # documentation edit even though the verb is `add`/`update` — scoping to a doc
353
+ # file is itself the honest "this is docs" signal, so it must not read as an
354
+ # unwitnessed CODE_EFFECT (the e2d5aa9/b2f58fb/f4a36aa real-history finding).
355
+ if any(w in _DOC_MARKERS for w in leads) or _scope_is_doc(s):
356
+ return ClaimKind.DOC
357
+ # CODE_EFFECT: a code verb LEADING the type or the scoped message.
358
+ if any(w in _CODE_VERBS for w in leads):
359
+ return ClaimKind.CODE_EFFECT
360
+ return ClaimKind.NONE
361
+
362
+
363
+ def _scope_is_doc(subject: str) -> bool:
364
+ """True iff a scope-shaped prefix (`<prefix>: …`) names a documentation target —
365
+ a doc suffix (`.md`/`.rst`/…), a known doc file (`CLAUDE.md`/`README`/`LICENSE`),
366
+ or a `docs/` path. Pure."""
367
+ s = subject.strip().lower()
368
+ if ":" not in s:
369
+ return False
370
+ head = s.split(":", 1)[0].strip()
371
+ if " " in head or len(head) >= 40:
372
+ return False
373
+ if head.endswith(_DOC_SUFFIXES):
374
+ return True
375
+ if head.startswith(("docs/", "doc/")):
376
+ return True
377
+ base = head.rsplit("/", 1)[-1]
378
+ return base in ("readme", "claude.md", "claude", "license", "contributing",
379
+ "changelog", "authors", "notice")
380
+
381
+
382
+ _CC_HEAD_RE = re.compile(r"^([a-z][a-z0-9_-]*)(?:\(([^)]*)\))?!?$")
383
+
384
+
385
+ def _scope_is_ci(subject: str) -> bool:
386
+ """True iff the conventional-commit head names CI as its TYPE or SCOPE —
387
+ `ci: …`, `ci(test): …`, `fix(ci): …`. STRUCTURAL token equality on the parsed
388
+ head, never a word-search: "fix the CI cache" in prose does not engage, nor
389
+ does a scope merely containing the letters (`fix(circus):` is not CI). Pure."""
390
+ s = subject.strip().lower()
391
+ if ":" not in s:
392
+ return False
393
+ m = _CC_HEAD_RE.match(s.split(":", 1)[0].strip())
394
+ if not m:
395
+ return False
396
+ return m.group(1) == "ci" or m.group(2) == "ci"
397
+
398
+
399
+ def _is_pure_move_or_rename(subject: str) -> bool:
400
+ """A rename/move legitimately touches many files with little net line change —
401
+ do not fire EMPTY_CLAIM/UNWITNESSED on it from line deltas alone."""
402
+ words = _leading_words(subject)
403
+ return any(w in ("rename", "move", "moved", "renamed") for w in words)
404
+
405
+
406
+ def classify(claim: CommitClaim, diff: DiffFacts,
407
+ policy: ClaimPolicy = DEFAULT_POLICY) -> ClaimVerdict:
408
+ """THE verdict: does the commit's claim match its diff? Pure.
409
+
410
+ Author-neutral, plan-free. Fires `CLAIM_UNWITNESSED` only where a concrete
411
+ claim and a contradicting diff coexist; `ABSTAIN`s on everything else (most
412
+ commits). The witness rung (`diff-witnessed`/`subject-only`) is reported on
413
+ every commit that makes a code/test claim, so even an `OK` carries its rung.
414
+ """
415
+ kind = classify_claim(claim.subject)
416
+ source_files = tuple(f for f in diff.files if _is_source(f))
417
+ test_files = tuple(f for f in diff.files if _is_test(f))
418
+ ci_files = tuple(f for f in diff.files if _is_ci_config(f))
419
+ # A ci-scoped claim is witnessed by the CI config itself — the scope names
420
+ # where the claimed effect lives, so the workflows diff is corroboration, not
421
+ # contradiction. CONJUNCTIVE on purpose: the shaped claim alone (a ci scope
422
+ # over a README diff) or the path alone (a bare `fix:` over a workflows diff)
423
+ # changes nothing, so this can only suppress an over-fire, never add one.
424
+ ci_witnessed = bool(ci_files) and _scope_is_ci(claim.subject)
425
+
426
+ if kind is ClaimKind.NONE:
427
+ return ClaimVerdict(
428
+ sha=claim.sha, verdict=Verdict.ABSTAIN, claim_kind=kind,
429
+ witness=Witness.ABSTAIN,
430
+ reason="subject makes no checkable code/test claim",
431
+ source_files=source_files, test_files=test_files, ci_files=ci_files)
432
+
433
+ if kind is ClaimKind.DOC:
434
+ # A doc claim is witnessed by ANY touched file (docs count); it never
435
+ # over-claims code, so it is OK as long as it is not empty.
436
+ if diff.is_empty:
437
+ return ClaimVerdict(
438
+ sha=claim.sha, verdict=Verdict.CLAIM_UNWITNESSED, claim_kind=kind,
439
+ witness=Witness.SUBJECT_ONLY,
440
+ reason="doc claim but the commit is empty (touched nothing)",
441
+ source_files=source_files, test_files=test_files, ci_files=ci_files)
442
+ return ClaimVerdict(
443
+ sha=claim.sha, verdict=Verdict.OK, claim_kind=kind,
444
+ witness=Witness.DIFF_WITNESSED,
445
+ reason="doc claim, the commit touches files (doc scope, no code over-claim)",
446
+ source_files=source_files, test_files=test_files, ci_files=ci_files)
447
+
448
+ if kind is ClaimKind.TEST:
449
+ # A test claim must touch a test file. Net-deleting test lines while
450
+ # claiming "tests pass/green" is the delete-the-assertion shape.
451
+ net_test_delta_known = (diff.test_lines_added >= 0
452
+ and diff.test_lines_removed >= 0)
453
+ net_deleted = (net_test_delta_known
454
+ and diff.test_lines_removed > diff.test_lines_added)
455
+ if not test_files:
456
+ return ClaimVerdict(
457
+ sha=claim.sha, verdict=Verdict.CLAIM_UNWITNESSED, claim_kind=kind,
458
+ witness=Witness.SUBJECT_ONLY,
459
+ reason="claims tests but the diff touches no test file",
460
+ source_files=source_files, test_files=test_files, ci_files=ci_files)
461
+ if net_deleted and not _is_pure_move_or_rename(claim.subject):
462
+ return ClaimVerdict(
463
+ sha=claim.sha, verdict=Verdict.CLAIM_UNWITNESSED, claim_kind=kind,
464
+ witness=Witness.SUBJECT_ONLY,
465
+ reason=(f"claims tests but net-DELETES test lines "
466
+ f"(+{diff.test_lines_added}/-{diff.test_lines_removed}) "
467
+ "— the delete-the-assertion shape"),
468
+ source_files=source_files, test_files=test_files, ci_files=ci_files)
469
+ return ClaimVerdict(
470
+ sha=claim.sha, verdict=Verdict.OK, claim_kind=kind,
471
+ witness=Witness.DIFF_WITNESSED,
472
+ reason="test claim witnessed by a touched test file",
473
+ source_files=source_files, test_files=test_files, ci_files=ci_files)
474
+
475
+ # kind is CODE_EFFECT
476
+ if source_files:
477
+ return ClaimVerdict(
478
+ sha=claim.sha, verdict=Verdict.OK, claim_kind=kind,
479
+ witness=Witness.DIFF_WITNESSED,
480
+ reason="code-effect claim witnessed by a touched source file",
481
+ source_files=source_files, test_files=test_files, ci_files=ci_files)
482
+ if ci_witnessed:
483
+ return ClaimVerdict(
484
+ sha=claim.sha, verdict=Verdict.OK, claim_kind=kind,
485
+ witness=Witness.DIFF_WITNESSED,
486
+ reason=("ci-scoped claim witnessed by the CI config it names "
487
+ "(the scope is where the claimed effect lives)"),
488
+ source_files=source_files, test_files=test_files, ci_files=ci_files)
489
+ # No source touched. Empty, or docs/config/binary only → the claim rests on the
490
+ # subject text alone. (A pure rename can land here with renames git records as
491
+ # add+delete pairs — but those ARE source files, so source_files is non-empty;
492
+ # the move guard is for the line-delta path, not this presence check.)
493
+ if diff.is_empty:
494
+ why = "code-effect claim but the commit is EMPTY (touched no files)"
495
+ elif policy.docs_satisfy_code_claim:
496
+ return ClaimVerdict(
497
+ sha=claim.sha, verdict=Verdict.OK, claim_kind=kind,
498
+ witness=Witness.DIFF_WITNESSED,
499
+ reason="code claim; docs_satisfy_code_claim policy accepts doc-only diff",
500
+ source_files=source_files, test_files=test_files, ci_files=ci_files)
501
+ else:
502
+ touched = ", ".join(diff.files[:4]) or "(nothing)"
503
+ why = (f"code-effect claim but the diff touches no SOURCE file "
504
+ f"(only: {touched}) — the claim rests on the subject text")
505
+ return ClaimVerdict(
506
+ sha=claim.sha, verdict=Verdict.CLAIM_UNWITNESSED, claim_kind=kind,
507
+ witness=Witness.SUBJECT_ONLY, reason=why,
508
+ source_files=source_files, test_files=test_files, ci_files=ci_files)
509
+
510
+
511
+ # ---------------------------------------------------------------------------
512
+ # Sweep summary — the "how honest are this repo's commit messages?" rate. Pure.
513
+ # ---------------------------------------------------------------------------
514
+
515
+
516
+ def sweep_summary(verdicts: list[ClaimVerdict]) -> dict:
517
+ """Fold a list of `ClaimVerdict`s into the drift RATE + a by-claim-kind grid.
518
+
519
+ The headline `drift_rate` is `unwitnessed / checkable`, where *checkable* =
520
+ commits that made a concrete code/test/doc claim (everything that did NOT
521
+ ABSTAIN). Abstains are excluded from the denominator on purpose: a `wip`/`merge`
522
+ commit makes no claim to be honest or dishonest about, so counting it would dilute
523
+ the rate toward zero and hide real drift. We report both denominators so the
524
+ reader sees how much of the history was checkable at all.
525
+
526
+ This is a RATE over real history, not a per-commit gate — the docs/179 caution
527
+ applies: it re-describes the corpus it is given (it mints no new label), so it is
528
+ a description of THIS repo's commit hygiene, not a generalization.
529
+ """
530
+ total = len(verdicts)
531
+ unwitnessed = [v for v in verdicts if v.verdict is Verdict.CLAIM_UNWITNESSED]
532
+ witnessed = [v for v in verdicts if v.verdict is Verdict.OK]
533
+ abstained = [v for v in verdicts if v.verdict is Verdict.ABSTAIN]
534
+ checkable = len(unwitnessed) + len(witnessed)
535
+
536
+ by_kind: dict[str, dict[str, int]] = {}
537
+ for v in verdicts:
538
+ k = v.claim_kind.value
539
+ row = by_kind.setdefault(k, {"unwitnessed": 0, "witnessed": 0, "abstain": 0})
540
+ if v.verdict is Verdict.CLAIM_UNWITNESSED:
541
+ row["unwitnessed"] += 1
542
+ elif v.verdict is Verdict.OK:
543
+ row["witnessed"] += 1
544
+ else:
545
+ row["abstain"] += 1
546
+
547
+ return {
548
+ "commits": total,
549
+ "checkable": checkable, # made a concrete claim (the denominator)
550
+ "abstained": len(abstained), # no checkable claim
551
+ "witnessed": len(witnessed),
552
+ "unwitnessed": len(unwitnessed),
553
+ "drift_rate": (len(unwitnessed) / checkable) if checkable else 0.0,
554
+ "by_kind": by_kind,
555
+ # the actual offenders, so the rate is inspectable, never just a number
556
+ "unwitnessed_shas": [v.sha for v in unwitnessed],
557
+ }
558
+
559
+
560
+ # ---------------------------------------------------------------------------
561
+ # Boundary I/O — read a commit's subject + diff facts from git. NOT pure.
562
+ # ---------------------------------------------------------------------------
563
+ #
564
+ # The git reads happen HERE (the `git_delta`/`liveness` discipline); `classify`
565
+ # above never shells out. `root` is passed EXPLICITLY (never the process-global
566
+ # config) so a long-lived caller — the MCP server, a CI runner — gets the right
567
+ # tree. Every failure degrades to a safe value (an empty/abstaining read), never a
568
+ # crash: a non-git dir, a missing binary, a bad ref, a timeout.
569
+
570
+
571
+ def _git(root: Path | str, *args: str) -> tuple[int, str]:
572
+ try:
573
+ r = subprocess.run(["git", "-C", str(root), *args],
574
+ capture_output=True, text=True, check=False,
575
+ timeout=_GIT_TIMEOUT_S)
576
+ except (OSError, subprocess.SubprocessError):
577
+ return 1, ""
578
+ return r.returncode, r.stdout
579
+
580
+
581
+ def read_commit(ref: str, *, root: Path | str) -> tuple[CommitClaim, DiffFacts] | None:
582
+ """Read one commit's subject + diff facts from git at ``ref`` over ``root``.
583
+
584
+ Returns ``(CommitClaim, DiffFacts)`` ready for `classify`, or ``None`` if the
585
+ ref can't be read (non-git dir, bad ref, git missing). Uses ``--numstat`` over
586
+ the first parent so a merge commit reports its own delta; binary files report
587
+ ``-``/``-`` numstat and are counted as touched files but contribute no line
588
+ delta.
589
+ """
590
+ rc, sha = _git(root, "rev-parse", "--short", ref)
591
+ if rc != 0 or not sha.strip():
592
+ return None
593
+ sha = sha.strip()
594
+ rc, subject = _git(root, "log", "-1", "--pretty=format:%s", ref)
595
+ if rc != 0:
596
+ return None
597
+ subject = subject.strip()
598
+
599
+ # numstat: "<added>\t<removed>\t<path>" per file; binary → "-\t-\t<path>".
600
+ rc, numstat = _git(root, "show", "--numstat", "--format=", "--no-renames", ref)
601
+ files: list[str] = []
602
+ test_add = test_rem = 0
603
+ have_test_delta = False
604
+ if rc == 0:
605
+ for line in numstat.splitlines():
606
+ line = line.rstrip("\n")
607
+ if not line.strip():
608
+ continue
609
+ parts = line.split("\t")
610
+ if len(parts) != 3:
611
+ continue
612
+ added_s, removed_s, path = parts
613
+ path = path.strip()
614
+ if not path:
615
+ continue
616
+ files.append(path)
617
+ if _is_test(path) and added_s != "-" and removed_s != "-":
618
+ try:
619
+ test_add += int(added_s)
620
+ test_rem += int(removed_s)
621
+ have_test_delta = True
622
+ except ValueError:
623
+ pass
624
+
625
+ diff = DiffFacts(
626
+ files=tuple(files),
627
+ is_empty=(len(files) == 0),
628
+ test_lines_added=(test_add if have_test_delta else -1),
629
+ test_lines_removed=(test_rem if have_test_delta else -1),
630
+ )
631
+ return CommitClaim(sha=sha, subject=subject), diff
632
+
633
+
634
+ def audit_commit(ref: str = "HEAD", *, root: Path | str = ".",
635
+ policy: ClaimPolicy = DEFAULT_POLICY) -> ClaimVerdict | None:
636
+ """Read commit ``ref`` and return its `ClaimVerdict`, or ``None`` if unreadable.
637
+
638
+ The one-call convenience the CLI / a pre-commit hook / the MCP tool use.
639
+ """
640
+ got = read_commit(ref, root=root)
641
+ if got is None:
642
+ return None
643
+ claim, diff = got
644
+ return classify(claim, diff, policy)
645
+
646
+
647
+ def audit_range(rev_range: str, *, root: Path | str = ".",
648
+ policy: ClaimPolicy = DEFAULT_POLICY,
649
+ limit: int = 500) -> list[ClaimVerdict]:
650
+ """Audit every commit in a `git log` range (e.g. ``origin/main..HEAD``).
651
+
652
+ Returns newest-first `ClaimVerdict`s, capped at ``limit`` so a huge range
653
+ can't hang an audit. Empty list on any read failure (the fail-safe floor).
654
+ """
655
+ rc, out = _git(root, "log", f"-{int(limit)}", "--pretty=format:%H", rev_range)
656
+ if rc != 0:
657
+ return []
658
+ verdicts: list[ClaimVerdict] = []
659
+ for sha in out.splitlines():
660
+ sha = sha.strip()
661
+ if not sha:
662
+ continue
663
+ v = audit_commit(sha, root=root, policy=policy)
664
+ if v is not None:
665
+ verdicts.append(v)
666
+ return verdicts