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.
- froid_loop/__init__.py +11 -0
- froid_loop/__main__.py +12 -0
- froid_loop/adapters/__init__.py +3 -0
- froid_loop/adapters/base.py +254 -0
- froid_loop/adapters/entrypoints.py +63 -0
- froid_loop/adapters/env_fault.py +290 -0
- froid_loop/adapters/generic.py +2013 -0
- froid_loop/adapters/mock.py +49 -0
- froid_loop/adapters/multiplexer.py +914 -0
- froid_loop/adapters/opencode_http.py +1687 -0
- froid_loop/adapters/profile.py +650 -0
- froid_loop/adapters/psmux_backend.py +1428 -0
- froid_loop/adapters/registry.py +322 -0
- froid_loop/adapters/tmux_backend.py +35 -0
- froid_loop/adapters/tmux_base.py +630 -0
- froid_loop/checks.py +187 -0
- froid_loop/cli.py +5041 -0
- froid_loop/data/__init__.py +0 -0
- froid_loop/data/froid_loop_hook.py +228 -0
- froid_loop/data/froid_loop_probe_hook.py +88 -0
- froid_loop/data/plugins/example/plugin.toml +21 -0
- froid_loop/data/plugins/tea/plugin.toml +184 -0
- froid_loop/data/plugins/tea/tea_plugin.py +258 -0
- froid_loop/data/plugins/unity/plugin.toml +140 -0
- froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef +16 -0
- froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef.meta +7 -0
- froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs +221 -0
- froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs.meta +11 -0
- froid_loop/data/plugins/unity/unity_assets/_folders/Editor.meta +8 -0
- froid_loop/data/plugins/unity/unity_assets/_folders/FroidLoop.meta +8 -0
- froid_loop/data/plugins/unity/unity_cleanup.py +125 -0
- froid_loop/data/plugins/unity/unity_dialog_probe.py +239 -0
- froid_loop/data/plugins/unity/unity_facts.md +17 -0
- froid_loop/data/plugins/unity/unity_plugin.py +415 -0
- froid_loop/data/plugins/unity/unity_quiesce.py +234 -0
- froid_loop/data/plugins/unity/unity_ready.py +230 -0
- froid_loop/data/plugins/unity/unity_seed_assets.py +298 -0
- froid_loop/data/plugins/unity/unity_setup.py +551 -0
- froid_loop/data/plugins/unity/unity_teardown.py +362 -0
- froid_loop/data/profiles/antigravity.toml +52 -0
- froid_loop/data/profiles/claude.toml +85 -0
- froid_loop/data/profiles/codex.toml +22 -0
- froid_loop/data/profiles/copilot.toml +52 -0
- froid_loop/data/profiles/gemini.toml +26 -0
- froid_loop/data/profiles/opencode.toml +54 -0
- froid_loop/data/settings/core.toml +458 -0
- froid_loop/data/skills/README.md +93 -0
- froid_loop/data/skills/froid-loop-resolve/SKILL.md +288 -0
- froid_loop/data/skills/froid-loop-setup/SKILL.md +161 -0
- froid_loop/data/skills/froid-loop-setup/assets/module-help.csv +3 -0
- froid_loop/data/skills/froid-loop-setup/assets/module.yaml +19 -0
- froid_loop/data/skills/froid-loop-sweep/SKILL.md +100 -0
- froid_loop/data/skills/froid-loop-sweep/automation-mode.md +127 -0
- froid_loop/data/skills/froid-loop-sweep/deferred-work-format.md +302 -0
- froid_loop/data/skills/froid-loop-sweep/migration-mode.md +86 -0
- froid_loop/decisions.py +202 -0
- froid_loop/deferredwork.py +2282 -0
- froid_loop/devcontract.py +892 -0
- froid_loop/diagnostics.py +1104 -0
- froid_loop/documents.py +532 -0
- froid_loop/engine.py +7732 -0
- froid_loop/envvars.py +111 -0
- froid_loop/escalation.py +225 -0
- froid_loop/events.py +266 -0
- froid_loop/fences.py +103 -0
- froid_loop/froidconfig.py +226 -0
- froid_loop/frontmatter.py +526 -0
- froid_loop/gates.py +133 -0
- froid_loop/install.py +2936 -0
- froid_loop/journal.py +178 -0
- froid_loop/machine.py +148 -0
- froid_loop/model.py +898 -0
- froid_loop/operatoractions.py +474 -0
- froid_loop/platform_util.py +1490 -0
- froid_loop/plugins/__init__.py +64 -0
- froid_loop/plugins/bus.py +259 -0
- froid_loop/plugins/context.py +319 -0
- froid_loop/plugins/loader.py +145 -0
- froid_loop/plugins/manifest.py +279 -0
- froid_loop/plugins/model.py +296 -0
- froid_loop/plugins/registry.py +245 -0
- froid_loop/plugins/trust.py +75 -0
- froid_loop/policy.py +1569 -0
- froid_loop/probe.py +1044 -0
- froid_loop/process_host.py +408 -0
- froid_loop/recovery_flow.py +1561 -0
- froid_loop/resolve.py +283 -0
- froid_loop/runs.py +4715 -0
- froid_loop/runsetup.py +1293 -0
- froid_loop/sanitize.py +593 -0
- froid_loop/settings_schema.py +276 -0
- froid_loop/signals.py +160 -0
- froid_loop/sprintstatus.py +609 -0
- froid_loop/statemachine.py +57 -0
- froid_loop/stories.py +615 -0
- froid_loop/stories_engine.py +796 -0
- froid_loop/sweep.py +1892 -0
- froid_loop/tokens.py +196 -0
- froid_loop/tui/__init__.py +11 -0
- froid_loop/tui/app.py +1584 -0
- froid_loop/tui/data.py +840 -0
- froid_loop/tui/launch.py +1003 -0
- froid_loop/tui/screens/__init__.py +1 -0
- froid_loop/tui/screens/dashboard.py +1071 -0
- froid_loop/tui/screens/modals.py +943 -0
- froid_loop/tui/screens/settings_screen.py +477 -0
- froid_loop/tui/settings.py +135 -0
- froid_loop/tui/widgets.py +981 -0
- froid_loop/verify.py +4545 -0
- froid_loop/workspace.py +320 -0
- froid_loop/worktree_flow.py +2301 -0
- froid_loop-0.11.1.dist-info/METADATA +728 -0
- froid_loop-0.11.1.dist-info/RECORD +116 -0
- froid_loop-0.11.1.dist-info/WHEEL +4 -0
- froid_loop-0.11.1.dist-info/entry_points.txt +2 -0
- 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()))
|