Skip to content

feat(doctor): notice when Claude usage cannot be recorded at all - #129

Merged
pitimon merged 4 commits into
mainfrom
feat/128-transcript-suppression-detector
Jul 30, 2026
Merged

feat(doctor): notice when Claude usage cannot be recorded at all#129
pitimon merged 4 commits into
mainfrom
feat/128-transcript-suppression-detector

Conversation

@pitimon

@pitimon pitimon commented Jul 30, 2026

Copy link
Copy Markdown
Owner

PR Goal (one sentence)

Make TokenTracker able to say "some Claude usage is not being recorded" instead of silently reporting a source that has gone unobservable — the failure behind #128.

Why

Every Claude number in this product is parsed out of the session transcripts under ~/.claude/projects. A CLI started with --no-session-persistence writes none, so those calls burn real tokens and contribute zero. The only symptom is a source reporting less than it should, which is indistinguishable from a quiet day. On the machine that found this, a gateway-routed stream stopped being counted on 2026-07-28 and nothing said so for two days — tokentracker doctor read ok 9 | warn 1 | fail 0 | critical 0 the entire time.

This follows the same principle as #105: a chip that silently disappears when its fetcher breaks is worse than no chip, because the user has been trained to read absence as "nothing happened".

What was tried first and rejected

The original proposal in #128 was source-staleness detection. It does not work, and the replay is in the issue: every Claude model aggregates under source=claude, so the source read as active 0.4h ago while the affected model had been silent 37h. It would have missed the exact incident it was written for. At (source, model) granularity it does fire, but against the one machine it has been tested on it produced 3 false positives for 2 true ones — a user retiring a model is indistinguishable from a stream breaking. Parked rather than shipped.

What ships here is the live signal instead: read the process list, answer "is something running right now that I cannot see". No baseline, no threshold, no history, and it would have fired the same evening.

Scope

  • CLI (src/)
  • Dashboard (dashboard/)
  • macOS app (TokenTrackerBar/)
  • Windows app (TokenTrackerWin/)
  • Docs / CI / config

Checklist

  • npm run ci:local passes (exit 0: dashboard build, 41 dashboard test files / 308 tests, 992 CLI tests, all six validators, OpenWiki fact check 0 findings)
  • New dashboard/user-facing strings go through dashboard/src/content/copy.csv
  • Commits follow conventional style
  • PR description explains why, not just what
  • Version bump files are in lockstep (0.40.0 across all four)
  • npm publish state — not published. npm-publish.yml fires on push to main, so merging this releases it. The bump is a separate commit (6e38d1c) precisely so it can be dropped if this should ride a later release instead.

What changed

File
src/lib/process-list.js newparseProcessLine moved here so two callers share one copy; listProcessLines() returns {supported, ok, lines, reason} so "no /bin/ps on this platform" and "ps refused" stay distinguishable
src/lib/transcript-suppression.js new — the detector, with a 30s TTL cache and injected commandRunner
src/lib/doctor.js ingest.transcript_suppressed check + the degraded field
src/commands/doctor.js threads a one-shot (cache-bypassing) detection into the report
src/lib/local-api.js GET /functions/tokentracker-ingest-health
src/lib/usage-limits.js uses the shared parseProcessLine; behaviour and tests unchanged (45/45)
dashboard/…/IngestHealthNotice.jsx new — amber notice, markup copied from CopilotOtelHint, role="note"
dashboard/src/pages/DashboardPage.jsx mounts it above the hero. ~178 of the 209 changed lines are pure re-indent from the fragment wrap — review with git diff -w, which shows 31
openwiki/ endpoint + degraded documented; source-facts.json regenerated (14 endpoints)

Live output on a machine reproducing the bug

- [WARN] ingest.transcript_suppressed - 2 Claude CLI processes running with --no-session-persistence
         (glm-5-turbo) - these sessions write no transcript, so their token usage cannot be recorded
Summary: ok 9 | warn 2 | fail 0 | critical 0

--json on the same run: "ok": true, "degraded": true.

Codex Context (required when requesting @codex review)

  • Delta since last Codex review: a Codex pass at xhigh reviewed the design before any code existed and returned FIX. Everything in it is addressed or explicitly parked: source-level detection misses the incident (redesigned — see above); four wrong metrics in [bug]: claude-mem's gateway (GLM) usage silently stops being counted — claude-mem 13.12.4 spawns the CLI with --no-session-persistence #128 (corrected publicly in this comment); parseProcessLine not exported (moved to a shared module); pids over an unauthenticated endpoint (removed); no Windows /bin/ps (guarded); process spawn per dashboard request (TTL cache); doctor WARN being invisible to automation (degraded). Parked with reasons: monotonic high-water mark, timezone-aware activeDays, and gateway reconciliation — all belong to the silence detector, which is not in this PR.
  • Intended behavior / invariants: (1) nothing derived from a process command line leaves the module except a count, charset-clamped model ids, and one of two literal reason strings; (2) "checked and found nothing", "could not check", and "cannot check on this platform" are three distinct outcomes and are never collapsed; (3) ok and the exit code are unchanged for every existing caller.
  • Edge cases covered: binary path containing a space; --model absent; --model=value form; a non-Claude process whose argv merely contains the flag (a shell, an editor, this check itself); ps failing; Windows; TTL boundary; a command line carrying a user path in --model.
  • Tests run (command + result): npm run ci:local → exit 0. New: test/transcript-suppression.test.js (13), test/local-api-ingest-health.test.js (3), 4 cases in test/doctor.test.js, 6 in IngestHealthNotice.test.jsx.
  • Known gaps / out of scope: the dashboard fetches once on mount (mirroring the existing userStatus effect), so a session started later is not shown until reload — doctor is current on demand. Detection is macOS/Linux only. A node-launched CLI (node cli.js --no-session-persistence) is not matched; argv[0] must be the claude binary, which is the trade for not matching every shell that mentions the flag.

Risk Layer Trigger (if any)

  • Public exposure / share links / unauthenticated access
  • Auth/session/token handling
  • Cross-endpoint invariants or shared logic
  • External gateway / environment constraints

Risk Layer Addendum

Rules / Invariants

  • The new endpoint answers unauthenticated loopback GETs, like every other read endpoint here. Therefore its payload — not the module's internals — is the security boundary.
  • No pid, no argv fragment, no environment value, no filesystem path may appear in the response or in the doctor meta.
  • Model ids are returned, deliberately: they are already first-class tracked data (every queue row carries one) and are the only field that tells the user which stream is unobservable. They are clamped to [A-Za-z0-9._:-]{1,64}, so a path or arbitrary argv text cannot ride out through the field.
  • A result that was never computed must never render as a passing one, at either surface.

Boundary Matrix (must list at least 3)

# Boundary Input Allowed out Enforced by
1 ps stdout → detector return value full command lines of every process on the machine count, clamped model ids, null | "unsupported_platform" | "process_list_failed" findSuppressedModels returns models only, never the parsed pid; test/transcript-suppression.test.js "never returns a pid or a raw command line" serialises the result and asserts the pid, the path, and the raw flag are all absent
2 detector → unauthenticated HTTP response detector return value the same five fields, explicitly re-listed at the handler rather than spread test/local-api-ingest-health.test.js "leaks no pid, argv, or path" asserts on the raw response body
3 --model value → user-visible text (doctor detail, dashboard notice) arbitrary text after --model only [A-Za-z0-9._:-]{1,64} MODEL_FLAG charset clamp; test feeds --model /Users/example/secret-project/notes.md and asserts the process is still counted while no path is returned
4 platform capability → check status process.platform, ps exit status [OK] only when a scan actually ran and found nothing buildTranscriptSuppressionCheck returns null when supported === false (check omitted, not [OK]) and warn when checked === false; three doctor tests pin all three states

Evidence (tests or repro)

  • Every boundary above has a named test; the two leak tests assert on serialised output rather than on inspection, so they fail if a future refactor widens the payload.
  • The IngestHealthNotice remedy test asserts literal text rather than copy(...). That is deliberate: the row it guards originally shipped with an unquoted comma in copy.csv, which truncated the sentence at "this," — and validate:copy still reported Copy registry ok, because all six required columns were non-empty. Verified by reverting the row and re-running: validator still green, test fails.
  • The space-in-path case was verified against the pre-fix predicate (false) and the current one (true) before the test was added, so it is a guard rather than a restatement.

Public Exposure Checklist

  • Public access rules defined — loopback-only, unauthenticated read, identical to the other GET /functions/* endpoints; no new auth surface
  • Exposed fields explicitly listed and verified — supported, checked, count, models, reason, checked_at, and nothing else; the handler names each field rather than spreading the detector result
  • Avatar/image policy defined — N/A
  • Regression tests cover invalid link and auth fallback — N/A for a read endpoint; the leak tests cover what actually matters here
  • Mark N/A if no public exposure

Regression Test Gate

Most likely regression surface

src/lib/usage-limits.jsparseProcessLine moved out from under detectAntigravityProcess. A bad move breaks Antigravity quota detection, which has no other guard on this path.

Verification method (choose at least one)

Uncovered scope


Refs #128. Does not close it — proposal #1 (source silence) stays open there with the replay data that shows why it needs rework.

Every Claude figure TokenTracker reports is parsed out of the session
transcripts under ~/.claude/projects. A CLI started with
--no-session-persistence writes none, so those calls cost real tokens and
contribute zero — and the only symptom is a source quietly reporting less than
it should, which is indistinguishable from a quiet day. That is how this was
found: a gateway-routed stream stopped being counted on 2026-07-28 and nothing
said so. `doctor` stayed green the whole time.

The detector is live rather than historical. It reads the process list once and
answers "is something running right now that I cannot see", so it needs no
baseline, no threshold, and no per-source history.

Source-level silence detection was tried first and rejected. Every Claude model
aggregates under source=claude, so the source read as active 0.4h ago while the
affected model had been silent 37h — it would have missed the exact incident it
was written for. At (source, model) granularity it does fire, but on the one
machine it was tested against it produced three false positives for two true
ones: a user retiring a model looks identical to a stream breaking. Parked
rather than shipped; #128 records the replay.

Two states are kept distinct on purpose. "Checked and found nothing" is [OK];
"could not read the process list" is a warning; and a platform that cannot
answer at all — no /bin/ps — omits the check rather than printing [OK] for
something that never ran.

What leaves the detector is a count, model ids, and a coarse reason. No pid,
argv, or environment value: the local API answers unauthenticated loopback
GETs, and a command line can carry a user's file paths. The tests assert that
absence by serialising the result and searching it, rather than by inspection.
Model ids are included deliberately — they are already first-class tracked data
and they are the one field that says *which* stream is unobservable — and are
charset-clamped so no arbitrary command-line text can ride out through them.

`degraded` joins `ok` on the doctor report. `ok` still governs the exit code and
only `critical` moves it, so no existing caller changes behaviour; `degraded` is
true whenever any check warns. A warning otherwise leaves an entirely
green-looking report, which is what let this stay invisible.

parseProcessLine moves to src/lib/process-list.js so both callers share one
copy; usage-limits.js keeps its behaviour and its tests.

Refs: #128
@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create an environment for this repo.

@pitimon

pitimon commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

QA gate — independent 7-gate pass

QA agent: Codex gpt-5.6-sol at model_reasoning_effort=xhigh, read-only, given the DoD and issue #128 as
context but not the PR author's self-assessment. Codex returned BLOCK / 6 Fix. I then re-checked every citation
against the working tree: 4 upheld, 2 downgraded, 1 finding Codex missed.

Read this first: merging publishes 0.40.0

This PR carries 6e38d1c chore(release): 0.40.0, and the description notes that npm-publish.yml fires on push to
main. npm dist-tags currently reads latest: 0.39.44, so merging is not a repo-internal act — it ships to every
@ipv9/tokentracker-cli user, carrying the deferred follow-ups with it.

  • (a) land the two pre-merge fixes → merge → 0.40.0 publishes with follow-ups outstanding
  • (b) suggested — land the two fixes, drop 6e38d1c, merge the feature only; bump in a later release once
    the real-/bin/ps test and the degraded narrowing land

(b) because of the upheld Edge Case gap below: no test drives the real cp.spawnSync path, so a broken real ps
invocation ships green. That is the part that is hard to walk back once published.

Gates

Gate Verdict Detail
Goal Fix Live-only detector (src/lib/transcript-suppression.js:12-14); the dashboard fetch is mount-only. A user hitting #128 is warned only while the suppressed process is still alive. Not a code change — see the #128 note below.
Accuracy (dashboard) Risk IngestHealthNotice.jsx:17-20 collapses "could not check" and "checked, clean" to the same null. Flagging honestly: stated invariant #2 and my DoD both say "never collapsed" without scoping, so Codex read them faithfully and called this a Fix. I am accepting the narrower module-scoped reading only because the omission is deliberate and documented twice (IngestHealthNotice.jsx:8-11, DashboardPage.jsx:191-194). Under the invariant as literally written, the Fix stands.
Accuracy (degraded) Fix — Codex missed this degraded: summary.warn > 0 || summary.fail > 0 (src/lib/doctor.js:60) is diluted. The field is new here so it did not exist during the incident, but counterfactually: #128's own doctor output carries warn 1 (queue.row_invariant, which the reporter calls unrelated and pre-existing), so on that machine degraded reads true constantly — including on a perfectly healthy day. openwiki/cli-and-operations.md:42-44 presents it as an automation alert signal; there it cannot serve as one.
UX/Flow Fix + Risk role="note" (IngestHealthNotice.jsx:31) is not a live region, and the notice is inserted asynchronously after the fetch — screen readers never announce it → Fix. Mount-only refresh → Risk; already disclosed as a known gap in the description.
Copy Pass All four strings via dashboard/src/content/copy.csv:687-690; the text reports that sessions are unrecordable and never claims a lost-token quantity.
Edge Case Fix Detector tests inject a runner, endpoint tests pre-prime the cache, doctor tests inject a report value — so no test exercises the real cp.spawnSync /bin/ps path. A broken real invocation passes CI green. Suggest one POSIX fixture through the default runner.
Security/Privacy Fix — pre-merge PS_ARGS uses -ax (src/lib/process-list.js:14), which enumerates every user's processes. The flag is pre-existing, but at usage-limits.js:1354 it feeds detectAntigravityProcess, whose pid/token are used for an internal call (usage-limits.js:1593-1616) while the served payload is quota data. As far as I traced, GET /functions/tokentracker-ingest-health is therefore the first HTTP surface reporting other users' process existence, count, and model ids. Mitigated by the loopback bind (src/commands/serve.js:19) and the Host guard, so it needs local access on a multi-user host. Caveat: I did not exhaustively trace every getUsageLimits branch.
Handoff Risk openwiki/local-api.md:28 lists the endpoint as GET, but the handler matches on path only, so POST/DELETE also return 200. True — though only 5 endpoints in local-api.js check req.method at all (:966, :984, :1059, :1167, :1568), so this is a codebase-wide gap rather than something introduced here. Better as its own issue than a one-endpoint exception.

Verified as passing: version 0.40.0 is in lockstep across package.json, package-lock.json,
TokenTrackerBar/project.yml (both the app and widget targets), and TokenTrackerWin/TokenTrackerWin.csproj.

Two fixes before merge, both cheap

  1. Scope the scan to the current usersrc/lib/process-list.js:14, -ax-x (or an explicit euid filter),
    plus a scope regression test. Blast radius is zero: grep -rn PS_ARGS src/ test/ shows process-list.js is its
    only consumer, and usage-limits.js:1354 keeps its own literal array.
  2. role="note"role="status" + aria-live="polite"IngestHealthNotice.jsx:31, plus an announcement test.

Follow-ups (not blocking)

  • A test that drives the real /bin/ps path end-to-end.
  • Narrow degraded, or add a suppression-specific machine-readable field.
  • Fold getIngestHealth() into the existing refresh cycle (~30s TTL) with an in-flight guard.
  • Method enforcement across local-api.js as a whole.

On issue #128

Directly related, but it does not resolve #128. The PR deliberately rejects #128's ranked proposal #1
(source-staleness detection) — with a good argument, replayed in the description — and ships a live process-list
detector instead. The originally reported shape, noticing a source that has already gone to zero, remains open.
Suggest linking this PR as partial and keeping #128 open until the silence/staleness half ships.

Evidence gaps

  • npm run ci:local was not re-run for this review; the green result is the author's claim plus the ci:local check
    on the PR. It needs a re-run after the two fixes regardless.
  • Codex reported reproducing POST/DELETE returning 200; I confirmed only that the handler contains no method
    check, which is consistent with that.

`ps -ax` lists every user's processes. transcript-suppression serves counts
and model ids derived from that output over `GET /functions/tokentracker-
ingest-health`, which is unauthenticated, so on a multi-user host the endpoint
answered questions about other people's Claude sessions. Verified on the
machine that found this: `-ax` spans 40 distinct users including a second
human account, `-x` returns 518 lines against 750 and still lists every
Claude process this user is running.

`-x` keeps processes with no controlling terminal, which is what the check
needs, and loses nothing: a session TokenTracker could not have recorded is by
definition one this user is running.

Blast radius is contained to this module — `grep -rn PS_ARGS src/ test/`
returns only process-list.js, and usage-limits.js:1354 keeps its own literal
array (still `-ax`; filed as a follow-up, not touched here because nothing in
this PR tests that path).

Adds test/process-list.test.js, which the module did not have. Two of its
cases were checked by breaking the code and watching them fail, not by
watching them pass:

- test/process-list.test.js:23 asserts the literal argv rather than observed
  output, because a real `ps` run on a single-user machine looks identical
  either way. Re-introducing `-ax`: 1 fail, then 5 pass on revert.
- test/process-list.test.js:71 drives the default `cp.spawnSync` path with no
  injected runner, and :79 asserts `ok === true`, so a broken real invocation
  fails a suite in which every other case fakes `ps`. Pointing PS_BINARY at a
  non-existent path: 1 fail, then 5 pass on revert.
@pitimon
pitimon force-pushed the feat/128-transcript-suppression-detector branch from 6e38d1c to 908b4d8 Compare July 30, 2026 03:58
@pitimon

pitimon commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

Both pre-merge items from the QA gate are addressed, and the release bump is dropped

Force-pushed (rebase). The branch is now three commits and no longer carries a version bump, so merging this
lands the feature without triggering npm-publish.yml. Version files are back at 0.39.44 in lockstep across all
four; validate:version-lockstep passes. ci:local is green on the PR.

f83459e fix(process-list): PS_ARGS -ax-x, plus test/process-list.test.js (the module had no test file)
908b4d8 fix(a11y): role="note"role="status" + aria-live="polite", five existing assertions moved, one new

On the scope fix

-x still returns every Claude process the current user is running, which is the whole population the check cares
about — a session TokenTracker could not have recorded is by definition one this user started. On the machine that
reported #128, -ax spans 40 distinct users including a second human account; -x returns 518 lines against 750.

Blast radius stayed inside the module: grep -rn PS_ARGS src/ test/ returns only process-list.js.
usage-limits.js:1354 still passes its own literal -ax and was deliberately left alone — nothing in this PR
exercises that path, so changing it here would be an untested edit to a different feature. Worth its own issue: that
call site extracts a CSRF token from a matched process, and on a shared host -a means it can match a process
belonging to someone else. I did not find evidence that the token reaches an HTTP response, and I did not trace every
getUsageLimits branch to rule it out.

The two new tests were checked by breaking the code, not by watching them pass

  • test/process-list.test.js:23 asserts the literal argv, because a real ps run on a single-user machine looks
    identical with or without -a. Re-introducing -ax: 1 fail, then 5 pass on revert.
  • test/process-list.test.js:71 drives the default cp.spawnSync path with no injected runner, and :79 asserts
    ok === true. Pointing PS_BINARY at a non-existent path: 1 fail, then 5 pass on revert. This also closes the
    Edge Case gap from the review — until now every case in the suite faked ps, so a broken real invocation would
    have passed.

Suite counts after the change: ci:local exit 0 with 997 + 4 node tests; dashboard 309 passed / 41 files, run
separately via npm --prefix dashboard test since ci:local does not include the vitest suite.

Still open from the review — none of it blocking

itarun.p added 2 commits July 30, 2026 11:36
The notice was `role="note"`, a static landmark. It is inserted after an async
fetch resolves — no user action, no focus move — so a screen reader announces
it only if the user later navigates onto it. The one class of user who cannot
see an amber box appear was also the one never told about it, on a notice
whose entire purpose is to say "your tokens are not being counted".

`role="status"` + `aria-live="polite"` queues the announcement behind whatever
is being read instead of interrupting.

The existing five `ByRole("note")` assertions move to `"status"`.
IngestHealthNotice.test.jsx:83 is new and guards the live-region attribute
separately from the role, because the role alone is what regressed.

Dashboard suite 309 passed / 41 files, run as part of `ci:local` — package.json:26
includes `npm --prefix dashboard run test` in the chain.

`dashboard/src/ui/components/DismissibleHint.jsx:55` also uses `role="note"` and
is left alone here; whether it is inserted asynchronously, and so has the same
problem, is unchecked.
The scoping decision from the previous commit lived only in a code comment and
a commit body. openwiki/local-api.md is where a reader goes to learn what this
endpoint discloses, and it described the payload shape without saying whose
processes the payload describes — the question that matters on a shared host.

Also names `src/lib/usage-limits.js` as a separate `ps` scan that still passes
`-ax`, so a future reader does not read the new sentence as a repo-wide
property. `docs:openwiki:check` reports 0 findings.
@pitimon
pitimon force-pushed the feat/128-transcript-suppression-detector branch from 908b4d8 to da080dc Compare July 30, 2026 04:37
@pitimon

pitimon commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

Q3 from the cross-verify pass closed, plus a correction to my own commit body

Force-pushed again. ci:local green on the PR.

da080dc docs(openwiki) — the scoping decision from f83459e lived only in a code comment and
a commit body. openwiki/local-api.md is where a reader goes to learn what this endpoint
discloses, and it described the payload shape without saying whose processes the payload
describes. Now says the scan is own-user only and why, and names src/lib/usage-limits.js as a
separate scan still passing -ax so the new sentence is not read as a repo-wide property.
docs:openwiki:check → 0 findings.

b7f121a is 908b4d8 amended. Its body claimed "ci:local does not run [the dashboard
suite] — npm --prefix dashboard test was run separately."
That is false. package.json:26
includes npm --prefix dashboard run test in the ci:local chain, and a real run emits
vitest runTest Files 41 passed (41). I asserted it from a stale note instead of opening
package.json. Corrected in place rather than left in the permanent record; flagging it here
since the wrong version was briefly pushed.

Also newly recorded in that commit body, since the cross-verify pass turned it up while tracing
blast radius: dashboard/src/ui/components/DismissibleHint.jsx:55 uses role="note" too. Left
alone — whether it is inserted asynchronously, and so shares the problem this PR fixes, is
unchecked.

One finding upgraded from inferred to verified

The degraded point in my first comment was reasoning over the doctor output pasted into #128.
Running it here directly instead:

ok: True | degraded: True
summary: {'ok': 9, 'warn': 2, 'fail': 0, 'critical': 0}
   WARN queue.row_invariant - 2 row problem(s) in 6482 line(s)
   WARN ingest.transcript_suppressed - 1 Claude CLI process running with --no-session-persistence (glm-5-turbo)

degraded is true with an unrelated pre-existing warn in the mix, so it cannot serve as the
suppression alert signal openwiki/cli-and-operations.md:42-44 presents it as. Still a follow-up,
still not blocking.

Two things fell out of the same run: the detector fires on a real suppressed session right now, so
the #128 condition is live on this machine — and it fires under -x, which is stronger
evidence that the scope fix preserved detection than the unit tests that inject a fake runner.

Unchanged

Still no human review on this PR. Everything above — the original code, the QA gate, the
adjudication, these fixes, and this comment — is machine-produced or machine-checked.

@pitimon

pitimon commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

Review debt from this PR is now filed — nothing left blocking except a human read

Every follow-up raised in the QA gate and the cross-verify pass has an issue. None of them block
merging this PR; they were all triaged as Risk or as work that belongs elsewhere.

Issue
#130 degraded (src/lib/doctor.js:60) is pinned true by any unrelated warn, so it cannot be the alert signal openwiki/cli-and-operations.md:42-44 documents. Verified by running doctor --json here: degraded: True with warn 2, one of them pre-existing and unrelated.
#131 The dashboard fetches ingest-health once on mount, so a suppressed session started later is never shown until reload. Disclosed in this PR's description; filed so it does not live only there.
#132 usage-limits.js:1354 still runs a machine-wide ps -ax and lifts a --csrf_token out of whatever Antigravity process matches first. Deliberately not touched here — nothing in this PR exercises that path. Whether the token reaches an HTTP response is explicitly unresolved.
#133 Endpoints documented GET answer any method; only five sites in local-api.js check req.method. This PR's endpoint follows the existing norm, so fixing it alone would reduce consistency. Filed as a whole-file change.
#134 DismissibleHint.jsx:55 also uses role="note". Found by the blast-radius grep for this PR's a11y fix. Whether it is async-inserted, and so shares the problem, is unchecked — the issue closes as no-change if not.
#135 Re-add the 0.40.0 bump when it should actually ship. Records why it was dropped and the current all-four-agree state at 0.39.44.

Also commented on #128 asking that it stay open on merge: #128 (comment)
this PR makes the failure visible while it is happening, but the shape #128 reported is a source
that already went quiet, and that half is still open.

State

Four commits, 18 files, no version bump, ci:local SUCCESS, MERGEABLE. validate:version-lockstep
passes with all four version files at 0.39.44, matching npm latest and the :17680 LaunchAgent
pin — merging this changes nothing about what users run.

Still zero human review. The original code, the QA gate, the adjudication of that gate, the two
fixes, the documentation commit, and every comment above are machine-produced or machine-checked.
Two of those machines were the same model. The failure class that stack has a measured record of
missing is a conclusion drawn from evidence that would look identical if the conclusion were false —
so the green checks above are the absence of found problems, not the presence of correctness.

@pitimon
pitimon merged commit 4842f5c into main Jul 30, 2026
1 check passed
pitimon added a commit that referenced this pull request Jul 30, 2026
* fix(doctor): count only non-advisory warns toward `degraded` (#130)

`degraded` was `summary.warn > 0 || summary.fail > 0`, which made it useless on
the machine it was written for. That box carries a standing `queue.row_invariant`
warning about two malformed rows out of 6482, so `degraded` read true on a
perfectly healthy day. An alert wired to it could never clear, and an alert that
never clears fails the same way as one that never fires — which is the exact
failure #128 was about.

A check may now set `advisory: true` to say its warn is a standing condition the
operator cannot act on at the moment they read the report. `queue.row_invariant`
is the only one that does, and it earns it on reasoning already written above
`queueCheck`: the rows are on disk and already being rendered, which is why that
check warns rather than fails in the first place.

`degraded` is now "at least one non-advisory warn or fail". `degraded_checks`
lists the ids that put it there, sorted — without it, `degraded: true` is
unactionable, since a consumer would have to re-walk `checks` to find out why and
a human reading the JSON cannot tell a new problem from the standing one.

An advisory check is unchanged everywhere else: still `warn` in `checks`, still
counted in `summary.warn`. The report is not quieter; only the alert signal is
narrower. Opting out is explicit, so a check that forgets the flag stays
alert-worthy — a problem that reads as silence is the thing being prevented.

Not a breaking change for anyone: `degraded` has never shipped.
`git show v0.39.44:src/lib/doctor.js | grep -c degraded` is 0, and npm `latest`
is still 0.39.44 — the field landed on main in #129 and that merge deliberately
did not publish. Narrowing it now costs no consumer anything.

Verified on the machine that reported #128, running the built CLI rather than a
fixture:

    ok: True | degraded: True
    degraded_checks: ['ingest.transcript_suppressed']
    summary: {'ok': 9, 'warn': 2, 'fail': 0, 'critical': 0}
       WARN queue.row_invariant        | advisory: True
       WARN ingest.transcript_suppressed | advisory: False

Two warns still reported, one reaches the alert signal, and `degraded_checks`
names which. Before this change both would have been indistinguishable behind a
single boolean.

Both mutation points were checked by breaking them, not by watching tests pass:
neutralising the `advisory` filter in `listDegradedChecks` fails 3 tests;
restoring the old `summary.warn > 0` predicate fails 1. `ci:local` exit 0 with
1002 node tests and 41 dashboard test files.

* fix(doctor): close three fail-open holes an independent review found

A Fable review of the first cut returned correct-but-incomplete. All three of its
substantive findings held up against the source, and the worst one was mine
entirely.

1. `advisory` was stamped per check id, not per warn. `queueCheck` hard-coded
   `advisory: true` on everything it returned, and `checkQueueRows` uses it for
   two different warns: the row-invariant one (standing — the rows are already
   written and rendered) and `queue unreadable` (a non-ENOENT read error:
   permissions, disk). The second is new, actionable, and plausibly means
   ingestion has stopped — the #128 class this field exists to surface — and it
   was going out as `warn` in `checks` with `degraded: false` on the wire. I had
   written "a problem that reads as silence is the thing being prevented" in the
   same file and then opened that exact path. `advisory` is now an explicit
   per-call-site argument defaulting to false.

2. `listDegradedChecks` dropped checks with a missing or malformed `id`, and
   `degraded` derives from the length of that list, so an id typo removed a
   genuine warn from the alert signal altogether — `[WARN] unknown` to a human
   (`src/commands/doctor.js` tolerates a missing id) and nothing at all to
   automation. Such a check is now counted under `UNNAMED_CHECK_ID`.

3. The filter suppressed advisory `fail`s as well as advisory warns. The
   rationale for the flag is about standing warnings; nothing argues for muting a
   fail on the same id. Now scoped to warns.

Each fix has a test that fails without it, checked one at a time by reverting
just that fix:

    queueCheck hard-codes advisory again  -> "an unreadable queue is actionable" fails
    id map drops non-strings again        -> "a warn with a missing id still degrades" fails
    advisory suppresses fail too          -> "advisory never suppresses a fail" fails
    .sort() removed                       -> "degraded_checks is sorted" fails
    all restored                          -> 20/20

That last row is also a review finding: the docs claimed sorted output and no
test pinned it, because the only multi-element expectation was already in sorted
input order. The unreadable-queue fixture uses a directory where a file is
expected (EISDIR) rather than chmod, because CI runs as root and an
unreadable-by-permission file is still readable there.

The review's remaining finding is real and is NOT fixed here: `browser.opener`
warns permanently on a headless host, so `degraded` still pins there. Reproduced
rather than reasoned about:

    $ node bin/tracker.js doctor --json | jq -c '{degraded, degraded_checks}'
    {"degraded":true,"degraded_checks":["ingest.transcript_suppressed"]}
    $ CI=true node bin/tracker.js doctor --json | jq -c '{degraded, degraded_checks}'
    {"degraded":true,"degraded_checks":["browser.opener","ingest.transcript_suppressed"]}

Classifying that check means a severity call on code I did not write, with no
coverage for the headless path, so openwiki now states the limit instead of
implying the alert-wiring advice is general. Tracked in #137.

openwiki also had two sentences that described the intent rather than the
mechanism: it said the flag is per check when it is per warn, and omitted the
id rule. Both corrected.

ci:local exit 0 — 1006 node tests, 41 dashboard test files, OpenWiki fact check
0 findings.

---------

Co-authored-by: itarun.p <itarun.p@somapait.com>
pitimon pushed a commit that referenced this pull request Jul 30, 2026
Two gaps a review found in the previous commit.

The `-x` scoping was only ever demonstrated on macOS, where `ps` is BSD and `-x`
plainly means "own user, tty restriction lifted". Linux `ps` is procps and parses
dash-prefixed options as UNIX-style, where `-x` is not an option — and
`isProcessListSupported` returns true for every platform except win32, so Linux
runs this argv. Had procps rejected it, `listProcessLines` would have returned
`process_list_failed` on every Linux host: a permanent non-advisory warn, pinning
`degraded` for a whole platform, which is the failure #136 exists to remove.

Checked on a real Debian 12 / procps-ng 4.0.2 host rather than reasoned from the
manual: `ps -x -o pid=,command=` exits 0 and reports one user across 22 lines,
while `-ax` on the same box reports seven users across 39. procps accepts it as
the BSD `x`, so the argv is correct on both supported platforms and #129's
shipped code is correct too. Recorded in the comment so nobody has to re-derive
it from a manpage.

The array is now frozen. Two modules share it, and the claim written in the
previous commit — that the two scans "cannot drift apart" — was only true of
editorial drift. Importing one constant does not stop `PS_ARGS.push("-a")`;
freezing does, and the test asserts the mutation throws rather than asserting
the flag alone.

Refs #132
pitimon added a commit that referenced this pull request Aug 2, 2026
…user (#138)

* fix(usage-limits): scope the Antigravity process scan to the current user

`detectAntigravityProcess` ran `/bin/ps -ax`, which walks every account on the
box, and attached to whichever Antigravity language server matched first.

Tracing what that could expose settles the question #132 left open: the CSRF
token and the pid never reach an HTTP response. `processInfo` is read field by
field and is never spread into a returned object — the pid goes to
`listAntigravityPorts`, the token becomes a request header, and all four return
shapes of `fetchAntigravityLimits` carry neither.

What the token *fetches* does reach the response. `normalizeAntigravityResponse`
returns `account_email` and `account_plan`, `finalize` spreads them into the
result, and `getUsageLimits` serves that at
`/functions/tokentracker-usage-limits`. On a shared host this displayed another
person's email, plan and quota as the local user's own, and
`writeAntigravityLimitsCache` persisted the address to disk, where the
not-configured branch kept serving it after their process exited. So: not
credential exposure, but cross-account PII — on multi-user hosts only, which is
why no observed output could have caught it.

The scan now uses the PS_BINARY / PS_ARGS already exported by process-list.js
rather than its own inline argv. Sharing the constant is the point: #129 fixed
the other scan, and two scans that must both stay own-user should not be able to
drift apart.

The regression test captures the literal argv from an injected commandRunner,
because a real `ps` run on a single-user machine returns identical lines either
way. Verified as a guard rather than a restatement: restoring `-ax` fails it.

Anyone who has run this on a shared host may have another user's account_email
cached in ~/.tokentracker/tracker/usage-limits-cache.json. It is a cache;
deleting the file is the whole remedy.

Closes #132

* fix(process-list): verify `-x` on Linux and freeze the shared argv

Two gaps a review found in the previous commit.

The `-x` scoping was only ever demonstrated on macOS, where `ps` is BSD and `-x`
plainly means "own user, tty restriction lifted". Linux `ps` is procps and parses
dash-prefixed options as UNIX-style, where `-x` is not an option — and
`isProcessListSupported` returns true for every platform except win32, so Linux
runs this argv. Had procps rejected it, `listProcessLines` would have returned
`process_list_failed` on every Linux host: a permanent non-advisory warn, pinning
`degraded` for a whole platform, which is the failure #136 exists to remove.

Checked on a real Debian 12 / procps-ng 4.0.2 host rather than reasoned from the
manual: `ps -x -o pid=,command=` exits 0 and reports one user across 22 lines,
while `-ax` on the same box reports seven users across 39. procps accepts it as
the BSD `x`, so the argv is correct on both supported platforms and #129's
shipped code is correct too. Recorded in the comment so nobody has to re-derive
it from a manpage.

The array is now frozen. Two modules share it, and the claim written in the
previous commit — that the two scans "cannot drift apart" — was only true of
editorial drift. Importing one constant does not stop `PS_ARGS.push("-a")`;
freezing does, and the test asserts the mutation throws rather than asserting
the flag alone.

Refs #132

---------

Co-authored-by: itarun.p <itarun.p@somapait.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant