stacktrace-cli 0.5.2__py3-none-any.whl → 0.6.0__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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  import logging as _logging
4
4
 
5
- __version__ = "0.5.2"
5
+ __version__ = "0.6.0"
6
6
 
7
7
  # Only the daemon attaches a handler (ADR-0048). Without this one, a WARNING
8
8
  # logged in any other process would reach Python's last-resort handler and
@@ -204,7 +204,7 @@ def analyse(
204
204
  root=root,
205
205
  session_ids=session_ids,
206
206
  )
207
- return _analyse_sessions(
207
+ found = _analyse_sessions(
208
208
  sessions,
209
209
  window_start=window_start,
210
210
  window_end=window_end,
@@ -220,6 +220,7 @@ def analyse(
220
220
  jev_key=jev_key,
221
221
  history=history,
222
222
  )
223
+ return scoped_to_window(found)
223
224
 
224
225
 
225
226
  def analyse_sessions(
@@ -347,30 +348,63 @@ def _acquire(
347
348
  )
348
349
 
349
350
 
350
- def _correlated(
351
- *,
352
- agent_kinds: tuple[str, ...],
353
- window_start: datetime,
354
- bom_paths: tuple[Path, ...],
355
- project_map: tuple[str, ...],
356
- root: Path | None,
357
- session_ids: tuple[str, ...],
358
- attach_advisories: Any,
359
- build_all: Any = None,
360
- ) -> Acquired:
361
- """Everything up to the judging, shared by both entry points."""
362
- return _acquire(
363
- _collected(
364
- agent_kinds=agent_kinds,
365
- window_start=window_start,
366
- root=root,
367
- session_ids=session_ids,
368
- ),
369
- bom_paths=bom_paths,
370
- project_map=project_map,
371
- attach_advisories=attach_advisories,
372
- build_all=build_all,
351
+ def _reaches(detection: Any, window_start: datetime, window_end: datetime) -> bool:
352
+ """Whether this finding's own span overlaps the window.
353
+
354
+ Both ends are tested, because both can be crossed. `occurred_end` against
355
+ the start, so a rule firing across a run of turns belongs in the window
356
+ its last citation reaches; `occurred_start` against the end, so a finding
357
+ straddling the boundary is still reported rather than dropped for
358
+ finishing late.
359
+
360
+ The upper bound is not ceremony. `analyse` takes `window_end` *before*
361
+ collection -- deliberately, so a window cannot claim to cover a session
362
+ that started while the pipeline ran -- and collection then reads files
363
+ that may have been appended to in between. A finding can therefore carry
364
+ a clock later than the end the caller is shown, and reporting it would
365
+ contradict the heading the window prints.
366
+
367
+ A finding with no clock, or one whose clock is naive and cannot be
368
+ compared against an aware bound, is reported rather than judged: hiding a
369
+ real finding over a missing field is the more expensive mistake.
370
+ """
371
+ end = detection.occurred_end or detection.occurred_start
372
+ start = detection.occurred_start or detection.occurred_end
373
+ if end is None or start is None or end.tzinfo is None or start.tzinfo is None:
374
+ return True
375
+ return end >= window_start and start <= window_end
376
+
377
+
378
+ def scoped_to_window(analysis: Analysis) -> Analysis:
379
+ """The same analysis, reporting only the findings the window reaches.
380
+
381
+ A window selects whole transcripts by file mtime, so a session written
382
+ inside it is collected entire and every rule walks all of its turns. A
383
+ long session still being typed into therefore carried its whole history
384
+ into whatever range the caller asked for: a 538-turn session last active a
385
+ minute ago put an 18-minute-old block in a five-minute window.
386
+
387
+ Only ever narrowing, and that is not a coincidence. A finding's clock
388
+ comes from the turns it cites, and a transcript's mtime is its last write,
389
+ so mtime is never earlier than `occurred_end`. A finding inside the window
390
+ therefore always sits in a file the window already admitted -- this can
391
+ drop what the mtime rule over-admitted, and can never need to recover
392
+ anything it excluded.
393
+
394
+ Applied by `analyse`, so `detect`, `monitor` and `sync detect` cannot
395
+ disagree about one `--since` (`cli.py` states that invariant). Not by
396
+ `analyse_sessions`: the daemon passes a watermark rather than a range a
397
+ person chose, and its findings are the record that feeds the store, the
398
+ webhook and Fleet, where a window has no business narrowing anything.
399
+ """
400
+ kept = tuple(
401
+ d
402
+ for d in analysis.run.detections
403
+ if _reaches(d, analysis.window_start, analysis.window_end)
373
404
  )
405
+ if len(kept) == len(analysis.run.detections):
406
+ return analysis
407
+ return replace(analysis, run=replace(analysis.run, detections=kept))
374
408
 
375
409
 
376
410
  def _unplaced(sessions: SessionView, *, window_start: datetime, window_end: datetime) -> Analysis:
@@ -422,6 +456,7 @@ def analyse_progressively(
422
456
  history: DriftHistory | None = None,
423
457
  reasoning: bool = False,
424
458
  budget: int = DEFAULT_BUDGET,
459
+ exclude_from_reasoning: frozenset[tuple[str, str]] = frozenset(),
425
460
  ) -> Iterator[Analysis]:
426
461
  """The same pipeline, delivered in instalments.
427
462
 
@@ -498,11 +533,13 @@ def analyse_progressively(
498
533
  stage carrying fewer sessions than the preview did would make the count
499
534
  on the page go backwards.
500
535
  """
501
- return Analysis(
502
- run=run,
503
- view=replace(view, sessions=sessions),
504
- window_start=window_start,
505
- window_end=window_end,
536
+ return scoped_to_window(
537
+ Analysis(
538
+ run=run,
539
+ view=replace(view, sessions=sessions),
540
+ window_start=window_start,
541
+ window_end=window_end,
542
+ )
506
543
  )
507
544
 
508
545
  # The detector derives these from the view, and a stage that has not judged
@@ -572,12 +609,20 @@ def analyse_progressively(
572
609
  if not reasoning or not ordered:
573
610
  return
574
611
 
575
- # One more instalment, one run, one budget. Everything above has already
576
- # been published, so the page is complete and readable while this is in
577
- # flight — on a large window it is several hundred sequential requests and
578
- # holding the first paint behind it is what made the page look hung.
612
+ excluded_count = 0
613
+ if exclude_from_reasoning:
614
+ kept = tuple(
615
+ s
616
+ for s in view.sessions
617
+ if (s.session.session_id, s.session.agent_kind) not in exclude_from_reasoning
618
+ )
619
+ excluded_count = len(view.sessions) - len(kept)
620
+ reasoning_view = replace(view, sessions=kept) if excluded_count else view
621
+ else:
622
+ reasoning_view = view
623
+
579
624
  judged = run_detector(
580
- view,
625
+ reasoning_view,
581
626
  reasoning=True,
582
627
  budget=budget,
583
628
  cache=cache,
@@ -585,4 +630,23 @@ def analyse_progressively(
585
630
  jev_key=jev_key,
586
631
  history=history,
587
632
  )
633
+
634
+ if excluded_count:
635
+ extra_detections = tuple(
636
+ d
637
+ for d in accumulated.detections
638
+ if (d.session.session_id, d.session.agent_kind) in exclude_from_reasoning
639
+ )
640
+ extra_unknowns = tuple(
641
+ u
642
+ for u in accumulated.unknowns
643
+ if (u.session.session_id, u.session.agent_kind) in exclude_from_reasoning
644
+ )
645
+ judged = replace(
646
+ judged,
647
+ detections=judged.detections + extra_detections,
648
+ unknowns=judged.unknowns + extra_unknowns,
649
+ sessions=judged.sessions + excluded_count,
650
+ )
651
+
588
652
  yield _stage(judged, tuple(ordered))
@@ -12,6 +12,16 @@ CLI and runs this:
12
12
  uv tool install stacktrace-cli
13
13
  stacktrace-cloud-setup
14
14
 
15
+ Or, equivalently, one hosted line that wraps exactly those two steps (see
16
+ `stacktrace-site`'s `public/remote-install.sh`). Download before running rather
17
+ than piping directly into `sh`: without `pipefail` (the default in the `sh`
18
+ that a setup-script box runs), a failed `curl` still lets `sh` read an empty
19
+ script and exit 0, so a broken download would otherwise snapshot an
20
+ environment with neither the CLI nor the boot hook and report success:
21
+
22
+ curl -fsSL https://stacktrace.ai/remote-install.sh -o /tmp/remote-install.sh \
23
+ && sh /tmp/remote-install.sh
24
+
15
25
  Both variables matter: setup runs as root, and `uv tool install`'s root
16
26
  defaults put the tool's environment under `/root/.local/share/uv/tools` and the
17
27
  linked executable under `/root/.local/bin` — both inside `/root`, which a later
@@ -10,7 +10,10 @@
10
10
  # every home so it fires whichever user the session runs as.
11
11
  # 2. The asset identity, in every home's ~/.config/stacktrace/asset-id, so
12
12
  # `stacktrace remote sync` resolves it at the persisted rung (ADR-0065)
13
- # instead of minting a fresh asset per session.
13
+ # instead of minting a fresh asset per session. Beside it,
14
+ # asset-display-prefix makes the fleet label `cloud (<id>)` rather than
15
+ # the image's hostname (`vm`), which is the same on every box. Label
16
+ # only: the key above is what identifies the asset.
14
17
  #
15
18
  # The telemetry install id is deliberately NOT seeded here. Seeding it would
16
19
  # make `read_install_id()` non-None before any event runs, so the one-time
@@ -39,9 +42,14 @@
39
42
  # from STACKTRACE_ASSET_EXTERNAL_ID in the environment
40
43
  # --root DIR treat DIR as / (for testing)
41
44
  #
42
- # The remote token and API URL are read by the boot hook at session time from
43
- # STACKTRACE_REMOTE_TOKEN and STACKTRACE_REMOTE_API_URL, not here: they belong
44
- # to the environment's variables, not to the snapshot this seeds.
45
+ # The remote token and API URL, and the Slack webhook URL, are read by the boot
46
+ # hook at session time from STACKTRACE_REMOTE_TOKEN, STACKTRACE_REMOTE_API_URL
47
+ # and STACKTRACE_SLACK_WEBHOOK_URL, not here: they belong to the environment's
48
+ # variables, not to the snapshot this seeds. The webhook in particular must be
49
+ # configured by the boot hook and not by this script, because
50
+ # `webhook configure` writes `$HOME/.config/stacktrace/webhook.toml` and this
51
+ # script runs as root — a webhook configured here would land in /root and the
52
+ # daemon, running as the session user, would read a home that has none.
45
53
  set -uo pipefail
46
54
 
47
55
  ASSET_ID="${STACKTRACE_ASSET_EXTERNAL_ID:-}"
@@ -54,7 +62,7 @@ while [ $# -gt 0 ]; do
54
62
  # the loop spins forever — a malformed invocation would hang setup.
55
63
  --asset-id) [ $# -ge 2 ] || { echo "✗ --asset-id needs a value" >&2; exit 2; }; ASSET_ID="$2"; shift 2 ;;
56
64
  --root) [ $# -ge 2 ] || { echo "✗ --root needs a value" >&2; exit 2; }; ROOT="$2"; shift 2 ;;
57
- -h|--help) sed -n '2,44p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
65
+ -h|--help) sed -n '2,52p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
58
66
  *) echo "✗ unknown argument: $1" >&2; exit 2 ;;
59
67
  esac
60
68
  done
@@ -73,6 +81,7 @@ _uuid() {
73
81
  fi
74
82
  }
75
83
 
84
+ DISPLAY_PREFIX="cloud"
76
85
  [ -n "$ASSET_ID" ] || ASSET_ID="cloud-$(_uuid)"
77
86
  # remote/identity.py:_is_identifier rejects whitespace, non-printable
78
87
  # characters, and anything over 255 characters. A value this accepts but the
@@ -108,19 +117,45 @@ cat > "$BOOT" <<'BOOTSCRIPT' || { echo "✗ cannot write $BOOT" >&2; exit 1; }
108
117
  set -u
109
118
  [ "${CLAUDE_CODE_REMOTE:-}" = "true" ] || exit 0
110
119
  command -v stacktrace >/dev/null 2>&1 || exit 0
111
- [ -n "${STACKTRACE_REMOTE_TOKEN:-}" ] || exit 0
120
+ # A webhook is an independent subscriber, not a Fleet feature (ADR-0067): a
121
+ # session with only STACKTRACE_SLACK_WEBHOOK_URL set still needs the daemon
122
+ # running to drain it, so the remote token gates `remote configure` alone,
123
+ # never this whole hook.
124
+ if [ -z "${STACKTRACE_REMOTE_TOKEN:-}" ] && [ -z "${STACKTRACE_SLACK_WEBHOOK_URL:-}" ]; then
125
+ exit 0
126
+ fi
112
127
 
113
128
  STATE="$HOME/.local/state/stacktrace"
114
129
  mkdir -p "$STATE" && chmod 700 "$STATE"
115
130
 
116
- stacktrace remote configure \
117
- --token "$STACKTRACE_REMOTE_TOKEN" \
118
- --api-url "${STACKTRACE_REMOTE_API_URL:-https://api.stacktrace.ai}" >/dev/null 2>&1 || true
131
+ if [ -n "${STACKTRACE_REMOTE_TOKEN:-}" ]; then
132
+ stacktrace remote configure \
133
+ --token "$STACKTRACE_REMOTE_TOKEN" \
134
+ --api-url "${STACKTRACE_REMOTE_API_URL:-https://api.stacktrace.ai}" >/dev/null 2>&1 || true
135
+ fi
136
+
137
+ # The Slack endpoint is session-time configuration like the token above, not
138
+ # part of the snapshot: written here it lands in the session user's home, which
139
+ # is the home the daemon started below reads `webhook.toml` from. Rotating the
140
+ # URL is then an environment-variable change, with no snapshot to rebuild.
141
+ # `--secret` is always passed explicitly, empty if unset: the option prompts
142
+ # when neither it nor STACKTRACE_WEBHOOK_SECRET is given, and a prompt in a hook
143
+ # with no terminal aborts the command. Configured before the daemon starts, so
144
+ # the daemon reads the endpoint at startup rather than waiting for a drain.
145
+ if [ -n "${STACKTRACE_SLACK_WEBHOOK_URL:-}" ]; then
146
+ stacktrace webhook configure \
147
+ --url "$STACKTRACE_SLACK_WEBHOOK_URL" \
148
+ --secret "${STACKTRACE_WEBHOOK_SECRET:-}" >/dev/null 2>&1 || true
149
+ fi
119
150
 
120
151
  # Streams redirected because own_std_streams only re-points fd 1/2 on log
121
152
  # rollover; without this the daemon inherits the hook's pipe to Claude Code.
153
+ # --no-reasoning is explicit, not just `run`'s own default (ADR-0081): this
154
+ # hook never provisions TYPESAFE_API_KEY itself, but a cloud VM's ambient
155
+ # environment is not something this script controls or can audit ahead of
156
+ # time, and this surface has nobody present to notice if that changed.
122
157
  if ! stacktrace daemon status 2>/dev/null | grep -q '^running: yes'; then
123
- setsid nohup stacktrace daemon run </dev/null >>"$STATE/daemon.log" 2>&1 &
158
+ setsid nohup stacktrace daemon run --no-reasoning </dev/null >>"$STATE/daemon.log" 2>&1 &
124
159
  fi
125
160
  exit 0
126
161
  BOOTSCRIPT
@@ -149,6 +184,11 @@ for home in "${ROOT}"/root "${ROOT}"/home/*; do
149
184
  fi
150
185
  chmod 600 "$cfg/asset-id" 2>/dev/null || true
151
186
 
187
+ if ! printf '%s\n' "$DISPLAY_PREFIX" > "$cfg/asset-display-prefix" 2>/dev/null; then
188
+ echo "✗ cannot write $cfg/asset-display-prefix — not seeding $home" >&2; failed=1; continue
189
+ fi
190
+ chmod 600 "$cfg/asset-display-prefix" 2>/dev/null || true
191
+
152
192
  # Register the SessionStart hook, merging so any existing settings survive.
153
193
  # The hook is the point of seeding a home — a home whose registration fails
154
194
  # (an unwritable path, or a settings.json that is valid JSON of an unexpected
@@ -221,7 +261,7 @@ PY
221
261
  for d in "$home/.config" "$cfg" "$home/.claude"; do
222
262
  [ -e "$d" ] && chown --reference="$home" "$d" 2>/dev/null || true
223
263
  done
224
- chown --reference="$home" "$cfg/asset-id" \
264
+ chown --reference="$home" "$cfg/asset-id" "$cfg/asset-display-prefix" \
225
265
  "$home/.claude/settings.json" 2>/dev/null || true
226
266
 
227
267
  echo "✓ seeded $home"
@@ -1381,9 +1381,13 @@ _SEPARATORS = ("&&", "||", ";", "|", "\n")
1381
1381
  _WORD_START_BEFORE_COMMENT = frozenset(" \t\n;&|<>()")
1382
1382
 
1383
1383
 
1384
- def _split_on_separators(command: str) -> list[str]:
1384
+ def _split_on_separators(command: str) -> list[tuple[str, str | None]]:
1385
1385
  """The commands in a shell line, split only where the shell would split it.
1386
1386
 
1387
+ Returns ``(segment_text, preceding_operator)`` pairs. The first segment's
1388
+ operator is ``None``; every later segment carries the ``&&``, ``||``, ``;``,
1389
+ ``|`` or ``\\n`` that separated it from the one before it.
1390
+
1387
1391
  A control operator is an operator **only unquoted and unescaped** — that is
1388
1392
  the shell's own grammar, and it is verified against `bash` itself in
1389
1393
  `tests/test_shell_separators_match_the_shell.py` rather than asserted here.
@@ -1420,10 +1424,11 @@ def _split_on_separators(command: str) -> list[str]:
1420
1424
  left in, `shlex` hands the caller a bare newline where the next word should
1421
1425
  be, and `git \\<newline> push` loses its subcommand to it.
1422
1426
  """
1423
- parts: list[str] = []
1427
+ parts: list[tuple[str, str | None]] = []
1424
1428
  current: list[str] = []
1425
1429
  index = 0
1426
1430
  quote: str | None = None
1431
+ preceding_op: str | None = None
1427
1432
  #: Whether a `#` here would open a comment. True at the start of the string
1428
1433
  #: and after anything that ends a word; false after any character a word is
1429
1434
  #: made of, quotes included -- `echo "a"#b` prints `a#b`.
@@ -1475,11 +1480,20 @@ def _split_on_separators(command: str) -> list[str]:
1475
1480
  index += 1
1476
1481
  at_word_start = char in _WORD_START_BEFORE_COMMENT
1477
1482
  continue
1478
- parts.append("".join(current))
1483
+ segment_text = "".join(current)
1484
+ if segment_text.strip():
1485
+ parts.append((segment_text, preceding_op))
1486
+ preceding_op = separator
1487
+ else:
1488
+ # Two separators with nothing between them (e.g. "&&\n"):
1489
+ # keep the conditional operator over a plain terminator so
1490
+ # the next real segment inherits the right dependency.
1491
+ if separator in ("&&", "||") or preceding_op is None:
1492
+ preceding_op = separator
1479
1493
  current = []
1480
1494
  index += len(separator)
1481
1495
  at_word_start = True
1482
- parts.append("".join(current))
1496
+ parts.append(("".join(current), preceding_op))
1483
1497
  return parts
1484
1498
 
1485
1499
 
@@ -1521,7 +1535,7 @@ def _segments_of(text: str, depth: int) -> tuple[tuple[str, tuple[str, ...]], ..
1521
1535
  `command_segments` would run the masks a second time on their own output.
1522
1536
  """
1523
1537
  found: list[tuple[str, tuple[str, ...]]] = []
1524
- for part in _split_on_separators(text):
1538
+ for part, _op in _split_on_separators(text):
1525
1539
  # Comments are gone: `_split_on_separators` discards one at the word
1526
1540
  # start that opens it, which is the only place the shell begins one.
1527
1541
  # This used to skip a segment *beginning* with `#`, which caught the
@@ -1567,6 +1581,48 @@ def _segments_of(text: str, depth: int) -> tuple[tuple[str, tuple[str, ...]], ..
1567
1581
  return tuple(found)
1568
1582
 
1569
1583
 
1584
+ def command_segments_with_ops(
1585
+ command: object, depth: int = 0
1586
+ ) -> tuple[tuple[str, tuple[str, ...], str | None], ...]:
1587
+ """Like `command_segments`, but each entry also carries the shell operator
1588
+ that preceded it (``None`` for the first segment, then ``&&``, ``||``,
1589
+ ``;``, ``|`` or ``\\n``).
1590
+
1591
+ Only `_resolve_rm_targets` in the deterministic detector needs the operator
1592
+ context — to decide whether an inline ``cd`` reliably changed the directory
1593
+ and whether an ``rm`` segment is unconditionally reachable. Every other
1594
+ caller uses the plain `command_segments`, which discards the operator.
1595
+ """
1596
+ if not isinstance(command, str) or not command:
1597
+ return ()
1598
+ text = _readable_commands(command, depth)
1599
+ found: list[tuple[str, tuple[str, ...], str | None]] = []
1600
+ for part, preceding_op in _split_on_separators(text):
1601
+ part = part.strip()
1602
+ if not part:
1603
+ continue
1604
+ try:
1605
+ tokens = shlex.split(part)
1606
+ except ValueError:
1607
+ continue
1608
+ while tokens and (_ASSIGNMENT.match(tokens[0]) or tokens[0] in _NOT_A_BINARY):
1609
+ if tokens.pop(0) == "time":
1610
+ while tokens and tokens[0] in _TIME_OPTIONS:
1611
+ tokens.pop(0)
1612
+ if not tokens:
1613
+ continue
1614
+ head = tokens[0].rsplit("/", 1)[-1]
1615
+ if not head or all(char in _OPERATOR_CHARS for char in head):
1616
+ continue
1617
+ arguments = tuple(tokens[1:])
1618
+ found.append((head, arguments, preceding_op))
1619
+ if head in _SHELLS and depth < _MAX_SHELL_DEPTH:
1620
+ body = _shell_command_body(arguments)
1621
+ if body is not None:
1622
+ found.extend(command_segments_with_ops(body, depth + 1))
1623
+ return tuple(found)
1624
+
1625
+
1570
1626
  def redirect_targets(command: object) -> tuple[str, ...]:
1571
1627
  """Files a shell command redirects its output into.
1572
1628
 
@@ -1604,7 +1660,7 @@ def redirect_targets(command: object) -> tuple[str, ...]:
1604
1660
  # body, an arithmetic comparison, a `case` frame or the body of a function
1605
1661
  # nothing called is not a redirection, and each was named as a file before
1606
1662
  # its mask existed.
1607
- for part in _split_on_separators(_readable_commands(command, 0)):
1663
+ for part, _op in _split_on_separators(_readable_commands(command, 0)):
1608
1664
  part = part.strip()
1609
1665
  if not part:
1610
1666
  continue
@@ -14,7 +14,7 @@ from typing import TypeVar
14
14
 
15
15
  import click
16
16
 
17
- from .config import DaemonConfig
17
+ from .config import DaemonConfig, parse_reasoning
18
18
  from .paths import RuntimePaths
19
19
  from .presentation import render_finding
20
20
  from .store import FindingStore, SessionKey
@@ -117,6 +117,24 @@ def _retry_while_starting(
117
117
  help="How often to upload endpoint composition. 0 disables it."
118
118
  " Defaults to STACKTRACE_BOM_INTERVAL, then 6h.",
119
119
  )
120
+ @click.option(
121
+ "--reasoning/--no-reasoning",
122
+ # Declared `False`, not `None`: `test_the_reasoning_stage_is_off_by_
123
+ # default_everywhere_it_can_be_asked_for` reads this literal value off
124
+ # every command in the tree, on the theory that a default legible only
125
+ # after tracing what `DaemonConfig.resolve` does with `None` is exactly
126
+ # the kind of default that drifts unnoticed (ADR-0029). Whether the flag
127
+ # was actually typed is recovered below via `get_parameter_source`, the
128
+ # same "flag beats environment beats default" ladder every other option
129
+ # here gets from `resolve` seeing `None` -- Click just won't hand this one
130
+ # a `None` to pass through.
131
+ default=False,
132
+ hidden=True,
133
+ help="Analyse changed sessions with a reasoning model (stage three). Off"
134
+ " by default: pass --reasoning to opt in, and set TYPESAFE_API_KEY so"
135
+ " there is something to answer with. Defaults to STACKTRACE_REASONING,"
136
+ " then false.",
137
+ )
120
138
  def run_command(
121
139
  scan_interval: str | None,
122
140
  scan_since: str | None,
@@ -124,10 +142,17 @@ def run_command(
124
142
  fleet_interval: str | None,
125
143
  webhook_interval: str | None,
126
144
  bom_interval: str | None,
145
+ reasoning: bool,
127
146
  ) -> None:
128
147
  """Run the daemon in the foreground for a host supervisor."""
129
148
  _, _, run_daemon = _runtime()
130
149
  paths = RuntimePaths.from_environment()
150
+ ctx = click.get_current_context()
151
+ reasoning_flag = (
152
+ reasoning
153
+ if ctx.get_parameter_source("reasoning") is click.core.ParameterSource.COMMANDLINE
154
+ else None
155
+ )
131
156
  try:
132
157
  config = DaemonConfig.resolve(
133
158
  scan_interval=scan_interval,
@@ -136,17 +161,32 @@ def run_command(
136
161
  fleet_interval=fleet_interval,
137
162
  webhook_interval=webhook_interval,
138
163
  bom_interval=bom_interval,
164
+ reasoning=reasoning_flag,
139
165
  )
140
166
  _reject_unobservable_kinds(config)
141
167
  except ValueError as error:
142
168
  raise click.ClickException(str(error)) from error
143
- from .lifecycle import DETACHED_ENV
169
+ from .lifecycle import DETACHED_ENV, StartOutcome
170
+
171
+ detached = os.environ.get(DETACHED_ENV) == "1"
172
+
173
+ def report_ready() -> None:
174
+ if not detached:
175
+ click.echo(
176
+ "Stacktrace daemon is running in the foreground. Press Ctrl-C to stop.",
177
+ err=True,
178
+ )
144
179
 
145
180
  status = run_daemon(
146
181
  paths,
147
182
  config,
148
- own_std_streams=os.environ.get(DETACHED_ENV) == "1",
183
+ own_std_streams=detached,
184
+ on_ready=report_ready,
149
185
  )
186
+ if status is StartOutcome.ALREADY_RUNNING:
187
+ if not detached:
188
+ click.echo("Stacktrace daemon is already running.")
189
+ return
150
190
  if status:
151
191
  raise click.exceptions.Exit(status)
152
192
 
@@ -317,13 +357,79 @@ def _database_line(paths: RuntimePaths) -> str:
317
357
  return f"{store.database_size()} bytes ({paths.state})"
318
358
 
319
359
 
360
+ _JEV_KEY_ENV = "TYPESAFE_API_KEY"
361
+
362
+
363
+ def _ensure_jev_key() -> None:
364
+ """Prompt for the jev key when it is not already in the environment.
365
+
366
+ Sets the key in ``os.environ`` so the forked daemon inherits it.
367
+ An empty response or a non-interactive terminal skips — reasoning
368
+ degrades gracefully when the analyzer reports unavailable.
369
+ """
370
+ if os.environ.get(_JEV_KEY_ENV):
371
+ return
372
+ if not sys.stdin.isatty():
373
+ return
374
+ key = click.prompt(
375
+ "TYPESAFE_API_KEY for reasoning (Enter to skip)",
376
+ default="",
377
+ hide_input=True,
378
+ show_default=False,
379
+ )
380
+ if key:
381
+ os.environ[_JEV_KEY_ENV] = key
382
+
383
+
320
384
  @daemon.command()
321
- def start() -> None:
385
+ @click.option(
386
+ "--reasoning/--no-reasoning",
387
+ default=False,
388
+ hidden=True,
389
+ help="Analyse changed sessions with a reasoning model (stage three). Off"
390
+ " by default: pass --reasoning to opt in for this run, and set"
391
+ " TYPESAFE_API_KEY so there is something to answer with.",
392
+ )
393
+ def start(reasoning: bool) -> None:
322
394
  """Start one detached daemon when no host service manager is available."""
323
395
  _runtime()
324
396
  from .lifecycle import StartOutcome, start_daemon, transition_lock
325
397
 
398
+ # `start_daemon` forks/execs inheriting `os.environ` as it stands right
399
+ # here -- the same route `_ensure_jev_key` uses to hand the detached
400
+ # process a key it just prompted for, and now the only route
401
+ # `--reasoning`/`--no-reasoning` has to reach that process's own
402
+ # `DaemonConfig.resolve()` too, which reads `STACKTRACE_REASONING`. When
403
+ # the flag was not actually typed, the same "flag beats environment beats
404
+ # default" ladder `run_command` gets from `resolve()` applies here too:
405
+ # an operator who already exported `STACKTRACE_REASONING=true` before
406
+ # calling `start` (the documented way to opt in a detached daemon,
407
+ # config.py's own comment on `SETTINGS`) must not have that silently
408
+ # overwritten by this command's unrelated `False` click default.
409
+ ctx = click.get_current_context()
410
+ explicit = ctx.get_parameter_source("reasoning") is click.core.ParameterSource.COMMANDLINE
411
+ if explicit:
412
+ enabled = reasoning
413
+ else:
414
+ inherited = os.environ.get("STACKTRACE_REASONING")
415
+ try:
416
+ enabled = inherited is not None and parse_reasoning(inherited)
417
+ except ValueError as error:
418
+ raise click.ClickException(str(error)) from error
419
+ if enabled:
420
+ _ensure_jev_key()
421
+ os.environ["STACKTRACE_REASONING"] = "true"
422
+ else:
423
+ # An explicit --no-reasoning, or no opt-in at all: either way, do not
424
+ # let a key already present in the *caller's* environment (a shell
425
+ # profile, an exported var) reach the daemon and reason anyway --
426
+ # that is exactly the implicit activation this opt-in exists to
427
+ # prevent.
428
+ os.environ.pop(_JEV_KEY_ENV, None)
429
+ os.environ["STACKTRACE_REASONING"] = "false"
430
+
326
431
  paths = RuntimePaths.from_environment()
432
+ click.echo("Starting Stacktrace daemon...", err=True)
327
433
  try:
328
434
  with transition_lock(paths.transition_lock):
329
435
  outcome = start_daemon(paths)
@@ -342,6 +448,7 @@ def stop() -> None:
342
448
  from .lifecycle import daemon_lock_held, transition_lock
343
449
 
344
450
  paths = RuntimePaths.from_environment()
451
+ click.echo("Stopping Stacktrace daemon...", err=True)
345
452
  with transition_lock(paths.transition_lock):
346
453
  if not daemon_lock_held(paths.lock):
347
454
  click.echo("Stacktrace daemon is not running.")
@@ -416,6 +523,7 @@ def flush() -> None:
416
523
  "no daemon is running, so there is no queue to drain; this endpoint "
417
524
  "uploads by scanning instead — run `stacktrace remote sync detect`"
418
525
  )
526
+ click.echo("Flushing queued findings...", err=True)
419
527
  try:
420
528
  result = request_flush(paths.socket)
421
529
  except (OSError, ProtocolError) as error:
@@ -451,6 +559,7 @@ def sync_bom() -> None:
451
559
  raise click.ClickException(
452
560
  "no daemon is running; to upload without one, run `stacktrace remote sync endpoint`"
453
561
  )
562
+ click.echo("Syncing endpoint composition...", err=True)
454
563
  try:
455
564
  result = request_sync_bom(paths.socket)
456
565
  except (OSError, ProtocolError) as error: