froid-loop 0.11.1__py3-none-any.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 (116) hide show
  1. froid_loop/__init__.py +11 -0
  2. froid_loop/__main__.py +12 -0
  3. froid_loop/adapters/__init__.py +3 -0
  4. froid_loop/adapters/base.py +254 -0
  5. froid_loop/adapters/entrypoints.py +63 -0
  6. froid_loop/adapters/env_fault.py +290 -0
  7. froid_loop/adapters/generic.py +2013 -0
  8. froid_loop/adapters/mock.py +49 -0
  9. froid_loop/adapters/multiplexer.py +914 -0
  10. froid_loop/adapters/opencode_http.py +1687 -0
  11. froid_loop/adapters/profile.py +650 -0
  12. froid_loop/adapters/psmux_backend.py +1428 -0
  13. froid_loop/adapters/registry.py +322 -0
  14. froid_loop/adapters/tmux_backend.py +35 -0
  15. froid_loop/adapters/tmux_base.py +630 -0
  16. froid_loop/checks.py +187 -0
  17. froid_loop/cli.py +5041 -0
  18. froid_loop/data/__init__.py +0 -0
  19. froid_loop/data/froid_loop_hook.py +228 -0
  20. froid_loop/data/froid_loop_probe_hook.py +88 -0
  21. froid_loop/data/plugins/example/plugin.toml +21 -0
  22. froid_loop/data/plugins/tea/plugin.toml +184 -0
  23. froid_loop/data/plugins/tea/tea_plugin.py +258 -0
  24. froid_loop/data/plugins/unity/plugin.toml +140 -0
  25. froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef +16 -0
  26. froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef.meta +7 -0
  27. froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs +221 -0
  28. froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs.meta +11 -0
  29. froid_loop/data/plugins/unity/unity_assets/_folders/Editor.meta +8 -0
  30. froid_loop/data/plugins/unity/unity_assets/_folders/FroidLoop.meta +8 -0
  31. froid_loop/data/plugins/unity/unity_cleanup.py +125 -0
  32. froid_loop/data/plugins/unity/unity_dialog_probe.py +239 -0
  33. froid_loop/data/plugins/unity/unity_facts.md +17 -0
  34. froid_loop/data/plugins/unity/unity_plugin.py +415 -0
  35. froid_loop/data/plugins/unity/unity_quiesce.py +234 -0
  36. froid_loop/data/plugins/unity/unity_ready.py +230 -0
  37. froid_loop/data/plugins/unity/unity_seed_assets.py +298 -0
  38. froid_loop/data/plugins/unity/unity_setup.py +551 -0
  39. froid_loop/data/plugins/unity/unity_teardown.py +362 -0
  40. froid_loop/data/profiles/antigravity.toml +52 -0
  41. froid_loop/data/profiles/claude.toml +85 -0
  42. froid_loop/data/profiles/codex.toml +22 -0
  43. froid_loop/data/profiles/copilot.toml +52 -0
  44. froid_loop/data/profiles/gemini.toml +26 -0
  45. froid_loop/data/profiles/opencode.toml +54 -0
  46. froid_loop/data/settings/core.toml +458 -0
  47. froid_loop/data/skills/README.md +93 -0
  48. froid_loop/data/skills/froid-loop-resolve/SKILL.md +288 -0
  49. froid_loop/data/skills/froid-loop-setup/SKILL.md +161 -0
  50. froid_loop/data/skills/froid-loop-setup/assets/module-help.csv +3 -0
  51. froid_loop/data/skills/froid-loop-setup/assets/module.yaml +19 -0
  52. froid_loop/data/skills/froid-loop-sweep/SKILL.md +100 -0
  53. froid_loop/data/skills/froid-loop-sweep/automation-mode.md +127 -0
  54. froid_loop/data/skills/froid-loop-sweep/deferred-work-format.md +302 -0
  55. froid_loop/data/skills/froid-loop-sweep/migration-mode.md +86 -0
  56. froid_loop/decisions.py +202 -0
  57. froid_loop/deferredwork.py +2282 -0
  58. froid_loop/devcontract.py +892 -0
  59. froid_loop/diagnostics.py +1104 -0
  60. froid_loop/documents.py +532 -0
  61. froid_loop/engine.py +7732 -0
  62. froid_loop/envvars.py +111 -0
  63. froid_loop/escalation.py +225 -0
  64. froid_loop/events.py +266 -0
  65. froid_loop/fences.py +103 -0
  66. froid_loop/froidconfig.py +226 -0
  67. froid_loop/frontmatter.py +526 -0
  68. froid_loop/gates.py +133 -0
  69. froid_loop/install.py +2936 -0
  70. froid_loop/journal.py +178 -0
  71. froid_loop/machine.py +148 -0
  72. froid_loop/model.py +898 -0
  73. froid_loop/operatoractions.py +474 -0
  74. froid_loop/platform_util.py +1490 -0
  75. froid_loop/plugins/__init__.py +64 -0
  76. froid_loop/plugins/bus.py +259 -0
  77. froid_loop/plugins/context.py +319 -0
  78. froid_loop/plugins/loader.py +145 -0
  79. froid_loop/plugins/manifest.py +279 -0
  80. froid_loop/plugins/model.py +296 -0
  81. froid_loop/plugins/registry.py +245 -0
  82. froid_loop/plugins/trust.py +75 -0
  83. froid_loop/policy.py +1569 -0
  84. froid_loop/probe.py +1044 -0
  85. froid_loop/process_host.py +408 -0
  86. froid_loop/recovery_flow.py +1561 -0
  87. froid_loop/resolve.py +283 -0
  88. froid_loop/runs.py +4715 -0
  89. froid_loop/runsetup.py +1293 -0
  90. froid_loop/sanitize.py +593 -0
  91. froid_loop/settings_schema.py +276 -0
  92. froid_loop/signals.py +160 -0
  93. froid_loop/sprintstatus.py +609 -0
  94. froid_loop/statemachine.py +57 -0
  95. froid_loop/stories.py +615 -0
  96. froid_loop/stories_engine.py +796 -0
  97. froid_loop/sweep.py +1892 -0
  98. froid_loop/tokens.py +196 -0
  99. froid_loop/tui/__init__.py +11 -0
  100. froid_loop/tui/app.py +1584 -0
  101. froid_loop/tui/data.py +840 -0
  102. froid_loop/tui/launch.py +1003 -0
  103. froid_loop/tui/screens/__init__.py +1 -0
  104. froid_loop/tui/screens/dashboard.py +1071 -0
  105. froid_loop/tui/screens/modals.py +943 -0
  106. froid_loop/tui/screens/settings_screen.py +477 -0
  107. froid_loop/tui/settings.py +135 -0
  108. froid_loop/tui/widgets.py +981 -0
  109. froid_loop/verify.py +4545 -0
  110. froid_loop/workspace.py +320 -0
  111. froid_loop/worktree_flow.py +2301 -0
  112. froid_loop-0.11.1.dist-info/METADATA +728 -0
  113. froid_loop-0.11.1.dist-info/RECORD +116 -0
  114. froid_loop-0.11.1.dist-info/WHEEL +4 -0
  115. froid_loop-0.11.1.dist-info/entry_points.txt +2 -0
  116. froid_loop-0.11.1.dist-info/licenses/LICENSE +30 -0
@@ -0,0 +1,1104 @@
1
+ """`froid-loop diagnose`: a sanitized diagnostic dump of a run/sweep.
2
+
3
+ When a run or sweep misbehaves, a maintainer needs to see the *shape* of what
4
+ happened — phase transitions, escalations, token usage, which adapter/model ran,
5
+ how sessions ended — but must NEVER receive the user's proprietary code, spec or
6
+ story content, prompts, transcripts, file paths, or any PII. This command derives
7
+ that diagnostic shape from a run dir and routes every content-bearing value
8
+ through the audited :mod:`froid_loop.sanitize` chokepoint before rendering.
9
+
10
+ It mirrors :mod:`froid_loop.probe`: typed findings → collectors → ``render_markdown``
11
+ / ``render_json``. On the CLI the default is the markdown report and ``--json``
12
+ selects the pure JSON document instead (the :mod:`froid_loop.machine` contract —
13
+ one object on stdout, nothing else); ``--out FILE`` redirects whichever of the
14
+ two was selected to a file. The safety model is fail-closed by construction:
15
+ structure (counts, enums, ints, durations) is derived
16
+ directly; every value that could carry content is dropped, reduced to a boolean,
17
+ **pseudonymized** (story keys/branches/SHAs are identifier-shaped and would
18
+ otherwise survive verbatim), or scrubbed. Unknown/future fields default to a
19
+ ``scrub_json`` pass, never raw. As a final backstop the rendered bytes are run
20
+ through :func:`sanitize.guard` — the fail-closed egress self-check shared with
21
+ ``probe-adapter`` since #199. That backstop re-scans the rendered bytes for known
22
+ *shapes* — email, URL credentials, credential-shaped tokens, the absolute-home
23
+ spellings in both separator forms (#512 added the backslash WSL-UNC one), and *this
24
+ process's* username — and refuses to emit on a hit; it is not a proof of absence, since
25
+ a home spelling it does not know, or a username that is not this process's (the Linux
26
+ account behind a WSL UNC path), passes it untouched. Per-field routing is the primary
27
+ defense and the guard is defense in depth. A stray pseudonymized original (a
28
+ per-field routing gap — the value is in the legend, so its safe alias is known)
29
+ is **repaired** by substituting the alias, re-verified, and disclosed in the
30
+ dump itself — a "Backstop repairs" section in the markdown report, an optional
31
+ top-level ``backstop_repairs`` label→count key in the JSON document — so the gap
32
+ still surfaces as a reportable bug even when only one of the two was rendered.
33
+ A genuine PII/secret/path/username hit, or a repair that does not converge,
34
+ raises so the command refuses to write.
35
+
36
+ The guiding assumption: the dump will be posted publicly.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import json
42
+ import os
43
+ import platform
44
+ import re
45
+ import stat
46
+ import sys
47
+ from collections import Counter
48
+ from dataclasses import dataclass, field
49
+ from datetime import datetime, timezone
50
+ from pathlib import Path
51
+ from typing import Any
52
+
53
+ from . import __version__, sanitize
54
+ from .journal import VERIFY_DIR, Journal, load_state
55
+ from .model import RunState, StoryTask
56
+ from .platform_util import walk_files_unlinked
57
+
58
+ # The guard machinery (fail-closed egress self-check + alias-substitution
59
+ # repair) moved to sanitize.py so probe-adapter shares the single audited
60
+ # implementation (#199). LeakDetected stays importable from here — cli.py and
61
+ # external callers resolve `diagnostics.LeakDetected` — so it carries a noqa:
62
+ # without it ruff F401 autofixes the re-export away and the except clause in
63
+ # cmd_diagnose breaks.
64
+ from .sanitize import LeakDetected # noqa: F401 — re-export
65
+
66
+ # Deliberately NOT bumped when `--json` stopped being a fenced ```json block
67
+ # inside the markdown report and became a pure document (machine.py contract):
68
+ # this number versions the *document*, and the payload did not change. Bumping it
69
+ # would falsely tell a consumer pinned to v1 that the fields it reads are gone,
70
+ # while a consumer actually broken by the repackaging finds out immediately —
71
+ # the fence is gone and json.loads fails. Bump only on a payload break.
72
+ SCHEMA_VERSION = 1
73
+ DEFAULT_JOURNAL_CAP = 200
74
+
75
+ # Subdirectories whose mere existence/size is diagnostic but whose CONTENTS are
76
+ # off-limits (raw tmux panes = code, prompts, feedback prose, patches, full
77
+ # worktree checkouts). We stat them; we never read into output.
78
+ #
79
+ # All are run-dir-relative EXCEPT "events", which since #494 lives out of the
80
+ # project tree at the user state root — see `_category_roots`.
81
+ #
82
+ # VERIFY_DIR belongs here for the reason the category exists: retained verifier
83
+ # stdout/stderr is a build's own output — off-limits to read, but its SIZE is
84
+ # exactly the diagnostic. `[verify] stream_capture_kb` defaults to 256 KiB per
85
+ # stream, so a run retains up to 512 KiB per command per attempt with no GC
86
+ # behind it yet, which can make this store one of the larger things in a run
87
+ # dir. Omitting it left `diagnose` unable to show a retention or disk-usage
88
+ # problem it is the natural place to notice. Imported, not re-spelled, so the
89
+ # reporter cannot drift from the writer that creates the directory.
90
+ _FILE_CATEGORIES = (
91
+ "logs",
92
+ "tasks",
93
+ "feedback",
94
+ "bundles",
95
+ "failed",
96
+ "worktrees",
97
+ "events",
98
+ VERIFY_DIR,
99
+ )
100
+ _EVENTS_CATEGORY = "events"
101
+
102
+ # Journal fields that name a proprietary identifier — pseudonymized, not dropped,
103
+ # so events stay correlatable. Maps field name -> alias namespace.
104
+ _JOURNAL_ALIAS_FIELDS = {
105
+ "story_key": "story",
106
+ "paused_story_key": "story",
107
+ "log_task": "story",
108
+ "task_id": "story",
109
+ "bundle": "bundle",
110
+ "branch": "branch",
111
+ "target_branch": "branch",
112
+ "commit": "commit",
113
+ "baseline": "commit",
114
+ # The baseline a re-arm's re-stamp replaced (`rearm-baseline-restamped`). Its
115
+ # OWN entry rather than the `baseline` one beside it, because both shas ride a
116
+ # single record: alias one and leave the other and a dump pseudonymizes half a
117
+ # comparison, which is worse than either doing both or doing neither.
118
+ "overwritten": "commit",
119
+ # A spec name IS the customer's feature name — `Pseudonymizer`'s own docstring
120
+ # has always listed "spec filenames" among what it exists to alias, so the
121
+ # omission here was a routing gap, not a policy. A producer that journals a
122
+ # bare basename passes a value `looks_like_identifier` waves through verbatim
123
+ # on the scrub_json fallback — and the egress backstop cannot rescue it,
124
+ # because it only repairs values already in the legend, so it happens to
125
+ # catch `1.2-Acme….md` (the story key is in there) and misses
126
+ # `AcmeVaultRotation.md` entirely. Its OWN namespace, not "story": the epic
127
+ # lookup below is keyed on ns == "story", so a filename aliased there would
128
+ # render as an epic-less `story-<hex>` — indistinguishable from a story key
129
+ # whose epic could not be resolved. See `_JOURNAL_BASENAME_NAMESPACES` for why
130
+ # the value is normalized first: the producers do NOT agree on a bare basename.
131
+ "spec": "spec",
132
+ # The same value also arrives under a second field NAME. `runs.rearm_escalation`
133
+ # is the only journal producer of `spec_file`, across FOUR kinds —
134
+ # `rearm-spec-write-unreachable`, `rearm-spec-flip-skipped`,
135
+ # `rearm-baseline-restamp-skipped` and `rearm-baseline-restamped`. Routing is by
136
+ # field NAME, not by kind, so the list is documentation rather than a gate — but an
137
+ # enumeration that undercounts is how the next reader concludes a kind is unrouted.
138
+ # `engine._park_awaiting_operator` passes
139
+ # `spec_file=` to `operatoractions.record_park`, which is a record file, not the
140
+ # journal. So the divergence is BETWEEN FIELDS, not between two producers of this
141
+ # one — but BOTH fields are mixed-shape, and neither is the reliable one:
142
+ # Both fields now journal an absolute path wherever they carry one: `spec_file`
143
+ # through `str(task_spec_path(...))` on all four kinds, and `spec` through
144
+ # `engine._operator_spec_path` (which anchors `checkpoint-pause` the same
145
+ # way) alongside engine's already-absolute reconcile and marker-repair kinds. Same
146
+ # value, same namespace. Do NOT read that convergence as "both fields are
147
+ # normalized, so the basename step is dead" — it is not a guarantee this module
148
+ # holds. `_operator_spec_path` still answers a bare STORY KEY for a spec-less task,
149
+ # nothing stops a future producer from journaling a raw `task.spec_file`, and
150
+ # `_JOURNAL_BASENAME_NAMESPACES` keys on the NAMESPACE rather than the field
151
+ # precisely so every spelling reduces to one alias whichever shape it carries.
152
+ "spec_file": "spec",
153
+ }
154
+ # Kind-scoped routing, consulted BEFORE the by-name table above and losing to
155
+ # `_JOURNAL_DROP_FIELDS`, which is stricter than any alias.
156
+ #
157
+ # It exists for ONE field, and the by-name rule genuinely cannot express it: `target`
158
+ # carries the target BRANCH on the three merge kinds below and a sprint STATUS on the
159
+ # `board-advance-*` family (`board-advance-carried`, `-carry-failed`,
160
+ # `-carry-foreign-dirt`, `-carry-uncommitted`). Aliasing it by NAME would pseudonymize
161
+ # statuses as branches, turning a legible `"target": "done"` into `branch-3f2a` and
162
+ # destroying the field a maintainer reads those kinds for; leaving it unrouted — what
163
+ # happened until now — ships an identifier-shaped branch name VERBATIM. A normal run
164
+ # journals the same string as `branch` first (`ensure_target_branch`), so the egress
165
+ # backstop repairs it and discloses a `backstop_repairs` gap, but that is a defense in
166
+ # depth that only works because the value is already in the legend: a journal truncated
167
+ # past that event, or a `unit-merged` read out of a bundle on its own, has nothing to
168
+ # repair from.
169
+ #
170
+ # Deliberately NOT fixed by renaming the producers to `target_branch`, which is the
171
+ # obvious move and is unsafe: `engine._replay_unlatched_ledger_carries` correlates
172
+ # `unit-merge-started` against `unit-merged` on a tuple that INCLUDES this field, and it
173
+ # reads a journal written by an earlier process — and possibly an earlier version. A
174
+ # rename would make a run started before the change and resumed after it fail to
175
+ # correlate, silently skipping the carry replay so a resumed sweep re-triages work that
176
+ # already landed. The scrub is what is wrong, so the scrub is where the fix belongs.
177
+ #
178
+ # A closed set, not a growing one: any NEW producer should pick a name the by-name table
179
+ # already routes (`branch`, or `target_branch` — see `runs.rearm_escalation`) rather than
180
+ # add a row here.
181
+ _JOURNAL_KIND_ALIAS_FIELDS: dict[str, dict[str, str]] = {
182
+ "unit-merge-started": {"target": "branch"},
183
+ "unit-merged": {"target": "branch"},
184
+ "resume-unit-merge": {"target": "branch"},
185
+ }
186
+ # Namespaces whose journalled value arrives in more than one shape and must be
187
+ # reduced to its basename before it is aliased. `spec` is one: engine.py's
188
+ # reconcile and marker-repair kinds journal `str(spec_path)` (absolute —
189
+ # `verify.resolve_spec_path` returns an absolute path). Every producer is absolute
190
+ # TODAY — `checkpoint-pause` moved to `_operator_spec_path` — but the reduction is
191
+ # keyed on the NAMESPACE rather than on any producer precisely so that stays a
192
+ # property this module does not have to trust: `_operator_spec_path` still answers a
193
+ # bare STORY KEY for a spec-less task, and nothing stops a future producer journaling
194
+ # a raw `task.spec_file`. Aliasing
195
+ # the raw string would give ONE spec TWO aliases in a single dump, defeating the
196
+ # correlation these fields are aliased rather than dropped to preserve, and would
197
+ # park an absolute home path in the local `--legend` file (before `spec` was
198
+ # routed such a value died at `scrub_json` and never entered the map at all).
199
+ #
200
+ # Split on BOTH separators rather than using `PurePath(...).name`: a journal
201
+ # written on Windows is routinely diagnosed on POSIX, where `PurePath` treats a
202
+ # backslash as an ordinary character and would keep the whole path. The cost is
203
+ # that a POSIX filename containing a literal backslash aliases on its tail —
204
+ # a pathological name, and the consequence is a shorter legend entry, never a
205
+ # leak, since the alias is still stable and the raw value still never ships.
206
+ _JOURNAL_BASENAME_NAMESPACES = frozenset({"spec"})
207
+ _PATH_SEP_RE = re.compile(r"[\\/]")
208
+ # Journal fields that carry free text (LLM/merge prose, prompts, errors). Never
209
+ # emitted — replaced with a boolean presence marker so a maintainer still learns
210
+ # the field was set without seeing it.
211
+ #
212
+ # The `verify-command-result` group at the end is the same convention applied to
213
+ # the verifier records: `command` is operator-authored shell (`[verify] commands`),
214
+ # `output_tail` is a build's own output, `capture_error` is an OSError string
215
+ # carrying a path, and the two pointers embed the story key. Routing them here
216
+ # rather than leaving them to `scrub_json` is deliberate — that fallback fails
217
+ # closed only by accident of shape, since `_IDENTIFIER_RE` forbids `/` and spaces
218
+ # and so collapses paths, argv-ish commands and multi-line tails, while a
219
+ # one-word `command` (`make`) or a one-word tail (`FAILED`) is identifier-shaped
220
+ # and would ship verbatim. The presence boolean is also strictly more useful for
221
+ # the pointers: it separates "a stream was retained" from "the cap is 0 or the
222
+ # write failed", which a redacted string cannot.
223
+ _JOURNAL_DROP_FIELDS = frozenset(
224
+ {
225
+ "prompt",
226
+ "reason",
227
+ "error",
228
+ "detail",
229
+ "suggestion",
230
+ "message",
231
+ "note",
232
+ "blocker",
233
+ "commit_message",
234
+ "was_paused",
235
+ "command",
236
+ "output_tail",
237
+ "capture_error",
238
+ "stdout_path",
239
+ "stderr_path",
240
+ # An absolute host path naming the run's code tree
241
+ # (`rearm-baseline-advance-failed`). Dropped rather than aliased: unlike a
242
+ # spec filename it correlates nothing across events — one run has one code
243
+ # root — while carrying the customer's directory layout. Dropped rather
244
+ # than left to the `scrub_json` fallback: that fallback happens to redact an
245
+ # absolute path (`_IDENTIFIER_RE` forbids `/`, `\` and `:`, so any real root
246
+ # collapses to `<redacted:str>`), but it fails closed only by accident of
247
+ # shape — a root that did parse as a bare identifier would pass verbatim, and
248
+ # nothing here should depend on a path never looking like one. The `error=`
249
+ # field on the very same record is dropped too, but under this set's
250
+ # free-text rule above (it is a `GitError` string quoting git's own stderr),
251
+ # not this identifier-shape argument — same set, different rationale.
252
+ "repo",
253
+ # An absolute host path naming the folder a sentinel's upstream correction has
254
+ # to land in (`rearm-upstream-write-unreachable`). Dropped for `repo`'s reason,
255
+ # not aliased for `spec_file`'s: it is a DIRECTORY, journalled by one kind, and
256
+ # one run has one spec folder — so it correlates nothing across events, while a
257
+ # `spec` alias would additionally be wrong, since that namespace reduces to a
258
+ # basename and every run we author would collapse onto the same `stories`-ish
259
+ # tail. `scrub_json` already redacts any real path (`_IDENTIFIER_RE` forbids
260
+ # `/`, `\` and `:`), so — exactly as for `repo` — only an assertion on the
261
+ # field's ABSENCE can grade this, and the canary sweep cannot.
262
+ "stories_root",
263
+ }
264
+ )
265
+ # Journal fields whose value is a LIST of story keys (sprint unknown-keys).
266
+ _JOURNAL_KEYLIST_FIELDS = frozenset({"keys", "dw_ids"})
267
+
268
+ # Policy keys whose values can carry secrets/paths/free text. Dropped or reduced
269
+ # rather than scrubbed, since a single-token API key or repo name could be
270
+ # identifier-shaped and survive a plain scrub.
271
+ _POLICY_COUNT_KEYS = frozenset({"extra_args", "env", "worktree_seed"})
272
+ _POLICY_BOOL_KEYS = frozenset({"commit_message_template"})
273
+ _POLICY_KEYSET_KEYS = frozenset({"settings"}) # plugins.settings -> plugin ids only
274
+
275
+
276
+ # --------------------------------------------------------------- dataclasses
277
+
278
+
279
+ @dataclass
280
+ class EnvInfo:
281
+ os: str
282
+ os_release: str
283
+ python_version: str
284
+ package_version: str
285
+ multiplexer: str
286
+ tmux_version: str | None
287
+ # The host's git, as git names itself — `verify.GIT_FLOOR` is the floor a run is
288
+ # refused below, so "which git ran this" is the first thing a dump has to answer
289
+ # about a refusal. `None` when git could not be asked at all, which is itself the
290
+ # finding: the same probe that fails here is the one that aborts a run.
291
+ git_version: str | None
292
+ # `platform.system()` above says "Windows" for both a native shell and a WSL
293
+ # interop launch. `sys_platform` carries the raw token instead, so a dump can be
294
+ # matched character-for-character against validate's `platform default for {token}`
295
+ # line; `win32_on_wsl_path` is the #332 condition itself. Named for exactly what it
296
+ # observes — a win32 interpreter working a distro path — and NOT "wsl interop":
297
+ # `cd \\wsl.localhost\...` from native PowerShell reaches the same state, so this
298
+ # must not claim where the operator is standing (the matching `host.win32-on-wsl-path`
299
+ # finding is worded to the same limit). Additive fields — no SCHEMA_VERSION bump;
300
+ # `--json` evolution is additive-only, see the contract note in machine.py.
301
+ sys_platform: str
302
+ win32_on_wsl_path: bool
303
+
304
+
305
+ @dataclass
306
+ class FileGroup:
307
+ category: str
308
+ count: int
309
+ total_bytes: int
310
+ total_lines: int | None = None # logs only
311
+
312
+
313
+ @dataclass
314
+ class SessionTally:
315
+ by_status: dict[str, int] = field(default_factory=dict)
316
+ by_role: dict[str, int] = field(default_factory=dict)
317
+
318
+
319
+ @dataclass
320
+ class TaskDiag:
321
+ alias: str
322
+ epic: int
323
+ phase: str
324
+ attempt: int
325
+ review_cycle: int
326
+ terminal: bool
327
+ rearmed: bool
328
+ resolved_redrive: bool
329
+ followup_review_recommended: bool
330
+ committed: bool
331
+ deferred_with_reason: bool
332
+ spec_present: bool
333
+ worktree_isolated: bool
334
+ # The discriminator a #705-class replay turns on. Without it a collided re-drive
335
+ # dumps as `rearmed=True, attempt=1, n_sessions=2` — byte-identical to a HEALTHY
336
+ # post-re-arm task. A counter, so it carries no customer content.
337
+ generation: int
338
+ dw_count: int
339
+ n_sessions: int
340
+ sessions: SessionTally
341
+ tokens: dict[str, int]
342
+
343
+
344
+ @dataclass
345
+ class JournalDiag:
346
+ total_entries: int
347
+ kind_histogram: dict[str, int]
348
+ first_ts: float | None
349
+ last_ts: float | None
350
+ duration_s: float | None
351
+ escalation_count: int
352
+ defer_count: int
353
+ plugin_error_count: int
354
+ per_alias_event_counts: dict[str, dict[str, int]]
355
+ entries: list[dict] = field(default_factory=list)
356
+
357
+
358
+ @dataclass
359
+ class RunDiag:
360
+ run_id: str
361
+ project_alias: str
362
+ run_type: str
363
+ started_date: str | None
364
+ finished: bool
365
+ stopped: bool
366
+ paused: bool
367
+ paused_stage: str | None
368
+ paused_reason_present: bool
369
+ # Whether this run's code tree is a DIFFERENT directory from its project root.
370
+ # A presence flag in the `paused_reason_present` / `worktree_isolated` style, never
371
+ # the path — `_JOURNAL_DROP_FIELDS` drops `repo` as an absolute host path, and that
372
+ # drop otherwise removes the last trace of the split from a dump. The split layout
373
+ # is exactly the one in which the re-anchored baseline probes behave differently,
374
+ # so a bug report that cannot show it cannot be triaged.
375
+ repo_root_diverges: bool
376
+ current_epic: int | None
377
+ sweep_cycle: int
378
+ sweeps_triggered: list[str]
379
+ sweeps_refused: dict[str, str]
380
+ plugin_shared_keys: int
381
+ policy: dict
382
+ n_tasks: int
383
+ phase_histogram: dict[str, int]
384
+ token_totals: dict[str, int]
385
+ session_tally: SessionTally
386
+ tasks: list[TaskDiag]
387
+ journal: JournalDiag
388
+ files: list[FileGroup]
389
+ warnings: list[str] = field(default_factory=list)
390
+
391
+
392
+ @dataclass
393
+ class Diagnostics:
394
+ schema_version: int
395
+ generated_at: str
396
+ tool_version: str
397
+ env: EnvInfo
398
+ runs: list[RunDiag]
399
+
400
+
401
+ # ----------------------------------------------------------------- collectors
402
+
403
+
404
+ def collect_env(project: Path) -> EnvInfo:
405
+ """Host facts for the dump's Environment block.
406
+
407
+ ``project`` feeds only the #332 verdict. It is required — here *and* on
408
+ ``collect`` — so a new caller must consciously supply the project it actually
409
+ diagnosed; the type cannot force that choice to be *right* (any ``Path``
410
+ yields a confident verdict), and the sole production caller, ``cmd_diagnose``,
411
+ passes the resolved project.
412
+
413
+ The project *path* itself is never emitted: it is a redaction hazard — the
414
+ redactor leaves the Linux username in a ``\\\\wsl.localhost\\...\\home\\<user>\\...``
415
+ path standing (it compares against the *Windows* account), and ``collect_env``
416
+ has no pseudonymizer to alias it against — so the boolean is what ships, and since
417
+ #512 that shape trips the egress guard, which refuses the whole dump rather than
418
+ passing it. Same
419
+ reason ``sys.executable`` is absent despite naming the exact mismatch: the venv
420
+ path carries the project name past the redactor."""
421
+ from . import verify
422
+ from .adapters.multiplexer import fold_version, get_multiplexer
423
+ from .platform_util import is_wsl_unc_path
424
+
425
+ mux = "none"
426
+ tmux_v = None
427
+ try:
428
+ backend = get_multiplexer()
429
+ mux = type(backend).__name__
430
+ # fold_version both flattens and bounds (the seam's inline-render
431
+ # contract), so the max_lines=1 this used to carry is redundant — and it
432
+ # never held anyway: scrub_text's "(N more lines redacted)" marker is
433
+ # itself a new line, re-introducing exactly what the cap removed.
434
+ # Scrub first so the home/email redaction reads the whole probe rather
435
+ # than a tail the fold already cut.
436
+ raw = backend.version()
437
+ tmux_v = fold_version(sanitize.scrub_text(raw)) if raw else None
438
+ except Exception: # nosec B110 - env probe is best-effort; absent mux is fine
439
+ pass
440
+
441
+ git_v = None
442
+ try:
443
+ # Same scrub-then-fold order as the mux probe above, for the same #321
444
+ # reason: folding first can cut a home path mid-way, leaving a fragment
445
+ # `redact_home` no longer matches. `git version` is one short line today,
446
+ # but a vendor build is free to say more and the bound is what makes that
447
+ # safe. Reported RAW rather than as a verdict — a dump is evidence, and the
448
+ # floor it is read against can move after the dump was written.
449
+ # `git_bytes` ANSWERS with a non-zero rc rather than raising, so the rc is
450
+ # checked here for the same reason `git_below_floor` checks it: stdout on a
451
+ # failed probe is not a version, and folding it would put a fabricated
452
+ # answer in the dump. `None` — "could not be asked" — is the honest value.
453
+ # Bounded rather than inheriting the engine's `GIT_TIMEOUT_S` (120s), via
454
+ # the #390 per-call seam the other two best-effort probes already use
455
+ # (`install`'s init hint, the TUI's commit-subject render). `diagnose` is a
456
+ # FOREGROUND recovery aid — the command you reach for when the host is
457
+ # already broken — and a git that hangs is one of the states it exists to
458
+ # be usable in. Inheriting the engine bound made it sit silent for two
459
+ # minutes and then swallow the fault anyway, so the whole wait bought a
460
+ # `None` this returns in five seconds. The dump is worth far more than the
461
+ # one line this probe fills.
462
+ probed = verify.git_bytes(project, "version", timeout_s=5)
463
+ if probed.returncode == 0:
464
+ git_v = fold_version(sanitize.scrub_text(os.fsdecode(probed.stdout))) or None
465
+ except Exception: # nosec B110 - env probe is best-effort; absent git is fine
466
+ pass
467
+
468
+ return EnvInfo(
469
+ os=platform.system(),
470
+ os_release=sanitize.scrub_text(platform.release()),
471
+ python_version=platform.python_version(),
472
+ package_version=__version__,
473
+ multiplexer=mux,
474
+ tmux_version=tmux_v,
475
+ git_version=git_v,
476
+ sys_platform=sys.platform,
477
+ win32_on_wsl_path=sys.platform == "win32" and is_wsl_unc_path(project),
478
+ )
479
+
480
+
481
+ def _category_roots(category: str, run_dir: Path, events_dir: Path | None) -> list[Path]:
482
+ """Where a category's files live. One directory for all but ``events``.
483
+
484
+ The events channel has TWO live roots since #494 (Phase 3): the primary is
485
+ out of the project tree under the user state root, and the legacy in-tree
486
+ ``<run-dir>/events`` is still both written and polled. It has to be, because
487
+ the hook relay is COPIED into the project by ``init`` — an upgraded
488
+ orchestrator routinely drives sessions whose relay predates the move and
489
+ still writes in-tree, so the watcher dual-polls. Counting only one root would
490
+ report zero events for precisely the runs whose event routing is what a
491
+ maintainer is reading the dump to understand.
492
+
493
+ Both are summed into ONE ``FileGroup`` named ``events``: the payload shape is
494
+ the schema, and splitting the category (or adding a field) would be a break
495
+ for a v1 consumer. Which root the events came from is not what the count is
496
+ for — "did the hooks fire at all" is.
497
+ """
498
+ if category != _EVENTS_CATEGORY:
499
+ return [run_dir / category]
500
+ legacy = run_dir / _EVENTS_CATEGORY
501
+ # Deduped by spelling so a state root deliberately pointed inside the run dir
502
+ # (FROID_LOOP_STATE_DIR is honoured as spelled) cannot double-count.
503
+ if events_dir is None or events_dir == legacy:
504
+ return [legacy]
505
+ return [events_dir, legacy]
506
+
507
+
508
+ def _count_lines(path: Path) -> int:
509
+ """Lines in a regular file, or 0 — never blocking on a FIFO a session planted.
510
+
511
+ ``O_NONBLOCK`` plus an ``S_ISREG`` check **on the descriptor**, the idiom
512
+ ``runs.read_trusted_config_digest`` and ``tui.launch._read_ctl_window``
513
+ already carry for the same hazard: the run directory is exported to the
514
+ driven session as ``FROID_LOOP_RUN_DIR``, so an lstat taken before the open is
515
+ a check-then-open race, and ``fstat`` describes the object actually opened.
516
+ Opening a FIFO read-only without ``O_NONBLOCK`` blocks until a writer
517
+ arrives — indefinitely, for a diagnostic dump nobody is feeding, and
518
+ ``diagnose`` is a foreground command a human is waiting on. ``O_NOFOLLOW``
519
+ keeps the final component from redirecting the read out of the run, which is
520
+ the one hop :func:`platform_util.walk_files_unlinked` cannot refuse for it.
521
+ The POSIX-only flags degrade to 0 on win32, where the fd check carries alone.
522
+ """
523
+ flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
524
+ flags |= getattr(os, "O_BINARY", 0) # win32: no CRLF translation on the raw fd
525
+ try:
526
+ fd = os.open(path, flags)
527
+ except OSError:
528
+ return 0
529
+ try:
530
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
531
+ return 0
532
+ with os.fdopen(fd, "rb", closefd=False) as f:
533
+ return sum(1 for _ in f)
534
+ except OSError:
535
+ return 0
536
+ finally:
537
+ os.close(fd)
538
+
539
+
540
+ def summarize_files(run_dir: Path, *, events_dir: Path | None = None) -> list[FileGroup]:
541
+ """Counts/sizes only — file contents are NEVER opened into the output.
542
+
543
+ ``events_dir`` is this run's out-of-tree event channel (#494); ``None`` when
544
+ the caller could not derive one, which degrades to the legacy in-tree
545
+ location alone rather than dropping the category.
546
+ """
547
+ groups: list[FileGroup] = []
548
+ for category in _FILE_CATEGORIES:
549
+ count = 0
550
+ total_bytes = 0
551
+ total_lines = 0
552
+ for root in _category_roots(category, run_dir, events_dir):
553
+ if not root.is_dir():
554
+ continue
555
+ # walk_files_unlinked, not rglob: `is_dir()` above FOLLOWS a link, so a
556
+ # planted redirect at a category root reads as a directory and rglob
557
+ # then counts the target's tree as this run's retained output.
558
+ for p in walk_files_unlinked(root):
559
+ # The regular-file filter `rglob` + `is_file()` used to carry, and
560
+ # which came off with the switch: `os.walk` reports every
561
+ # non-directory entry, so `files` holds FIFOs, device nodes and
562
+ # symlinks too. None of those is retained output of this run, and
563
+ # the `logs` arm below OPENS what it counts. lstat, not
564
+ # `is_file()` — that FOLLOWS, so it answers about the target of a
565
+ # planted link rather than about the entry in this run's tree.
566
+ try:
567
+ info = p.lstat()
568
+ except OSError:
569
+ continue
570
+ if not stat.S_ISREG(info.st_mode):
571
+ continue
572
+ count += 1
573
+ total_bytes += info.st_size
574
+ if category == "logs":
575
+ total_lines += _count_lines(p)
576
+ if count:
577
+ groups.append(
578
+ FileGroup(
579
+ category=category,
580
+ count=count,
581
+ total_bytes=total_bytes,
582
+ total_lines=total_lines if category == "logs" else None,
583
+ )
584
+ )
585
+ return groups
586
+
587
+
588
+ def _session_tally(tasks: list[StoryTask]) -> SessionTally:
589
+ by_status: Counter[str] = Counter()
590
+ by_role: Counter[str] = Counter()
591
+ for task in tasks:
592
+ for s in task.sessions:
593
+ by_status[str(s.status)] += 1
594
+ by_role[str(s.role)] += 1
595
+ return SessionTally(by_status=dict(by_status), by_role=dict(by_role))
596
+
597
+
598
+ def _task_diag(task: StoryTask, pseudo: sanitize.Pseudonymizer, weight: float) -> TaskDiag:
599
+ tokens = task.tokens.to_dict()
600
+ tokens["total"] = task.tokens.total
601
+ # Derived from the run's snapshot weight, which this bundle also carries
602
+ # under policy.limits — so the figure stays checkable against its inputs.
603
+ tokens["weighted"] = task.tokens.weighted_total(weight)
604
+ return TaskDiag(
605
+ alias=pseudo.alias(task.story_key, ns="story", epic=task.epic),
606
+ epic=task.epic,
607
+ phase=str(task.phase),
608
+ attempt=task.attempt,
609
+ review_cycle=task.review_cycle,
610
+ terminal=task.terminal,
611
+ rearmed=task.rearmed,
612
+ resolved_redrive=task.resolved_redrive,
613
+ followup_review_recommended=task.followup_review_recommended,
614
+ committed=task.commit_sha is not None,
615
+ deferred_with_reason=bool(task.defer_reason),
616
+ spec_present=bool(task.spec_file),
617
+ worktree_isolated=bool(task.worktree_path),
618
+ generation=task.generation,
619
+ dw_count=len(task.dw_ids),
620
+ n_sessions=len(task.sessions),
621
+ sessions=_session_tally([task]),
622
+ tokens=tokens,
623
+ )
624
+
625
+
626
+ def _scrub_policy(obj: Any) -> Any:
627
+ """Deep-scrub a policy snapshot, dropping the keys that can carry secrets,
628
+ paths, or free text (``extra_args``/``env`` -> count, ``settings`` -> the
629
+ plugin ids only, ``commit_message_template`` -> bool); everything else goes
630
+ through the standard ``scrub_json`` value gate."""
631
+ if isinstance(obj, dict):
632
+ out: dict[str, Any] = {}
633
+ for raw_key, value in obj.items():
634
+ key = str(raw_key)
635
+ if key in _POLICY_COUNT_KEYS:
636
+ out[f"{key}_count"] = len(value) if isinstance(value, (list, tuple, dict)) else 0
637
+ elif key in _POLICY_BOOL_KEYS:
638
+ out[f"{key}_set"] = bool(value)
639
+ elif key in _POLICY_KEYSET_KEYS and isinstance(value, dict):
640
+ out[key] = sorted(
641
+ k for k in (str(x) for x in value) if sanitize.looks_like_identifier(k)
642
+ )
643
+ else:
644
+ # Keys pass through VERBATIM here, deliberately — unlike
645
+ # `sanitize._scrub`, which scrubs keys as well as values. The
646
+ # warrant is what this snapshot IS: `Policy.to_dict()` is
647
+ # `asdict()` over frozen dataclasses, so every key is a
648
+ # compile-time field name — developer-authored, non-PII, and the
649
+ # point of the dump (a reader diagnosing a run needs to see
650
+ # `max_review_cycles`, not `<redacted:str>`). The one user-keyed
651
+ # table, `plugins.settings`, never reaches this branch:
652
+ # `_POLICY_KEYSET_KEYS` intercepts it above and reduces it to its
653
+ # identifier-gated plugin-id keyset (#186).
654
+ #
655
+ # That rests on an invariant nothing else stated until #202: NO
656
+ # policy section has a free-keyed table. Add one — say an
657
+ # `adapter.overrides` keyed by binary path — and its keys ship
658
+ # verbatim in a dump meant to be shareable.
659
+ # `test_no_policy_section_has_a_free_keyed_table`
660
+ # (tests/test_diagnostics.py) enforces it over the field TYPES, so
661
+ # it fires when such a table is declared rather than when a user
662
+ # first populates it; route a new one through `_POLICY_KEYSET_KEYS`
663
+ # or `_POLICY_COUNT_KEYS` rather than widening that test.
664
+ out[key] = _scrub_policy(value)
665
+ return out
666
+ if isinstance(obj, (list, tuple)):
667
+ return [_scrub_policy(v) for v in obj]
668
+ return sanitize.scrub_json(obj)
669
+
670
+
671
+ def _alias_in_entry(entry: dict, pseudo: sanitize.Pseudonymizer, epic_by_key: dict[str, int]):
672
+ """The alias an entry's story-key/task field maps to, for per-alias counts."""
673
+ for fld in ("log_task", "story_key", "task_id", "paused_story_key"):
674
+ val = entry.get(fld)
675
+ if val:
676
+ return pseudo.alias(val, ns="story", epic=epic_by_key.get(str(val)))
677
+ return None
678
+
679
+
680
+ def _alias_input(value: Any, ns: str) -> Any:
681
+ """The string an alias is computed over: the basename for the path-shaped
682
+ namespaces, the value unchanged for every other one (and for any non-string,
683
+ which :meth:`Pseudonymizer.alias` handles itself)."""
684
+ if ns not in _JOURNAL_BASENAME_NAMESPACES or not isinstance(value, str):
685
+ return value
686
+ # `or value`: a value ending in a separator splits to an empty tail, and an
687
+ # empty string is the one input `alias()` passes through unaliased — so it
688
+ # would render as `""` and lose the event's only reference to the spec.
689
+ return _PATH_SEP_RE.split(value)[-1] or value
690
+
691
+
692
+ def _scrub_entry(
693
+ entry: dict,
694
+ pseudo: sanitize.Pseudonymizer,
695
+ epic_by_key: dict[str, int],
696
+ first_ts: float | None,
697
+ ) -> dict:
698
+ """One journal entry reduced to a shareable form: relative timestamp, kind
699
+ verbatim, identifier fields aliased, free-text fields collapsed to a
700
+ presence boolean, and every remaining/unknown field scrub_json'd."""
701
+ out: dict[str, Any] = {}
702
+ ts = entry.get("ts")
703
+ if isinstance(ts, (int, float)) and first_ts is not None:
704
+ out["ts_offset"] = round(ts - first_ts, 3)
705
+ kind = str(entry.get("kind", "?"))
706
+ out["kind"] = kind if sanitize.looks_like_identifier(kind) else "<redacted:str>"
707
+ # Read off the RAW kind, not the redacted spelling above: a kind that failed
708
+ # `looks_like_identifier` is not one of the three below anyway, and keying on the
709
+ # placeholder would silently unroute every entry in a dump that had one.
710
+ by_kind = _JOURNAL_KIND_ALIAS_FIELDS.get(kind, {})
711
+ for k, v in entry.items():
712
+ if k in ("ts", "kind"):
713
+ continue
714
+ kind_ns = by_kind.get(k)
715
+ if k in _JOURNAL_DROP_FIELDS:
716
+ out[f"{k}_present"] = v is not None and v != ""
717
+ elif k in _JOURNAL_KEYLIST_FIELDS and isinstance(v, list):
718
+ ns = "story" if k == "keys" else "dw"
719
+ out[k] = [pseudo.alias(x, ns=ns, epic=epic_by_key.get(str(x))) for x in v]
720
+ elif kind_ns is not None or k in _JOURNAL_ALIAS_FIELDS:
721
+ ns = kind_ns or _JOURNAL_ALIAS_FIELDS[k]
722
+ v = _alias_input(v, ns)
723
+ epic = epic_by_key.get(str(v)) if ns == "story" else None
724
+ out[k] = pseudo.alias(v, ns=ns, epic=epic)
725
+ else:
726
+ out[k] = sanitize.scrub_json(v)
727
+ return out
728
+
729
+
730
+ def summarize_journal(
731
+ entries: list[dict],
732
+ pseudo: sanitize.Pseudonymizer,
733
+ epic_by_key: dict[str, int],
734
+ *,
735
+ cap: int,
736
+ ) -> JournalDiag:
737
+ kinds: Counter[str] = Counter()
738
+ per_alias: dict[str, Counter[str]] = {}
739
+ timestamps: list[float] = []
740
+ for entry in entries:
741
+ kind = str(entry.get("kind", "?"))
742
+ kinds[kind] += 1
743
+ ts = entry.get("ts")
744
+ if isinstance(ts, (int, float)):
745
+ timestamps.append(ts)
746
+ alias = _alias_in_entry(entry, pseudo, epic_by_key)
747
+ if alias is not None:
748
+ per_alias.setdefault(alias, Counter())[kind] += 1
749
+ first_ts = min(timestamps) if timestamps else None
750
+ last_ts = max(timestamps) if timestamps else None
751
+ scrubbed = (
752
+ [_scrub_entry(e, pseudo, epic_by_key, first_ts) for e in entries[:cap]] if cap > 0 else []
753
+ )
754
+ return JournalDiag(
755
+ total_entries=len(entries),
756
+ kind_histogram=dict(kinds),
757
+ first_ts=first_ts,
758
+ last_ts=last_ts,
759
+ # first_ts/last_ts are set together (both None iff no timestamps), so the
760
+ # first_ts guard also proves last_ts is not None here.
761
+ duration_s=(
762
+ round(last_ts - first_ts, 3) # pyright: ignore[reportOptionalOperand]
763
+ if first_ts is not None
764
+ else None
765
+ ),
766
+ escalation_count=kinds.get("story-escalated", 0) + kinds.get("preference-escalation", 0),
767
+ defer_count=kinds.get("story-deferred", 0),
768
+ plugin_error_count=kinds.get("plugin-error", 0),
769
+ per_alias_event_counts={a: dict(c) for a, c in per_alias.items()},
770
+ entries=scrubbed,
771
+ )
772
+
773
+
774
+ def _coarsen_date(started_at: str | None) -> str | None:
775
+ if not started_at:
776
+ return None
777
+ head = str(started_at)[:10]
778
+ return head if sanitize.looks_like_identifier(head.replace("-", "0")) else None
779
+
780
+
781
+ def _events_dir(state: RunState) -> Path | None:
782
+ """This run's out-of-tree event channel, or ``None`` if it is not derivable.
783
+
784
+ Derived from the run's OWN recorded project and id rather than threaded down
785
+ from ``cmd_diagnose``'s ``--project``, which is the smaller change and the
786
+ truer one: ``--all`` dumps every run under a project, and a run carries the
787
+ project it was started against. ``run_dir`` cannot answer this — the state
788
+ root is keyed by a digest of the resolved project, not by the run dir's
789
+ ancestry.
790
+
791
+ Every failure mode is observation, so every one of them degrades. The
792
+ derivation resolves the project (``runs.project_tag``) and consults the host
793
+ for a state root, and a dump is routinely read on a machine that is not the
794
+ one that produced it: an unresolvable project, or a host with no derivable
795
+ state root, must cost the events count and nothing else.
796
+ """
797
+ from . import runs
798
+
799
+ try:
800
+ return runs.events_dir_for(Path(state.project), state.run_id)
801
+ except Exception:
802
+ # No `# nosec`: bandit's B110/B112 are about `pass`/`continue` bodies, and
803
+ # a directive naming a rule that never fires here would read as a waived
804
+ # finding. The breadth is deliberate and stated above, not silenced.
805
+ return None
806
+
807
+
808
+ def collect_run(run_dir: Path, *, pseudo: sanitize.Pseudonymizer, cap: int) -> RunDiag:
809
+ state: RunState = load_state(run_dir)
810
+ tasks = list(state.tasks.values())
811
+ epic_by_key = {t.story_key: t.epic for t in tasks}
812
+
813
+ weight = state.cache_read_weight()
814
+ token_totals: Counter[str] = Counter()
815
+ for t in tasks:
816
+ for k, v in t.tokens.to_dict().items():
817
+ token_totals[k] += v
818
+ token_totals["total"] += t.tokens.total
819
+ # Per-task, matching Engine.summary and the TUI (weighted_total rounds
820
+ # internally, so summing per task is what keeps the numbers identical).
821
+ token_totals["weighted"] += t.tokens.weighted_total(weight)
822
+
823
+ phase_hist: Counter[str] = Counter(str(t.phase) for t in tasks)
824
+
825
+ return RunDiag(
826
+ run_id=state.run_id,
827
+ project_alias=pseudo.alias(Path(state.project).name, ns="project"),
828
+ run_type=state.run_type,
829
+ started_date=_coarsen_date(state.started_at),
830
+ finished=state.finished,
831
+ stopped=state.stopped,
832
+ paused=state.paused,
833
+ paused_stage=state.paused_stage,
834
+ paused_reason_present=state.paused_reason is not None,
835
+ repo_root_diverges=bool(state.repo_root) and Path(state.repo_root) != Path(state.project),
836
+ current_epic=state.current_epic,
837
+ sweep_cycle=state.sweep_cycle,
838
+ sweeps_triggered=[
839
+ s if sanitize.looks_like_identifier(str(s)) else "<redacted:str>"
840
+ for s in state.sweeps_triggered
841
+ ],
842
+ # BOTH halves are filtered. The value is a closed SWEEP_REFUSED_* slug by
843
+ # construction, but the key is a trigger string off state.json — the same
844
+ # untrusted footing as sweeps_triggered above — and a hand-edited or
845
+ # foreign state file must not be able to route a home path into a report
846
+ # that `sanitize.guard` would then refuse to emit at all.
847
+ sweeps_refused={
848
+ (k if sanitize.looks_like_identifier(str(k)) else "<redacted:str>"): (
849
+ v if sanitize.looks_like_identifier(str(v)) else "<redacted:str>"
850
+ )
851
+ for k, v in state.sweeps_refused.items()
852
+ },
853
+ plugin_shared_keys=len(state.plugin_shared),
854
+ policy=_scrub_policy(state.policy_snapshot),
855
+ n_tasks=len(tasks),
856
+ phase_histogram=dict(phase_hist),
857
+ token_totals=dict(token_totals),
858
+ session_tally=_session_tally(tasks),
859
+ tasks=[_task_diag(t, pseudo, weight) for t in tasks],
860
+ journal=summarize_journal(Journal(run_dir).entries(), pseudo, epic_by_key, cap=cap),
861
+ files=summarize_files(run_dir, events_dir=_events_dir(state)),
862
+ )
863
+
864
+
865
+ def collect(
866
+ run_dirs: list[Path],
867
+ *,
868
+ pseudo: sanitize.Pseudonymizer,
869
+ cap: int = DEFAULT_JOURNAL_CAP,
870
+ generated_at: str | None = None,
871
+ project: Path,
872
+ ) -> Diagnostics:
873
+ runs: list[RunDiag] = []
874
+ for run_dir in run_dirs:
875
+ try:
876
+ runs.append(collect_run(run_dir, pseudo=pseudo, cap=cap))
877
+ except Exception as e: # one bad run never sinks the dump
878
+ runs.append(_unreadable_run(run_dir, e))
879
+ return Diagnostics(
880
+ schema_version=SCHEMA_VERSION,
881
+ generated_at=generated_at or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
882
+ tool_version=__version__,
883
+ env=collect_env(project),
884
+ runs=runs,
885
+ )
886
+
887
+
888
+ def _unreadable_run(run_dir: Path, err: Exception) -> RunDiag:
889
+ return RunDiag(
890
+ run_id=run_dir.name if sanitize.looks_like_identifier(run_dir.name) else "<redacted:str>",
891
+ project_alias="project-?",
892
+ run_type="?",
893
+ started_date=None,
894
+ finished=False,
895
+ stopped=False,
896
+ paused=False,
897
+ paused_stage=None,
898
+ paused_reason_present=False,
899
+ repo_root_diverges=False,
900
+ current_epic=None,
901
+ sweep_cycle=0,
902
+ sweeps_triggered=[],
903
+ sweeps_refused={},
904
+ plugin_shared_keys=0,
905
+ policy={},
906
+ n_tasks=0,
907
+ phase_histogram={},
908
+ token_totals={},
909
+ session_tally=SessionTally(),
910
+ tasks=[],
911
+ journal=JournalDiag(0, {}, None, None, None, 0, 0, 0, {}),
912
+ files=[],
913
+ warnings=[f"run unreadable: {type(err).__name__}"],
914
+ )
915
+
916
+
917
+ # ------------------------------------------------------------------ rendering
918
+
919
+
920
+ def _fmt_kv(label: str, value: Any) -> str:
921
+ return f"- **{label}:** {value}"
922
+
923
+
924
+ def _to_jsonable(d: Diagnostics) -> dict:
925
+ from dataclasses import asdict
926
+
927
+ return asdict(d)
928
+
929
+
930
+ def render_json(
931
+ d: Diagnostics,
932
+ *,
933
+ pseudo: sanitize.Pseudonymizer | None = None,
934
+ repairs: list[tuple[str, int]] | None = None,
935
+ ) -> str:
936
+ # ensure_ascii=False is a SAFETY requirement, not cosmetics: the default
937
+ # escapes every non-ASCII char to \uXXXX, so a sensitive value like a
938
+ # non-ASCII username reaches _guard as "café-user" and matches nothing.
939
+ # The guard must see the string as itself. The document is only ever written
940
+ # with encoding="utf-8" (CLI --out) or printed to a UTF-8 stream, so emitting
941
+ # real non-ASCII is safe — and it must be identical in both dumps below, or
942
+ # the bytes we verified would not be the bytes we emit.
943
+ rendered = json.dumps(_to_jsonable(d), indent=2, sort_keys=True, ensure_ascii=False)
944
+ rendered, reps = sanitize.guard(rendered, pseudo)
945
+ if reps:
946
+ # Disclose the repair in the dump itself so the routing gap surfaces as
947
+ # a reportable bug. Substitution preserved JSON validity — a leaked
948
+ # original is identifier-shaped and its alias is [A-Za-z0-9-], neither
949
+ # side carries quotes or backslashes — so reload-and-extend is safe.
950
+ # backstop_repairs is an optional additive key: absent on a clean dump.
951
+ data = json.loads(rendered)
952
+ data["backstop_repairs"] = dict(reps)
953
+ rendered = json.dumps(data, indent=2, sort_keys=True, ensure_ascii=False)
954
+ sanitize.assert_clean(rendered, pseudo)
955
+ if repairs is not None:
956
+ repairs.extend(reps)
957
+ return rendered
958
+
959
+
960
+ def render_markdown(
961
+ d: Diagnostics,
962
+ *,
963
+ pseudo: sanitize.Pseudonymizer | None = None,
964
+ repairs: list[tuple[str, int]] | None = None,
965
+ ) -> str:
966
+ out: list[str] = []
967
+ out.append("# froid-loop diagnostic dump (sanitized)")
968
+ out.append("")
969
+ out.append(
970
+ "_Identifiers are pseudonymized; code, prompts, paths and free text are "
971
+ "redacted. Safe to share._"
972
+ )
973
+ out.append("")
974
+ out.append("## Environment")
975
+ e = d.env
976
+ out.append(_fmt_kv("froid-loop version", e.package_version))
977
+ out.append(_fmt_kv("python", e.python_version))
978
+ out.append(_fmt_kv("os", f"{e.os} {e.os_release}"))
979
+ out.append(_fmt_kv("sys.platform", e.sys_platform))
980
+ out.append(_fmt_kv("win32 on WSL distro path", "yes" if e.win32_on_wsl_path else "no"))
981
+ out.append(_fmt_kv("multiplexer", e.multiplexer))
982
+ out.append(_fmt_kv("tmux", e.tmux_version or "—"))
983
+ out.append(_fmt_kv("git", e.git_version or "—"))
984
+ out.append(_fmt_kv("schema / generated", f"v{d.schema_version} @ {d.generated_at}"))
985
+ out.append("")
986
+
987
+ for r in d.runs:
988
+ out.append(f"## Run `{r.run_id}` ({r.run_type})")
989
+ out.append(_fmt_kv("project", f"`{r.project_alias}`"))
990
+ # Rendered here and not only in `--json`: this is the report an operator
991
+ # produces by default and hands a maintainer, and the split layout is the
992
+ # one the re-anchored baseline probes behave differently in. A dump that
993
+ # cannot show it cannot be triaged — the whole warrant for the field.
994
+ out.append(
995
+ _fmt_kv("code root differs from project", "yes" if r.repo_root_diverges else "no")
996
+ )
997
+ out.append(_fmt_kv("started", r.started_date or "—"))
998
+ out.append(
999
+ _fmt_kv(
1000
+ "state",
1001
+ f"finished={r.finished} stopped={r.stopped} paused={r.paused}"
1002
+ + (
1003
+ f" (stage={r.paused_stage}, reason_present={r.paused_reason_present})"
1004
+ if r.paused
1005
+ else ""
1006
+ ),
1007
+ )
1008
+ )
1009
+ out.append(_fmt_kv("epic / sweep_cycle", f"{r.current_epic} / {r.sweep_cycle}"))
1010
+ if r.sweeps_triggered:
1011
+ out.append(_fmt_kv("sweeps_triggered", ", ".join(f"`{s}`" for s in r.sweeps_triggered)))
1012
+ if r.sweeps_refused:
1013
+ out.append(
1014
+ _fmt_kv(
1015
+ "sweeps_refused",
1016
+ ", ".join(f"`{k}`: {v}" for k, v in r.sweeps_refused.items()),
1017
+ )
1018
+ )
1019
+ out.append(_fmt_kv("tasks", r.n_tasks))
1020
+ out.append(_fmt_kv("phase histogram", _dict_inline(r.phase_histogram)))
1021
+ out.append(_fmt_kv("token totals", _dict_inline(r.token_totals)))
1022
+ out.append(_fmt_kv("sessions by status", _dict_inline(r.session_tally.by_status)))
1023
+ out.append(_fmt_kv("sessions by role", _dict_inline(r.session_tally.by_role)))
1024
+ if r.warnings:
1025
+ for w in r.warnings:
1026
+ out.append(f"- ⚠️ {w}")
1027
+ out.append("")
1028
+
1029
+ out.append("### Tasks")
1030
+ if r.tasks:
1031
+ # `gen` rides beside `att` because the pair is the discriminator: a
1032
+ # #705-class replay and a healthy post-re-arm task agree on every other
1033
+ # column here, so dropping it from the human report leaves the one field
1034
+ # that separates them visible only under `--json`.
1035
+ out.append(
1036
+ "| alias | epic | phase | att | gen | rev | committed | spec | dw | sessions "
1037
+ "| weighted | raw |"
1038
+ )
1039
+ out.append("|---|---|---|---|---|---|---|---|---|---|---|---|")
1040
+ for t in r.tasks:
1041
+ out.append(
1042
+ f"| `{t.alias}` | {t.epic} | {t.phase} | {t.attempt} | {t.generation} "
1043
+ f"| {t.review_cycle} | {t.committed} | {t.spec_present} | {t.dw_count} "
1044
+ f"| {t.n_sessions} | {t.tokens.get('weighted', 0)} "
1045
+ f"| {t.tokens.get('total', 0)} |"
1046
+ )
1047
+ else:
1048
+ out.append("_no tasks._")
1049
+ out.append("")
1050
+
1051
+ j = r.journal
1052
+ out.append("### Journal")
1053
+ out.append(_fmt_kv("entries", j.total_entries))
1054
+ out.append(_fmt_kv("duration (s)", j.duration_s if j.duration_s is not None else "—"))
1055
+ out.append(
1056
+ _fmt_kv(
1057
+ "escalations / defers / plugin-errors",
1058
+ f"{j.escalation_count} / {j.defer_count} / {j.plugin_error_count}",
1059
+ )
1060
+ )
1061
+ out.append(_fmt_kv("kind histogram", _dict_inline(j.kind_histogram)))
1062
+ if j.per_alias_event_counts:
1063
+ out.append("\n_Per-task event counts:_")
1064
+ for alias, counts in sorted(j.per_alias_event_counts.items()):
1065
+ out.append(f"- `{alias}`: {_dict_inline(counts)}")
1066
+ out.append("")
1067
+
1068
+ out.append("### Run-dir files (counts only)")
1069
+ if r.files:
1070
+ for g in r.files:
1071
+ lines = f", {g.total_lines} lines" if g.total_lines is not None else ""
1072
+ out.append(_fmt_kv(g.category, f"{g.count} files, {g.total_bytes} bytes{lines}"))
1073
+ else:
1074
+ out.append("_none._")
1075
+ out.append("")
1076
+
1077
+ rendered = "\n".join(out)
1078
+ rendered, reps = sanitize.guard(rendered, pseudo)
1079
+ if reps:
1080
+ note = [
1081
+ "",
1082
+ "### Backstop repairs",
1083
+ "",
1084
+ "_The leak self-check caught stray occurrences of pseudonymized "
1085
+ "identifiers that the per-field routing missed, and substituted "
1086
+ "their aliases — a froid-loop routing gap; please report it._",
1087
+ "",
1088
+ ]
1089
+ for label, count in reps:
1090
+ note.append(f"- `{label}`: {count} stray occurrence(s) pseudonymized")
1091
+ note.append("")
1092
+ rendered += "\n".join(note)
1093
+ # The note is appended after the repair loop verified the body, so
1094
+ # re-check the whole thing: the note must sit inside the verified bytes.
1095
+ sanitize.assert_clean(rendered, pseudo)
1096
+ if repairs is not None:
1097
+ repairs.extend(reps)
1098
+ return rendered
1099
+
1100
+
1101
+ def _dict_inline(d: dict) -> str:
1102
+ if not d:
1103
+ return "—"
1104
+ return ", ".join(f"{k}={v}" for k, v in sorted(d.items()))