stacktrace-cli 0.4.1__py3-none-any.whl → 0.5.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 (37) hide show
  1. stacktrace_cli/__init__.py +8 -1
  2. stacktrace_cli/daemon/activity_log.py +94 -0
  3. stacktrace_cli/daemon/bom.py +56 -0
  4. stacktrace_cli/daemon/cli.py +321 -20
  5. stacktrace_cli/daemon/client.py +115 -2
  6. stacktrace_cli/daemon/composition.py +112 -3
  7. stacktrace_cli/daemon/config.py +182 -0
  8. stacktrace_cli/daemon/db.py +625 -0
  9. stacktrace_cli/daemon/fleet.py +660 -0
  10. stacktrace_cli/daemon/identity.py +83 -0
  11. stacktrace_cli/daemon/jobs.py +181 -0
  12. stacktrace_cli/daemon/observer.py +545 -92
  13. stacktrace_cli/daemon/paths.py +9 -1
  14. stacktrace_cli/daemon/presentation.py +68 -0
  15. stacktrace_cli/daemon/protocol.py +106 -8
  16. stacktrace_cli/daemon/runtime.py +521 -70
  17. stacktrace_cli/daemon/scan.py +256 -0
  18. stacktrace_cli/daemon/server.py +63 -2
  19. stacktrace_cli/daemon/service.py +78 -116
  20. stacktrace_cli/daemon/store.py +770 -260
  21. stacktrace_cli/daemon/version.py +49 -0
  22. stacktrace_cli/detector/render.py +23 -0
  23. stacktrace_cli/remote/cli.py +28 -19
  24. stacktrace_cli/remote/client.py +29 -0
  25. stacktrace_cli/remote/detect_payload.py +147 -25
  26. stacktrace_cli/remote/identity.py +305 -0
  27. stacktrace_cli/remote/payload.py +7 -4
  28. stacktrace_cli/remote/redact.py +773 -105
  29. stacktrace_cli/remote/sync.py +330 -98
  30. stacktrace_cli/remote/sync_detect.py +134 -64
  31. stacktrace_cli/remote/upload_contract.py +10 -1
  32. stacktrace_cli/telemetry/events.py +9 -2
  33. stacktrace_cli/telemetry/posthog.py +34 -5
  34. {stacktrace_cli-0.4.1.dist-info → stacktrace_cli-0.5.1.dist-info}/METADATA +2 -2
  35. {stacktrace_cli-0.4.1.dist-info → stacktrace_cli-0.5.1.dist-info}/RECORD +37 -26
  36. {stacktrace_cli-0.4.1.dist-info → stacktrace_cli-0.5.1.dist-info}/WHEEL +0 -0
  37. {stacktrace_cli-0.4.1.dist-info → stacktrace_cli-0.5.1.dist-info}/entry_points.txt +0 -0
@@ -1,3 +1,10 @@
1
1
  """The `stacktrace` command-line interface — detection and response for AI agents."""
2
2
 
3
- __version__ = "0.4.1"
3
+ import logging as _logging
4
+
5
+ __version__ = "0.5.1"
6
+
7
+ # Only the daemon attaches a handler (ADR-0048). Without this one, a WARNING
8
+ # logged in any other process would reach Python's last-resort handler and
9
+ # print onto the user's terminal.
10
+ _logging.getLogger(__name__).addHandler(_logging.NullHandler())
@@ -0,0 +1,94 @@
1
+ """The daemon's activity log: leveled, private and capped (ADR-0048).
2
+
3
+ Every module logs to its own `logging.getLogger(__name__)`. Only the daemon
4
+ attaches a file handler, to the `stacktrace_cli` package logger; everywhere
5
+ else the package's `NullHandler` keeps those lines off the terminal.
6
+
7
+ A line names kinds, counts, class names and event ids. It never carries a
8
+ finding's title or body, an exception message, or anything from a transcript,
9
+ and a peer-supplied session id is written with `%r`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import os
16
+ import sys
17
+ import time
18
+ from collections.abc import Mapping
19
+ from logging.handlers import RotatingFileHandler
20
+ from pathlib import Path
21
+
22
+ from stacktrace_cli.private_state import ensure_private_directory
23
+
24
+ LEVEL_VARIABLE = "STACKTRACE_DAEMON_LOG_LEVEL"
25
+ MAX_BYTES = 5 * 1024 * 1024
26
+ BACKUP_COUNT = 1
27
+ PACKAGE = "stacktrace_cli"
28
+
29
+
30
+ def private_opener(path: str, flags: int) -> int:
31
+ return os.open(path, flags, 0o600)
32
+
33
+
34
+ class _PrivateRotatingFileHandler(RotatingFileHandler):
35
+ def __init__(self, path: Path, *, max_bytes: int, own_std_streams: bool) -> None:
36
+ self._own_std_streams = own_std_streams
37
+ super().__init__(path, maxBytes=max_bytes, backupCount=BACKUP_COUNT, encoding="utf-8")
38
+
39
+ def _open(self): # type: ignore[override]
40
+ stream = open( # noqa: SIM115 - the handler owns and closes it
41
+ self.baseFilename,
42
+ self.mode,
43
+ encoding=self.encoding,
44
+ errors=self.errors,
45
+ opener=private_opener,
46
+ )
47
+ # The opener's mode applies only to a file it creates.
48
+ os.chmod(self.baseFilename, 0o600)
49
+ return stream
50
+
51
+ def doRollover(self) -> None:
52
+ super().doRollover()
53
+ # `ensure_daemon` pointed this process's stdout and stderr at the file
54
+ # just renamed away. Follow it, or a crash traceback lands in the
55
+ # backup and is deleted with it at the next rollover.
56
+ if self._own_std_streams and self.stream is not None:
57
+ sys.stdout.flush()
58
+ sys.stderr.flush()
59
+ os.dup2(self.stream.fileno(), 1)
60
+ os.dup2(self.stream.fileno(), 2)
61
+
62
+
63
+ def level_from(environment: Mapping[str, str]) -> int:
64
+ name = environment.get(LEVEL_VARIABLE, "").strip().upper()
65
+ return logging.getLevelNamesMapping().get(name, logging.DEBUG)
66
+
67
+
68
+ def configure(
69
+ path: Path,
70
+ *,
71
+ environ: Mapping[str, str] | None = None,
72
+ own_std_streams: bool = False,
73
+ max_bytes: int = MAX_BYTES,
74
+ ) -> logging.Handler:
75
+ ensure_private_directory(path.parent)
76
+ handler = _PrivateRotatingFileHandler(
77
+ path, max_bytes=max_bytes, own_std_streams=own_std_streams
78
+ )
79
+ formatter = logging.Formatter(
80
+ "%(asctime)s %(levelname)s %(name)s: %(message)s", datefmt="%Y-%m-%dT%H:%M:%SZ"
81
+ )
82
+ formatter.converter = time.gmtime
83
+ handler.setFormatter(formatter)
84
+ logger = logging.getLogger(PACKAGE)
85
+ logger.setLevel(level_from(os.environ if environ is None else environ))
86
+ logger.addHandler(handler)
87
+ return handler
88
+
89
+
90
+ def release(handler: logging.Handler) -> None:
91
+ logger = logging.getLogger(PACKAGE)
92
+ logger.removeHandler(handler)
93
+ logger.setLevel(logging.NOTSET)
94
+ handler.close()
@@ -0,0 +1,56 @@
1
+ """The BOM job: `sync_endpoint()` on a timer.
2
+
3
+ A full snapshot of what is installed, not an append stream — so there is nothing
4
+ to cursor, and the spool already handles offline retry. It reads no part of the
5
+ finding queue, and it must upload on a machine with a broken detector, an empty
6
+ database, or no session ever scanned.
7
+
8
+ **It re-collects rather than uploading the daemon's warm `CompositionSnapshots`.**
9
+ `docs/specs/remote-sync.md`'s bar requires the payload be byte-identical to
10
+ OpenACA's for the same endpoint state, and the snapshot is built for a different
11
+ scope — `("claude-code", None)`, user scope with no project layering — by a
12
+ different path than `_collect()`. Reusing it would be a payload change wearing
13
+ the costume of a performance win.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Callable
19
+ from dataclasses import dataclass
20
+
21
+ from stacktrace_cli.remote.sync import SyncError, sync_endpoint
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class BomJobReport:
26
+ """What one run did, for a caller that has to tell the two zeros apart.
27
+
28
+ A machine with nothing installed and a machine that could not reach the
29
+ Cloud both upload nothing. The scheduled job does not care — it is due
30
+ again either way — but `stacktrace daemon sync-bom` is asked by a person,
31
+ and answering "0" to both is answering neither.
32
+ """
33
+
34
+ uploaded: int
35
+ failure: str | None = None
36
+
37
+
38
+ def run_bom_job(*, report: Callable[[str], None]) -> BomJobReport:
39
+ """One `sync endpoint`, with its progress sent somewhere a daemon can write.
40
+
41
+ `drain_the_spool` and `allow_offline_cache` semantics are exactly as the CLI
42
+ has them: a machine that is offline caches its collection and the next run
43
+ replays it. `SyncError` is an outcome here rather than an exit code — there
44
+ is no process to exit — so it is reported and the job is simply due again.
45
+ """
46
+ try:
47
+ results = sync_endpoint(
48
+ project=None,
49
+ quiet=True,
50
+ allow_offline_cache=True,
51
+ reporter=report,
52
+ )
53
+ except SyncError as error:
54
+ report(f"bom sync: {error}")
55
+ return BomJobReport(uploaded=0, failure=str(error))
56
+ return BomJobReport(uploaded=len(results))
@@ -4,11 +4,15 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import os
7
+ import sqlite3
7
8
  import sys
9
+ from datetime import UTC, datetime
8
10
 
9
11
  import click
10
12
 
13
+ from .config import DaemonConfig
11
14
  from .paths import RuntimePaths
15
+ from .presentation import render_finding
12
16
  from .store import FindingStore, SessionKey
13
17
 
14
18
 
@@ -18,19 +22,220 @@ def daemon() -> None:
18
22
 
19
23
 
20
24
  @daemon.command("run", hidden=True)
21
- def run_command() -> None:
25
+ # No `envvar=` on any of these. Click resolves an environment variable *into*
26
+ # the parameter, so a value that came from the environment would arrive here
27
+ # indistinguishable from one typed on the command line — and `daemon status`
28
+ # would report every forked daemon's settings as "(flag)", which is precisely
29
+ # the drift the source label exists to expose. `DaemonConfig.resolve` reads the
30
+ # environment itself, and knows which answer it gave.
31
+ @click.option(
32
+ "--scan-interval",
33
+ default=None,
34
+ help="How often to enumerate and analyse changed sessions. 0 disables scanning."
35
+ " Defaults to STACKTRACE_SCAN_INTERVAL, then 5s.",
36
+ )
37
+ @click.option(
38
+ "--scan-since",
39
+ default=None,
40
+ help="Bootstrap horizon for the first scan only; later scans use the watermark."
41
+ " Defaults to STACKTRACE_SCAN_SINCE, then 3d.",
42
+ )
43
+ @click.option(
44
+ "--agent-kind",
45
+ "agent_kinds",
46
+ multiple=True,
47
+ help="Agent kind to observe. Repeatable. Defaults to STACKTRACE_AGENT_KIND, then claude-code.",
48
+ )
49
+ @click.option(
50
+ "--fleet-interval",
51
+ default=None,
52
+ help="How often to drain the finding queue to Fleet as a fallback poll. 0 disables only"
53
+ " that fallback: a finding still uploads immediately when the scan commits it."
54
+ " Defaults to STACKTRACE_FLEET_INTERVAL, then 5m.",
55
+ )
56
+ @click.option(
57
+ "--bom-interval",
58
+ default=None,
59
+ help="How often to upload endpoint composition. 0 disables it."
60
+ " Defaults to STACKTRACE_BOM_INTERVAL, then 6h.",
61
+ )
62
+ def run_command(
63
+ scan_interval: str | None,
64
+ scan_since: str | None,
65
+ agent_kinds: tuple[str, ...],
66
+ fleet_interval: str | None,
67
+ bom_interval: str | None,
68
+ ) -> None:
22
69
  _, _, run_daemon = _runtime()
23
- run_daemon(RuntimePaths.from_environment())
70
+ paths = RuntimePaths.from_environment()
71
+ try:
72
+ config = DaemonConfig.resolve(
73
+ scan_interval=scan_interval,
74
+ scan_since=scan_since,
75
+ agent_kinds=agent_kinds,
76
+ fleet_interval=fleet_interval,
77
+ bom_interval=bom_interval,
78
+ )
79
+ _reject_unobservable_kinds(config)
80
+ except ValueError as error:
81
+ raise click.ClickException(str(error)) from error
82
+ # The real daemon process: its stdout and stderr are the log file
83
+ # (`ensure_daemon`), so they follow it across a rollover (ADR-0048).
84
+ run_daemon(paths, config, own_std_streams=True)
85
+
86
+
87
+ def _reject_unobservable_kinds(config: DaemonConfig) -> None:
88
+ """A kind with no observer would scan nothing, silently.
89
+
90
+ Fails at startup rather than at the first tick: `cursor` and `codex` have
91
+ composition coverage but no runtime detection yet (`daemon-scanning.md`
92
+ § *Deferred*), and a daemon quietly finding nothing for one of them is the
93
+ failure this refusal exists to make loud.
94
+ """
95
+ from .runtime import OBSERVERS
96
+
97
+ unknown = [kind for kind in config.kinds if kind not in OBSERVERS]
98
+ if unknown:
99
+ known = ", ".join(sorted(OBSERVERS))
100
+ raise ValueError(
101
+ f"no session observer for {', '.join(unknown)}; this build observes {known}"
102
+ )
24
103
 
25
104
 
26
105
  @daemon.command()
27
106
  def status() -> None:
28
- """Show whether the local Stacktrace daemon is running."""
107
+ """Report whether this endpoint is working, without reading a log.
108
+
109
+ Every row answers a question someone asks when a machine looks quiet: a
110
+ watermark hours old means the scanner has stopped; a queue that grows with
111
+ a reader attached means the reader is not draining; a database that only
112
+ grows means retention is not pruning; and a configuration without its source
113
+ makes fleet-wide drift undebuggable (`docs/specs/daemon-scanning.md`).
114
+ """
29
115
  daemon_is_running, _, _ = _runtime()
116
+ from .runtime import last_error, read_run_state
117
+
30
118
  paths = RuntimePaths.from_environment()
31
- if not daemon_is_running(paths.socket):
32
- raise click.ClickException("Stacktrace daemon is not running")
33
- click.echo("Stacktrace daemon is running.")
119
+ running = daemon_is_running(paths.socket)
120
+ run_state = read_run_state(paths)
121
+
122
+ click.echo(f"running: {'yes' if running else 'no'}")
123
+ if isinstance(run_state, dict):
124
+ click.echo(f"pid: {run_state.get('pid')}")
125
+ click.echo(f"version: {run_state.get('version')}")
126
+ click.echo(f"started: {run_state.get('started_at')}")
127
+
128
+ try:
129
+ # One `_store()` call apiece, all against the same file: if the first
130
+ # (`_watermark_line`'s) open fails, every other one would fail
131
+ # identically, so one `try` around all five is enough, and none of
132
+ # them can print a partial line before that first failure.
133
+ click.echo(f"watermark: {_watermark_line(paths)}")
134
+ click.echo(f"queue depth: {_queue_line(paths)}")
135
+ click.echo(f"cursors: {_cursor_line(paths)}")
136
+ click.echo(f"parked: {_parked_line(paths)}")
137
+ click.echo(f"database: {_database_line(paths)}")
138
+ except (sqlite3.DatabaseError, ValueError) as exc:
139
+ # A corrupt file or a schema newer than this build reads raises here
140
+ # (`_check_version`'s own refusal) -- precisely the moment the daemon
141
+ # may have failed to start, so this diagnostic surface must report it
142
+ # rather than crash before reaching the log and configuration lines
143
+ # below, which need no store at all.
144
+ click.echo(f"database: unreadable — {exc}")
145
+ click.echo(f"last error: {last_error(paths.log) or 'none reported'}")
146
+
147
+ try:
148
+ config = _effective_config(run_state)
149
+ except ValueError as exc:
150
+ # No readable run-state means `resolve()`'s own environment fallback
151
+ # ran, and its validation is exactly what could be *why* the daemon
152
+ # never started -- the one diagnostic surface for that case must not
153
+ # itself crash on it. `from_document` above already never raises for
154
+ # this same reason; this matches it rather than adding a second rule.
155
+ click.echo(f"config: unresolvable — {exc}")
156
+ else:
157
+ click.echo("config:")
158
+ for name, entry in config.as_document().items():
159
+ click.echo(f" {name}: {entry['value']} ({entry['source']})")
160
+
161
+
162
+ def _effective_config(run_state: dict[str, object] | None) -> DaemonConfig:
163
+ """What the daemon resolved, when it is there to say so.
164
+
165
+ Falling back to this process's own environment is a worse answer and is
166
+ labelled as such by the sources it produces — it is still better than
167
+ printing nothing, because the common case for an operator running `status`
168
+ is a daemon that is not running at all.
169
+ """
170
+ if isinstance(run_state, dict) and isinstance(run_state.get("config"), dict):
171
+ return DaemonConfig.from_document(run_state["config"]) # type: ignore[arg-type]
172
+ return DaemonConfig.resolve()
173
+
174
+
175
+ def _store(paths: RuntimePaths) -> FindingStore | None:
176
+ if not paths.state.exists():
177
+ return None
178
+ # A reader, so it neither sweeps nor writes: `read_only` is SQLite's own
179
+ # `mode=ro`, which is what keeps an inspection command from racing the
180
+ # daemon's first `connect()` through schema creation (`db.connect`).
181
+ return FindingStore(paths.state, prune_on_open=False, read_only=True)
182
+
183
+
184
+ def _watermark_line(paths: RuntimePaths) -> str:
185
+ store = _store(paths)
186
+ if store is None:
187
+ return "never scanned (no database yet)"
188
+ moment = store.scan_watermark()
189
+ if moment is None:
190
+ return "never scanned"
191
+ age = datetime.now(UTC) - moment
192
+ return f"{moment.isoformat()} ({int(age.total_seconds())}s ago)"
193
+
194
+
195
+ def _queue_line(paths: RuntimePaths) -> str:
196
+ store = _store(paths)
197
+ return "0 (no database yet)" if store is None else f"{store.queue_depth()} rows"
198
+
199
+
200
+ def _cursor_line(paths: RuntimePaths) -> str:
201
+ """Where the Fleet drain has got to, per agent kind.
202
+
203
+ No row at all is the answer for an unconfigured endpoint, and it is the
204
+ right one: retention prunes below the cursor floor, so a subscriber that
205
+ cannot run must hold no cursor rather than one at zero.
206
+ """
207
+ store = _store(paths)
208
+ if store is None:
209
+ return "none (no database yet)"
210
+ cursors = store.cursors()
211
+ if not cursors:
212
+ return "none held (retention falls back to age alone)"
213
+ return ", ".join(f"{kind}@{seq}" for kind, seq in sorted(cursors.items()))
214
+
215
+
216
+ def _parked_line(paths: RuntimePaths) -> str:
217
+ """Rows the far side rejected on their own merits, newest first.
218
+
219
+ Read from the table rather than from memory, so a rejection that happened
220
+ before the last restart is not silently forgotten.
221
+ """
222
+ store = _store(paths)
223
+ if store is None:
224
+ return "0"
225
+ newest = store.newest_park()
226
+ if newest is None:
227
+ return f"{store.parked_count()}"
228
+ return (
229
+ f"{store.parked_count()} (newest: seq {newest['seq']} {newest['kind']} "
230
+ f"at {newest['parked_at']} — {newest['reason']})"
231
+ )
232
+
233
+
234
+ def _database_line(paths: RuntimePaths) -> str:
235
+ store = _store(paths)
236
+ if store is None:
237
+ return f"absent ({paths.state})"
238
+ return f"{store.database_size()} bytes ({paths.state})"
34
239
 
35
240
 
36
241
  @daemon.command()
@@ -59,6 +264,74 @@ def subscribe(agent_kind: str, session_id: str) -> None:
59
264
  raise click.ClickException(str(error)) from error
60
265
 
61
266
 
267
+ @daemon.command()
268
+ def flush() -> None:
269
+ """Upload what is queued now, without waiting for the next interval.
270
+
271
+ One drain pass, reported. A large backlog drains over several passes —
272
+ `_batches` splits by serialised size and the read is capped — so this
273
+ reports what one pass did rather than looping until the queue is empty.
274
+
275
+ There is no fallback to scanning. An endpoint with no daemon uploads by
276
+ re-collecting (`stacktrace remote sync detect`), and a command that
277
+ silently switched sources would make its output mean two different things.
278
+ """
279
+ daemon_is_running, _, _ = _runtime()
280
+ from .client import ProtocolError, request_flush
281
+
282
+ paths = RuntimePaths.from_environment()
283
+ if not daemon_is_running(paths.socket):
284
+ raise click.ClickException(
285
+ "no daemon is running, so there is no queue to drain; this endpoint "
286
+ "uploads by scanning instead — run `stacktrace remote sync detect`"
287
+ )
288
+ try:
289
+ result = request_flush(paths.socket)
290
+ except (OSError, ProtocolError) as error:
291
+ raise click.ClickException(f"flush failed: {error}") from error
292
+
293
+ click.echo(f"uploaded: {result.uploaded}")
294
+ click.echo(f"parked: {result.parked}")
295
+ click.echo(f"kinds: {', '.join(result.kinds) or 'none'}")
296
+ if result.stopped is not None:
297
+ # Not an echo: `flush && <something that ends the machine>` has to see
298
+ # a non-zero exit when nothing left.
299
+ raise click.ClickException(f"stopped: {result.stopped}")
300
+
301
+
302
+ @daemon.command("sync-bom")
303
+ def sync_bom() -> None:
304
+ """Upload the endpoint composition now, instead of on `--bom-interval`.
305
+
306
+ The same `sync_endpoint()` the scheduled job runs, serialised with it: two
307
+ runs share the spool directory and the config file the asset id is written
308
+ to. Progress still goes to the daemon log, and is echoed here as well so a
309
+ local run does not also require tailing it.
310
+
311
+ `stacktrace remote sync endpoint` does the same upload without a daemon.
312
+ This one exists to exercise the daemon's own path — the reporter, the
313
+ serialisation, and the composition the resident process is holding.
314
+ """
315
+ daemon_is_running, _, _ = _runtime()
316
+ from .client import ProtocolError, request_sync_bom
317
+
318
+ paths = RuntimePaths.from_environment()
319
+ if not daemon_is_running(paths.socket):
320
+ raise click.ClickException(
321
+ "no daemon is running; to upload without one, run `stacktrace remote sync endpoint`"
322
+ )
323
+ try:
324
+ result = request_sync_bom(paths.socket)
325
+ except (OSError, ProtocolError) as error:
326
+ raise click.ClickException(f"bom sync failed: {error}") from error
327
+
328
+ for line in result.report:
329
+ click.echo(line)
330
+ click.echo(f"uploaded: {result.uploaded}")
331
+ if result.failure is not None:
332
+ raise click.ClickException(result.failure)
333
+
334
+
62
335
  def _runtime():
63
336
  if os.name != "posix":
64
337
  raise click.ClickException(
@@ -72,15 +345,14 @@ def _runtime():
72
345
  @click.command()
73
346
  @click.option(
74
347
  "--agent-kind",
75
- required=True,
76
- help="Agent kind that owns the native session.",
348
+ default=None,
349
+ help="Only this agent kind. Every kind by default.",
77
350
  )
78
351
  @click.option(
79
352
  "--session",
80
353
  "session_id",
81
- envvar="CLAUDE_CODE_SESSION_ID",
82
- required=True,
83
- help="Native session ID. Defaults to CLAUDE_CODE_SESSION_ID.",
354
+ default=None,
355
+ help="Only this native session ID. Every session by default.",
84
356
  )
85
357
  @click.option(
86
358
  "--format",
@@ -89,18 +361,47 @@ def _runtime():
89
361
  default="text",
90
362
  show_default=True,
91
363
  )
92
- def findings(agent_kind: str, session_id: str, output_format: str) -> None:
93
- """Show Stacktrace findings for one native agent session."""
364
+ def findings(agent_kind: str | None, session_id: str | None, output_format: str) -> None:
365
+ """Show what Stacktrace has found on this machine.
366
+
367
+ Neither key is required, and `--session` deliberately does **not** read
368
+ `CLAUDE_CODE_SESSION_ID`: the unqualified command would then be silently
369
+ scoped to one session inside an agent session, which is the one place an
370
+ operator is most likely to run it and least likely to notice.
371
+ """
94
372
  paths = RuntimePaths.from_environment()
95
- stored = FindingStore(paths.state).findings(SessionKey(agent_kind, session_id))
373
+ # `_store()`, not a bare `FindingStore(...)`: a reader must not initialize
374
+ # the database as a side effect of reporting that there is none -- besides
375
+ # the surprise, two processes racing that (this command and a daemon's own
376
+ # first open) could both see an unversioned file and each try to insert
377
+ # the same schema_version row.
378
+ try:
379
+ store = _store(paths)
380
+ except (sqlite3.DatabaseError, ValueError) as exc:
381
+ # A corrupt file or a schema newer than this build reads raises here
382
+ # (`_check_version`'s own refusal) -- an inspection command must
383
+ # report that rather than exit on a traceback.
384
+ raise click.ClickException(f"database unreadable — {exc}") from exc
385
+ stored = () if store is None else store.recorded(agent_kind=agent_kind, session_id=session_id)
96
386
  if output_format == "json":
97
- click.echo(json.dumps({"findings": stored}, indent=2))
387
+ # Additive, not a new envelope: `findings --format json` shipped in
388
+ # v0.4.0 as a list of findings, and a reader of `finding["rule_id"]`
389
+ # keeps working. The row's own columns win over any same-named key in
390
+ # the payload, because they are what the store filed it under.
391
+ document = [
392
+ {**finding, "agent_kind": session.agent_kind, "session_id": session.session_id}
393
+ for session, finding in stored
394
+ ]
395
+ click.echo(json.dumps({"findings": document}, indent=2))
98
396
  return
99
397
  if not stored:
100
- click.echo("No Stacktrace findings for this session.")
101
- return
102
- for finding in stored:
103
398
  click.echo(
104
- f"{str(finding.get('severity', 'unknown')).upper()} "
105
- f"{finding.get('rule_id', 'unknown-rule')}: {finding.get('title', 'Finding')}"
399
+ "No Stacktrace findings for this session." if session_id else "No Stacktrace findings."
106
400
  )
401
+ return
402
+ for session, finding in stored:
403
+ # The session is named on every row, not grouped into a header: the
404
+ # list is read by eye and by grep, and a header is lost to both once
405
+ # the output is filtered.
406
+ click.echo(f"[{session.agent_kind}:{session.session_id}]")
407
+ click.echo(render_finding(finding))