@keepsnappy/agent 0.1.0-alpha.22 → 0.1.0-alpha.23

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.
package/README.md CHANGED
@@ -16,6 +16,9 @@ keepsnappy measure --repeat 10 \
16
16
  keepsnappy extract runs/s1 --ri-capture observations.ndjson # re-derive metrics.csv (adds ri.* / mem.*)
17
17
  keepsnappy aa runs/s1 [--reps 300 --boot 400] # noise floor / MDE per metric
18
18
  keepsnappy doctor # xcrun, simctl, devicectl, maestro, appium, node, booted sims, devices
19
+ keepsnappy doctor --group setup [--json] [--app SLUG]... [--preflight | --step ID] # is this machine set up to watch, notify and investigate (see Setup below)
20
+ keepsnappy init [--yes] # fixes what `doctor --group setup` finds missing
21
+ keepsnappy init --detect [--json] # the repository folders found per app, and how each matched; writes nothing
19
22
  ```
20
23
 
21
24
  - `--arms`: `NAME=<.app/.ipa path>` (installed with `simctl install` / `devicectl device install app`, only when the arm changes) or `NAME=<bundle id>` (already installed). Without `--arms` there is one arm `A`: plain repetition, usable for `keepsnappy aa`.
@@ -149,10 +152,7 @@ result, `3` with `--fail-on-worse` when the verdict computed on that run set is
149
152
  verdict it checks nothing and says so, exit `0`), `2` for configuration, `1` when the API fails or nothing could be
150
153
  judged ("Blocked": no iteration sent any data).
151
154
 
152
- Slack: set `KEEPSNAPPY_SLACK_WEBHOOK` (an incoming-webhook URL kept as a CI secret) and `farm close` posts a measurement that sent
153
- no data ("No result") at once. Results are posted after the verdict is computed, by the operator's recompute job (decision 0031), in
154
- two lines and one button (docs/mockups/2026-10-26-slack, present `resultSlackPayload`). `$GITHUB_STEP_SUMMARY` gets the same two
155
- lines: "No result", the computed result, or "Not judged yet · <branch> / Waiting for the judgement" with the run set's page. A Slack failure is printed and never changes the exit code.
155
+ Slack: the CLI posts nothing to Slack, whatever webhook is set (`KEEPSNAPPY_SLACK_WEBHOOK` is no longer read). The operator's notify job (decision 0031, `docs/operations/notify.md`) posts each journey's result once its verdict is computed, in two lines and one button (docs/mockups/2026-10-26-slack, present `resultSlackPayload`), and a failed session once: fewer valid runs than a session needs, per journey, with each run's stored reason, whether or not `farm close` ran. `farm close` prints a measurement that sent no data as "Blocked" (exit 1) with the run set's link. `$GITHUB_STEP_SUMMARY` gets the same two lines: "No result", the computed result, or "Not judged yet · <branch> / Waiting for the judgement" with the run set's page.
156
156
 
157
157
  Declared steps on the farm: `maestro/keepsnappy/step.yaml` / `step.js` / `send-markers.js` (copy them into the suite).
158
158
  With a token set, `step.js` only records the marker; `send-markers.js`, run in `end-run.yaml` after each iteration,
@@ -216,6 +216,7 @@ export KEEPSNAPPY_KEY=pl_rk_…
216
216
  keepsnappy investigate f_0123456789abcdef --repo ~/apps/my-app # stores the hypotheses on the finding
217
217
  keepsnappy investigate f_0123456789abcdef --dry-run # everything except the last step; prints what would be stored
218
218
  keepsnappy investigate f_0123456789abcdef --agent codex --max-minutes 10 --json
219
+ keepsnappy investigate f_0123456789abcdef --claude-path ~/.local/bin/claude --save # claude is not on PATH: say where; --save remembers it
219
220
  ```
220
221
 
221
222
  What it does, in order:
@@ -224,25 +225,109 @@ What it does, in order:
224
225
  3. Computes the range itself, because the agent has no shell: `git log` and `git diff` (fixed arguments, `--no-ext-diff --no-textconv`, output capped: log 20 KB, stat 20 KB, diff 100 KB, with a note when cut) from main's commit to the measured commit, put in the prompt after the package. Without a main commit in the package, or with one your clone does not have, the prompt says so.
225
226
  3b. **Checks for a false alarm first.** The area is the files the package names for the finding (the components' files, and the files in the place ids' screen, parent and target), kept only if they are plain repo-relative paths that exist at the measured commit. The CLI computes, with fixed arguments, hooks off, literal pathspecs after `--`, from the two measured trees (`git diff <main commit> <measured commit>`, not the PR's range since the fork: main may have changed the area after it, and then the PR builds the old code): a symmetric log (`<` a commit only main has, `>` one only the PR has), the stat and a capped diff (40 KB) of those files, and says `AREA DIFF EMPTY: yes | no | unknown`. It also reads the stored facts through the client the tools use (`main_sessions`, `main_vs_previous`, `place_trend`, and the package's noisy marks, threshold, measured-differently and setup notes, at most 12 KB) and puts them in the prompt as the false-alarm facts, so claude and codex see the same ones. If the area diff is empty the first hypothesis is `kind: no-area-change` ("likely false alarm") with `checked`; the CLI leaves out a false alarm its own check does not support (the area changed, could not be checked, another range or file) with its reason, and still submits the other hypotheses; only when nothing is left does it submit nothing. The server cannot check this: a `no-area-change` from the skill path (or any key holder) is stored on the agent's word. The last line of the log is `KEEPSNAPPY-STATUS <word> [n]`, which `keepsnappy watch` reads for its second notification.
226
227
  4. Runs the agent in that worktree, read-only, with the prompt every runner shares (`packages/mcp/src/investigate-prompt.ts`, version `investigate-v4`, the same rules as the skill) and the package as the answer layout prints it. The prompt goes on stdin, so a large package does not hit the argument limit. Nothing the user or the repo configures can widen what the agent may do:
227
- - `claude -p --output-format json --max-turns N --restricted --permission-mode dontAsk --permission-prompts none --tools Read,Grep,Glob --allowedTools <Read,Grep,Glob + the KeepSnappy read tools by name> --disallowedTools Bash,PowerShell,Edit,Write,… --disable-slash-commands --mcp-config <private file> --strict-mcp-config`. Which flag closes which hole: `--restricted` ignores the user's, the project's (a PR's committed `.claude/settings.json`, with its hooks and allow rules) and the local settings files and removes Bash; `--permission-mode dontAsk` and `--permission-prompts none` deny whatever is not allowed and ask nobody; `--tools` and `--allowedTools` give it Read, Grep, Glob and the read-only KeepSnappy tools only (`get_investigation`, `get_finding`, `list_findings`, `list_problems`, `compare_runs`, `window_history`, `commits_between`, `session_places`, `place_trend`, `main_sessions`, `main_changes`, `main_vs_previous`, `pr_status`, `explain_layers`; never `submit_hypotheses`, so a dry run cannot store, and never `recompute_with_new_rules`; no wildcard); `--strict-mcp-config` keeps your other MCP servers out; `--disable-slash-commands` keeps the repo's and your skills and commands out. The KeepSnappy key is in a mode 0600 file that is deleted afterwards, not in the arguments or the agent's environment. It relies on the final JSON document's `result`, `is_error` and `subtype` fields. All these flags are in `claude --help` of 2.1.308 (`--max-turns` is not listed there; the prompt's own tool-call budget does not depend on it). The repo's CLAUDE.md can still be read as text by the agent; it can run and write nothing.
228
- - `codex exec --ignore-user-config --ignore-rules --ephemeral -c default_permissions="ks" -c 'permissions.ks.filesystem={":minimal"="read","<worktree>"="read","/private/tmp"="none","/tmp"="none"}' -c approval_policy="never" -c project_doc_max_bytes=0 --color never -o <file> -`. **Codex's read scope is the worktree and the system minimum, with `/tmp` and `/private/tmp` denied by name (the minimum includes `/tmp`, which holds other tools' output); its shell can write nothing and reach no network, and git cannot run in it, so the prompt carries the log, stat and diff.** The permission profile replaces `--sandbox read-only`, which reads every file you can (`~/.zshenv`, `~/.ssh`): with it, a PR's text could make codex quote a secret into a hypothesis that is then stored on the server. Checked with `codex sandbox` and the same `-c` values (no model; the test `investigate.test.ts` runs it when `codex` is installed): the worktree reads (also under `/tmp`); a sibling file, `/tmp` and the home directory do not. **Not checked: that `codex exec` applies the profile the same way in a live run** (that needs a model). `project_doc_max_bytes=0` keeps the repo's `AGENTS.md` out of the prompt (checked with `codex debug prompt-input`). Whether `codex exec --ignore-user-config` still loads a repo's `.codex/config.toml` or hooks is not checked against a live run either; trust for a project lives in the user config it ignores. `--ignore-user-config` keeps your configured MCP servers and approval reviewer out.
228
+ - `claude -p --output-format stream-json --verbose --max-turns N --restricted --permission-mode dontAsk --permission-prompts none --tools Read,Grep,Glob --allowedTools <Read,Grep,Glob + the KeepSnappy read tools by name> --disallowedTools Bash,PowerShell,Edit,Write,… --disable-slash-commands --mcp-config <private file> --strict-mcp-config`. Which flag closes which hole: `--restricted` ignores the user's, the project's (a PR's committed `.claude/settings.json`, with its hooks and allow rules) and the local settings files and removes Bash; `--permission-mode dontAsk` and `--permission-prompts none` deny whatever is not allowed and ask nobody; `--tools` and `--allowedTools` give it Read, Grep, Glob and the read-only KeepSnappy tools only (`get_investigation`, `get_finding`, `list_findings`, `list_problems`, `compare_runs`, `window_history`, `commits_between`, `session_places`, `place_trend`, `main_sessions`, `main_changes`, `main_vs_previous`, `pr_status`, `explain_layers`; never `submit_hypotheses`, so a dry run cannot store, and never `recompute_with_new_rules`; no wildcard); `--strict-mcp-config` keeps your other MCP servers out; `--disable-slash-commands` keeps the repo's and your skills and commands out. The KeepSnappy key is in a mode 0600 file that is deleted afterwards, not in the arguments or the agent's environment. It relies on the `result`, `is_error` and `subtype` fields of the last `result` event of the stream (the stream is read line by line; the events before it are where a run that never ends has its usage). All these flags are in `claude --help` of 2.1.308 (`--max-turns` is not listed there; the prompt's own tool-call budget does not depend on it). The repo's CLAUDE.md can still be read as text by the agent; it can run and write nothing.
229
+ - `codex exec --json [-m <codexModel>] --ignore-user-config --ignore-rules --ephemeral -c default_permissions="ks" -c 'permissions.ks.filesystem={":minimal"="read","<worktree>"="read","/private/tmp"="none","/tmp"="none"}' -c approval_policy="never" -c project_doc_max_bytes=0 --color never -o <file> -`. **Codex's read scope is the worktree and the system minimum, with `/tmp` and `/private/tmp` denied by name (the minimum includes `/tmp`, which holds other tools' output); its shell can write nothing and reach no network, and git cannot run in it, so the prompt carries the log, stat and diff.** The permission profile replaces `--sandbox read-only`, which reads every file you can (`~/.zshenv`, `~/.ssh`): with it, a PR's text could make codex quote a secret into a hypothesis that is then stored on the server. Checked with `codex sandbox` and the same `-c` values (no model; the test `investigate.test.ts` runs it when `codex` is installed): the worktree reads (also under `/tmp`); a sibling file, `/tmp` and the home directory do not. **Not checked: that `codex exec` applies the profile the same way in a live run** (that needs a model). `project_doc_max_bytes=0` keeps the repo's `AGENTS.md` out of the prompt (checked with `codex debug prompt-input`). Whether `codex exec --ignore-user-config` still loads a repo's `.codex/config.toml` or hooks is not checked against a live run either; trust for a project lives in the user config it ignores. `--ignore-user-config` keeps your configured MCP servers and approval reviewer out.
229
230
  5. Budget: `--max-turns` (default 26: 25 tool calls and the answer) and `--max-minutes` (default 15). At the time limit the agent and every process it started are killed, and it says `no result within N min`. More than 5 MB of output stops it too.
230
231
  6. Validates the answer with the one hypothesis schema (`@keepsnappy/dashboard-contract/hypotheses`), after filling `runner: cli`, the agent (the model name claude reports, else `claude` / `codex`), `prompt_version` and the package's `package_digest`. An invalid answer is shown with the schema's paths and is not submitted. An answer of no hypothesis stores nothing.
231
- 7. Submits with the same client as the MCP tool `submit_hypotheses` and prints `N hypotheses, open`, then each one: component, files, expected change. `--json` prints one document `{ finding_id, submission, stored }` (`stored` is null on a dry run). Progress and errors go to stderr.
232
+ 7. Submits with the same client as the MCP tool `submit_hypotheses` and prints `N hypotheses, open`, then each one: component, files, expected change. `--json` prints one document `{ finding_id, outcome, submission, stored, usage }` (`stored` is null on a dry run). Progress and errors go to stderr.
233
+ 8. **Records what the run used**, for every end of a run that started the agent (see "What a run used" below), and ends with one line: `used 68k tokens (~$0.07, agent reported ~$0.07)`.
234
+
235
+ ### What a run used
236
+
237
+ Every run that started the agent is recorded in KeepSnappy, whatever its end:
238
+
239
+ | Outcome | When |
240
+ |---|---|
241
+ | `submitted` | hypotheses stored (the run is linked to their submission) |
242
+ | `refused` | the CLI's false-alarm check left nothing to submit |
243
+ | `invalid` | the agent's answer did not match the hypothesis schema |
244
+ | `no-result` | no JSON object, or no hypothesis |
245
+ | `timeout` | the agent was stopped at `--max-minutes` |
246
+ | `stopped` | you stopped it with Ctrl-C (the post is cut off after 3 s, so the prompt comes back) |
247
+ | `error` | the agent failed or ran out of turns, a line of its output passed 16 MB, or the API refused the hypotheses |
248
+
249
+ Not recorded: `--dry-run` (shown, not stored) and a command that stopped before the agent started (no repo, no commit, agent not installed): no tokens were spent. A usage that cannot be stored is tried twice (10 s each, the same run id, which the API replays), then said on stderr; it does not fail the investigation.
250
+
251
+ What is recorded is what the agent printed, and `null` where it printed nothing: never 0, never a guess.
252
+
253
+ | Agent | Source | Input | Cache read | Cache write | Output | Model | Turns, time, cost |
254
+ |---|---|---|---|---|---|---|---|
255
+ | claude | the `result` event of `--output-format stream-json`: `modelUsage` (every model call of the run, subagents included), else `usage` (the main loop) | `inputTokens` | `cacheReadInputTokens` | `cacheCreationInputTokens`, split by lifetime from `usage.cache_creation` (`ephemeral_5m_input_tokens`, `ephemeral_1h_input_tokens`) | `outputTokens` | the one model in `modelUsage`; several: null, and `by_model` has the tokens of each | `num_turns`, `duration_ms`, `total_cost_usd` (claude's own figure) |
256
+ | codex | the `turn.completed` event of `codex exec --json` | `input_tokens` − `cached_input_tokens` | `cached_input_tokens` | `cache_write_input_tokens` if printed (no lifetime) | `output_tokens` (OpenAI counts the reasoning tokens inside it) | not printed: the one you pin with `--codex-model NAME` (`investigate.codexModel`; passes `codex -m NAME`), else null | not printed: null |
257
+
258
+ The shapes are read from what the vendors document and from codex's source, and checked with fixtures that model them; **not checked against a live `claude` or `codex` run** (that needs a model). The output of both is read line by line and not kept (megabytes of tool output are neither held in memory nor a reason to stop the run); a single line over 16 MB, or 512 MB in all, stops it as `error`.
259
+
260
+ **A run that never ends.** claude prints its `result` last. When it never comes (a timeout, a Ctrl-C), the usage is what streamed: the input side (input, cache read, cache write with its split) of each assistant message, once per message id. Its output count is only a placeholder until the message ends, so output is `null` and the estimate is `null`, but the input-side tokens are known. A crashed claude (`is_error` with every count 0: the Agent SDK says the cost fields may be zeroed) records `null`, not zeros. codex prints `turn.completed` only when a turn ends: a stop mid-turn has no usage (documented, not fixable).
261
+
262
+ **The estimate** is tokens × the list price of the model on an API key (`packages/dashboard-contract/src/model-prices.ts`, the one table the API and this CLI share, each entry with its official page and date), each kind at its own rate, and it prices the run as it ran: a `claude login` run writes the 1-hour cache (the 1-hour price), an API key defaults to the 5-minute cache (the 5-minute price); the split in claude's own report decides. With no split the cache-write part, and so the estimate, is `null`. Several models are priced one by one. A model priced by prompt size (Haiku 5.5, gpt-5.6-\*, gpt-5.5, gpt-5.4) is priced at its lower tier when all the prompt tokens of the whole run are within the limit (then no request was bigger), else `null`. The estimate is `null` too for an unknown model, a model not named (codex without `--codex-model`), or a kind of token not printed. It is not what your subscription costs and not a bill. `reported_cost_usd` is claude's own figure; it is shown beside the estimate, never in place of it.
263
+
264
+ `--json` has the usage as one object:
265
+
266
+ ```json
267
+ "usage": {
268
+ "run_id": "inv-run-f_0123456789abcdef-1793303393464-a1b2c3", "outcome": "submitted", "agent": "claude", "model": "claude-sonnet-5-5", "by_model": null,
269
+ "input_tokens": 1200, "output_tokens": 3400, "cache_read_tokens": 56000, "cache_creation_tokens": 7800, "cache_creation_5m_tokens": 3000, "cache_creation_1h_tokens": 4800,
270
+ "total_tokens": 68400, "turns": 9, "duration_ms": 84000, "reported_cost_usd": 0.0687, "estimated_cost_usd": 0.0687, "price_table_version": "2026-10-29", "stored": true
271
+ }
272
+ ```
273
+
274
+ `stored` is false on `--dry-run` and when the API did not take it. On a run that ends without hypotheses `--json` prints the same document with `"submission": null`. The end line is `used 68k tokens (~$0.07, agent reported ~$0.07)`: the estimate, then the agent's own figure; either is left out when it is not known.
275
+
276
+ **For the Mac app.** The app reads `keepsnappy watch --json`, not this command's `--json`. So the log of an investigation carries the same object on one line, just before the status line: `KEEPSNAPPY-USAGE {…}`. `watch` reads the last such line when the investigation ends (only an object with exactly these keys and bounded values; anything else is no usage) and adds it to the `ended` event as `usage` (left out when the log has none). Decoding it in the app is not done yet; the key is additive, so the app as it is ignores it.
277
+
278
+ `keepsnappy hypotheses <finding-id> [--json]` lists what is stored about a finding: its hypotheses and the runs that made them (or did not), each with its tokens and cost. The runs ride in the same answer as the hypotheses (`GET /v1/findings/:id/hypotheses` adds `runs`, `usage` and `usage_text`), so `--json` is still the API's answer as it came, and a server that does not send them yet prints no runs. `usage_text` says how many runs a sum covers: `3 runs · 41k tokens for 2 of 3 runs · ~$0.06 for 1 of 3 runs`.
232
279
 
233
280
  Setup, once. The repo comes from `--repo`, else from the app → repo map in `keepsnappy.yml` (the file `keepsnappy run` reads: the current directory, or `--config`). The app is the one the key reads. Nothing else in the file changes:
234
281
 
235
282
  ```yaml
236
283
  investigate:
237
284
  agent: claude # claude | codex (default claude); --agent wins
285
+ claudePath: ~/.local/bin/claude # the claude executable (default: claude on PATH); --claude-path wins
286
+ codexPath: /opt/codex/bin/codex # the codex executable (default: codex on PATH); --codex-path wins
238
287
  max_turns: 26 # --max-turns wins
239
288
  max_minutes: 15 # --max-minutes wins
240
289
  repos: # app → local clone; ~ is your home, a relative path is relative to where you run the command
241
290
  tapin: ~/apps/gigaverse-mobile
242
291
  ```
243
292
 
293
+ Where the agent is: `--agent claude|codex` picks it (default claude), then `--claude-path FILE` / `--codex-path FILE` (keys `investigate.claudePath` / `investigate.codexPath`) say which executable runs; without a path the name is looked up on `PATH`, as before. Order: flag, then `keepsnappy.yml` (or `--config FILE`), then your own config file, then `PATH`. `~/` is your home. Before anything starts (before the worktree, before the API is asked) the path of the agent in use must exist, be a regular file (symlinks are followed) and be executable by you; if not, the command stops with `--claude-path /x/claude: not found` (or `not a regular file`, `not executable`), or `investigate.claudePath /x/claude: not found (in <file>)` when the path came from a file, and exits 2. The path of the agent that is not in use is not looked at. The Mac app sets these from its settings.
294
+
295
+ Your own config file is `$XDG_CONFIG_HOME/keepsnappy/config.yml`, else `~/.config/keepsnappy/config.yml` (created on first write: directory 0700, file 0600). It has the same `investigate:` section as `keepsnappy.yml`; `keepsnappy investigate` and `keepsnappy watch` read it, a project's `keepsnappy.yml` (or `--config FILE`) wins over it key by key:
296
+
297
+ ```sh
298
+ keepsnappy config set investigate.agent codex
299
+ keepsnappy config set investigate.claudePath ~/.local/bin/claude # checked on set: exists, a regular file, executable; stored as typed
300
+ keepsnappy config get investigate.claudePath
301
+ keepsnappy config unset investigate.repos.tapin # removes the key, nothing else in the file changes; exit 1 when it is not set
302
+ keepsnappy config list # the three keys that are set, and the file
303
+ keepsnappy config set investigate.codexPath /opt/codex/bin/codex --config ./my.yml # --config FILE: another file instead of your own
304
+ ```
305
+
306
+ `set` and `unset` accept only `investigate.agent`, `investigate.claudePath`, `investigate.codexPath` and `investigate.repos.<app>` (the folder of an app's git repository; it must exist; anything else is refused with the list) and edit the file line by line, so comments, other keys and other sections stay as they are (a key written in quotes, `"tapin":`, is the same key as `tapin:` and keeps its quotes; a `repos` map left without an entry is removed with `unset`); a file that is not valid YAML, or whose `investigate:` is written inline (`investigate: {}`), is refused and left untouched. `keepsnappy investigate --save` does the same `set` for the agent and the path that run used (only those; the path only when one was given by a flag or a file, not when `PATH` found the agent).
307
+
244
308
  Exit codes: 0 stored (or a dry run, or no hypothesis), 1 no usable answer (timeout, agent error, invalid output), 2 wrong flags or settings, 130 Ctrl-C. Needs a key with read scope, like the MCP tools.
245
309
 
310
+ ## Setup: `doctor --group setup`, `init`, `investigate --preflight`
311
+
312
+ `keepsnappy doctor --group setup` says whether this machine is ready to watch, notify and investigate; the Mac app's first-run window is a view of it. It calls no model and writes nothing. Steps, in order (a step is `ok`, `missing` or `broken`):
313
+
314
+ | id | checks |
315
+ |---|---|
316
+ | `account` | a key is set (`KEEPSNAPPY_KEY` or `--key-env`) and the API answers; the detail is `connected: <org> · <app>` |
317
+ | `projects` | the apps in `watch.apps` (the key's app when none are named); each must be the key's app |
318
+ | `repo:<app>` | a folder is mapped (`investigate.repos.<app>`) and is a git repository. A remote must match the project's repository URL (`repo_url` on the events page; ssh and https forms, with or without `.git`, count as the same); with no URL set, or an API that does not send it yet, the step is `ok` with "repo URL unknown: remote not checked" |
319
+ | `agent` | `claude` or `codex` is chosen, its executable is found (the configured path, else `PATH`; when not on `PATH` the detail names where it was found) and `--version` runs |
320
+ | `agent-login` | the agent's own login status (`claude auth status`, `codex login status`; no model call); an agent without one is `ok` with a note |
321
+ | `notifications` | a notifier is present (alerter, terminal-notifier, or osascript on macOS) |
322
+ | `events` | the events read works; the count of the last 7 days |
323
+ | `preflight` | `keepsnappy investigate <newest finding> --preflight` (below). It checks a commit out, so `doctor` runs it only for `--preflight` or `--step preflight`; otherwise it is `missing` with the hint "run the check" |
324
+
325
+ `--json` prints `{"version":1,"steps":[{"id","group","state","detail","fix":{"hint","command"?}}]}`: both groups unless `--group setup|measure` is given (the measurement tools are the group `measure`, the same checks as plain `keepsnappy doctor`, which prints them as before and never fails). `fix.hint` is empty when the step is `ok`. Exit code 1 unless `account`, `projects` and `events` are `ok`. `--step ID` prints that one step (run, if it is the preflight) and exits 1 unless it is `ok`. `--app SLUG` (repeatable) makes `projects`, `repo:<app>` and the preflight judge only those apps, whatever `watch.apps` says (an app the key is not for is `broken`); `projects` shows the project's display name when the API sends one. `events` counts the results of the last 7 days the way `keepsnappy watch --history-days` lists them (same pages, same `watch.apps` / `watch.branch` / `watch.mine` filter).
326
+
327
+ `keepsnappy init [--yes] [--app SLUG] [--repo app=path ...] [--agent claude|codex] [--claude-path FILE] [--codex-path FILE] [--config FILE]` shows those steps, then asks about the ones that are `missing` or `broken`: the folder of each app (found by scanning `~/apps`, `~/code`, `~/src`, `~/Developer`, `~/Projects` three levels deep and `~` two levels, for 5 s at most, skipping hidden folders, `node_modules` and, in `~`, the folders macOS asks permission for), the agent, and the path of the agent (the login shell's `PATH`, then `~/.local/bin`, then `/opt/homebrew/bin`). Each question shows what was found; Enter accepts it. A second run asks nothing and changes nothing. It writes with the code of `keepsnappy config set`, to your config file (or `--config FILE`), and never writes a key. `--yes` asks nothing: a flag decides, else what was found, except a folder that only looks right by its name, which is shown with the flag that applies it. A folder is matched to the project by its remote against the project's repository URL (`repo_url` of the events page). `--detect` only looks (not with `--yes`, `--repo`, `--agent` or the path flags): per app it lists the folders found, `match: "remote"` when a remote is the project's URL and `"name"` when only the folder or a remote is named like the app (used when the URL is unknown). `--detect --json` prints `{"version":1,"apps":[{"app","repo_url","candidates":[{"path","remotes","match"}]}]}`, remote matches first.
328
+
329
+ `keepsnappy investigate <id> --preflight` does everything before the agent starts and calls no model: reads the package, checks the commit is in the repo, adds and removes the worktree, runs `<agent> --version`. It ends with `KEEPSNAPPY-STATUS preflight-ok` or fails with the usual statuses.
330
+
246
331
  ## Output layout
247
332
 
248
333
  ```
@@ -307,6 +307,7 @@ var IOS_BOTTOM_INSET_PT = 34;
307
307
  var GESTURE_ZONE_MARGIN_PT = 16;
308
308
  var ANDROID_NAV_BAR_FALLBACK_DP = 48;
309
309
  var GESTURE_ZONE_MARGIN_DP = 16;
310
+ var PROBE_FROM = 8;
310
311
 
311
312
  class Driver {
312
313
  bundleId;
@@ -443,13 +444,42 @@ class Driver {
443
444
  if (await this.onScreen(first, full))
444
445
  return first;
445
446
  }
446
- for (const el of await this.findAll(using, value, parent)) {
447
+ const all = await this.findAll(using, value, parent);
448
+ const probed = all.length > PROBE_FROM ? await this.probeShown(all, full) : undefined;
449
+ if (probed)
450
+ return probed;
451
+ for (const el of all) {
447
452
  if (await this.onScreen(el, full))
448
453
  return el;
449
454
  }
450
455
  }
451
456
  return null;
452
457
  }
458
+ async probeShown(els, full) {
459
+ const w = await this.screen();
460
+ let lo = 0;
461
+ let hi = els.length - 1;
462
+ while (lo <= hi) {
463
+ const mid = lo + hi >> 1;
464
+ const el = els[mid];
465
+ if (await this.onScreen(el, full)) {
466
+ let first = mid;
467
+ while (first > 0 && await this.onScreen(els[first - 1], full))
468
+ first--;
469
+ return els[first];
470
+ }
471
+ const r = this.rects.get(el);
472
+ if (!r || r.width <= 0 || r.height <= 0)
473
+ return;
474
+ if (r.y + r.height <= 0)
475
+ lo = mid + 1;
476
+ else if (r.y >= w.height)
477
+ hi = mid - 1;
478
+ else
479
+ return;
480
+ }
481
+ return null;
482
+ }
453
483
  async shownMany(sels, group) {
454
484
  const out = sels.map(() => false);
455
485
  const tests = sels.map((s) => s.id !== undefined && s.text === undefined && s.childOf === undefined ? idMatcher(s.id) : null);
@@ -507,6 +537,25 @@ class Driver {
507
537
  });
508
538
  await this.cmd("DELETE", "/actions").catch(() => null);
509
539
  }
540
+ async stroke(from, points, note) {
541
+ const safe = await this.safeBottomY();
542
+ const moves = points.map((p, i) => ({ type: "pointerMove", duration: p.ms, x: p.x, y: i === points.length - 1 ? Math.min(p.y, safe) : p.y }));
543
+ const logged = this.log.length;
544
+ try {
545
+ await this.cmd("POST", "/actions", {
546
+ actions: [{ type: "pointer", id: "finger1", parameters: { pointerType: "touch" }, actions: [
547
+ { type: "pointerMove", duration: 0, x: from.x, y: from.y },
548
+ { type: "pointerDown", button: 0 },
549
+ ...moves,
550
+ { type: "pointerUp", button: 0 }
551
+ ] }]
552
+ });
553
+ } finally {
554
+ if (note && this.log[logged])
555
+ Object.assign(this.log[logged], note);
556
+ }
557
+ await this.cmd("DELETE", "/actions").catch(() => null);
558
+ }
510
559
  async drag(from, to, durationMs, note) {
511
560
  to = { x: to.x, y: Math.min(to.y, await this.safeBottomY()) };
512
561
  const logged = this.log.length;
@@ -734,10 +783,18 @@ var FLING = {
734
783
  contactMs: 150,
735
784
  startMargin: 0.1
736
785
  };
737
- function flingStroke(r, toward, unit, safeBottom, start) {
738
- const [f, t] = FLING[toward];
739
- const travel = Math.round(Math.max(Math.abs(t - f) * r.height, FLING.minTravel * unit));
740
- const step = toward === "end" ? -travel : travel;
786
+ var NAV_STROKE = {
787
+ end: [0.9, 0.1],
788
+ top: [0.2, 1],
789
+ slow: 0.1,
790
+ slowMs: 100,
791
+ fastMs: 45,
792
+ minTravel: 250,
793
+ precise: { margin: 0.2, moveMs: 600, holdMs: 150 }
794
+ };
795
+ function fit(r, f, t, minTravel, unit, safeBottom, start) {
796
+ const travel = Math.round(Math.max(Math.abs(t - f) * r.height, minTravel * unit));
797
+ const step = t > f ? travel : -travel;
741
798
  const clamp = (y) => Math.min(Math.max(y, 0), safeBottom);
742
799
  let from = Math.round(start ?? r.y + r.height * f);
743
800
  const below = Math.max(from, from + step) - safeBottom;
@@ -750,16 +807,38 @@ function flingStroke(r, toward, unit, safeBottom, start) {
750
807
  const to = clamp(from + step);
751
808
  return { from, to, travel, clamped: Math.abs(to - from) < travel };
752
809
  }
810
+ function flingStroke(r, toward, unit, safeBottom, start) {
811
+ const [f, t] = FLING[toward];
812
+ return fit(r, f, t, FLING.minTravel, unit, safeBottom, start);
813
+ }
814
+ function navStroke(r, toward, unit, safeBottom) {
815
+ const [f, t] = NAV_STROKE[toward];
816
+ const s = fit(r, f, t, NAV_STROKE.minTravel, unit, safeBottom);
817
+ const slow = Math.min(Math.round(NAV_STROKE.slow * r.height), Math.floor(Math.abs(s.to - s.from) / 2));
818
+ return { ...s, mid: s.from + (s.to > s.from ? slow : -slow) };
819
+ }
753
820
 
754
821
  // packages/appium-driver/src/scroll.ts
755
822
  var COAST_POLL_MS = 300;
756
823
  var COAST_MAX_MS = 3000;
757
- var FLING_UNMOVED_MS = 4500;
758
824
 
759
825
  class GestureNotTaken extends Error {
760
826
  name = "GestureNotTaken";
761
827
  }
762
- async function scrollList(t, spec, toward, { at, cache, hold, mustMove = false, coastMaxMs = COAST_MAX_MS } = {}) {
828
+ async function scrollList(t, spec, toward, { at, cache, hold, mustMove = false, coastMaxMs = COAST_MAX_MS, precise = false } = {}) {
829
+ if (t.navigating) {
830
+ await t.ready();
831
+ let rect = at;
832
+ if (!rect) {
833
+ if (!spec.list)
834
+ throw new Error("a navigation stroke needs the list (or its frame)");
835
+ const c = cache ?? {};
836
+ c.at = await t.frameOf(spec.list, c.at?.el ?? await t.waitVisible(spec.list));
837
+ rect = c.at.rect;
838
+ }
839
+ await navGesture(t, rect, toward, precise);
840
+ return { outcome: "moved", retried: false, sentAt: t.now() };
841
+ }
763
842
  if (spec.screen) {
764
843
  if (t.driver.platform.name === "ios" && spec.row !== undefined)
765
844
  await drawnRowDrag(t, spec, toward);
@@ -774,15 +853,19 @@ async function scrollList(t, spec, toward, { at, cache, hold, mustMove = false,
774
853
  const c = cache ?? {};
775
854
  const checked = mustMove && spec.row !== undefined;
776
855
  const ios = t.driver.platform.name === "ios";
777
- const held = checked && ios ? hold ?? (c.hold ??= {}) : hold;
856
+ const held = checked ? hold ?? (c.hold ??= {}) : hold;
778
857
  const send = async (first) => {
779
858
  c.at = await t.frameOf(spec.list, c.at?.el ?? await t.waitVisible(spec.list));
780
859
  if (first && held)
781
860
  await holdBeforeScroll(t, spec.row, handle(spec, c), spec.field, held);
782
- const before = checked && ios ? await heldFrame(t, held) : null;
861
+ let before = checked ? await heldFrame(t, held) : null;
862
+ if (checked && before === null) {
863
+ await holdBeforeScroll(t, spec.row, handle(spec, c), spec.field, held);
864
+ before = await heldFrame(t, held);
865
+ }
783
866
  const g = await listGesture(t, c.at.rect, toward);
784
867
  const after = before ? await heldFrame(t, held) : null;
785
- const suspect = ios ? before !== null && before === after : g.outcome === "unmoved";
868
+ const suspect = before !== null && before === after;
786
869
  return { ...g, suspect };
787
870
  };
788
871
  const first = await send(true);
@@ -890,22 +973,38 @@ async function listGesture(t, r, toward) {
890
973
  if (android) {
891
974
  const logged = t.driver.log.length;
892
975
  const sent = t.now();
893
- const answer = await t.driver.execute("mobile: flingGesture", {
976
+ const answer = await t.driver.execute("mobile: swipeGesture", {
894
977
  left: r.x,
895
978
  top: Math.min(s.from, s.to),
896
979
  width: r.width,
897
980
  height: Math.abs(s.to - s.from),
898
- direction: toward === "end" ? "down" : "up",
981
+ direction: toward === "end" ? "up" : "down",
982
+ percent: 1,
899
983
  speed: Math.round(1000 * Math.abs(s.to - s.from) / FLING.contactMs)
900
984
  });
901
985
  const answerMs = t.now() - sent;
902
986
  if (note && t.driver.log[logged])
903
987
  Object.assign(t.driver.log[logged], note);
904
- return { outcome: answer === false && answerMs >= FLING_UNMOVED_MS ? "unmoved" : "moved", answer, answerMs, sentAt: t.now() };
988
+ return { outcome: "moved", answer, answerMs, sentAt: t.now() };
905
989
  }
906
990
  await drag(t, Math.round(r.x + r.width / 2), s, note);
907
991
  return { outcome: "moved", sentAt: t.now() };
908
992
  }
993
+ async function navGesture(t, r, toward, precise = false) {
994
+ const android = t.driver.platform.name === "android";
995
+ const x = Math.round(r.x + r.width / 2);
996
+ if (precise) {
997
+ const p = NAV_STROKE.precise;
998
+ const [a, b] = toward === "end" ? [1 - p.margin, p.margin] : [p.margin, 1 - p.margin];
999
+ const safe = await t.driver.safeBottomY();
1000
+ const from = Math.min(Math.round(r.y + r.height * a), safe);
1001
+ const to = Math.min(Math.round(r.y + r.height * b), safe);
1002
+ await t.driver.stroke({ x, y: from }, [{ x, y: to, ms: p.moveMs }, { x, y: to, ms: p.holdMs }], { nav: true, precise: true });
1003
+ return;
1004
+ }
1005
+ const s = navStroke(r, toward, android ? await t.driver.density() : 1, await t.driver.safeBottomY());
1006
+ await t.driver.stroke({ x, y: s.from }, [{ x, y: s.mid, ms: NAV_STROKE.slowMs }, { x, y: s.to, ms: NAV_STROKE.fastMs }], { nav: true, ...s.clamped ? { clamped: true, travel: Math.abs(s.to - s.from) } : {} });
1007
+ }
909
1008
  var drag = (t, x, s, note) => t.driver.drag({ x, y: s.from }, { x, y: s.to }, FLING.contactMs, note);
910
1009
  async function drawnRowDrag(t, { list, row, above, missingMessage = "no drawn row to scroll from" }, toward) {
911
1010
  await t.ready();
@@ -1030,7 +1129,7 @@ import { dirname, join, relative, resolve } from "node:path";
1030
1129
  import { fileURLToPath } from "node:url";
1031
1130
  var DRIVER_NAME = "appium";
1032
1131
  function driverVersion() {
1033
- const built = "0.1.0-alpha.22";
1132
+ const built = "0.1.0-alpha.23";
1034
1133
  if (built)
1035
1134
  return built;
1036
1135
  let dir = dirname(fileURLToPath(import.meta.url));
@@ -1193,9 +1292,14 @@ var RESCUE_EVERY_MS = 3000;
1193
1292
  var noop = async () => {
1194
1293
  return;
1195
1294
  };
1295
+ var NAV_STEP = "nav";
1296
+ var NAV_EDGE_MS = 300;
1196
1297
  var RETRY_COMMAND = /-retry$/;
1197
- function harnessDefects(commands, gestureRetries) {
1298
+ function harnessDefects(commands, gestureRetries, navigation = {}) {
1198
1299
  const out = { gestureRetry: gestureRetries };
1300
+ for (const [name, n] of Object.entries(navigation))
1301
+ if (n)
1302
+ out[name] = n;
1199
1303
  for (const c of commands)
1200
1304
  if (RETRY_COMMAND.test(c.cmd))
1201
1305
  out[c.cmd] = (out[c.cmd] ?? 0) + 1;
@@ -1218,7 +1322,8 @@ class Session {
1218
1322
  gcN = 0;
1219
1323
  stepDepth = 0;
1220
1324
  stepLog = [];
1221
- defects = { gestureRetry: 0, items: [] };
1325
+ defects = { gestureRetry: 0, navStuck: 0, navCatchUp: 0, items: [] };
1326
+ navigating = false;
1222
1327
  onStepChange = null;
1223
1328
  refinds = 0;
1224
1329
  get staleRefinds() {
@@ -1279,10 +1384,34 @@ class Session {
1279
1384
  this.driver.log.push({ ...this.driver.context, cmd: "ready", t: this.now(), ms: took, status: 0 });
1280
1385
  return took;
1281
1386
  }
1387
+ async navigation(fn) {
1388
+ if (this.stepDepth > 0)
1389
+ throw new Error("a navigation pass cannot run inside a measured step");
1390
+ if (this.navigating)
1391
+ throw new Error("a navigation pass cannot run inside another navigation pass");
1392
+ this.navigating = true;
1393
+ this.marker(NAV_STEP, "start");
1394
+ try {
1395
+ await this.driver.sleep(NAV_EDGE_MS);
1396
+ return await fn();
1397
+ } finally {
1398
+ await this.driver.sleep(NAV_EDGE_MS);
1399
+ this.marker(NAV_STEP, "end");
1400
+ this.navigating = false;
1401
+ }
1402
+ }
1403
+ navigationUnlessStep(fn) {
1404
+ return this.stepDepth > 0 || this.navigating ? fn() : this.navigation(fn);
1405
+ }
1406
+ declaresSteps() {
1407
+ return this.markers.some((m) => m.phase === "start" && m.step !== NAV_STEP);
1408
+ }
1282
1409
  marker(step, phase, gcN = null) {
1283
1410
  this.markers.push({ token: this.token, step, phase, t_host: this.now(), ...gcN !== null ? { gc_n: gcN } : {} });
1284
1411
  }
1285
1412
  async step(name, action, checks = noop, once = noop) {
1413
+ if (this.navigating)
1414
+ throw new Error("a measured step cannot start inside a navigation pass");
1286
1415
  await this.ready();
1287
1416
  this.driver.context = { ...this.driver.context, step: name };
1288
1417
  const record = { journey: this.driver.context.journey, iteration: this.driver.context.iteration, name, depth: this.stepDepth, t: this.now(), ms: null };
@@ -1448,7 +1577,11 @@ class Session {
1448
1577
  if (!await this.isVisible(target))
1449
1578
  throw new Error(`${describeSelector(toSelector(target))} not visible after ${times} swipes from ${describeSelector(toSelector(from))}`);
1450
1579
  }
1451
- async scrollUntil(sel, { direction = "down", timeoutMs = 15000 } = {}) {
1580
+ async scrollUntil(sel, options = {}) {
1581
+ await this.ready();
1582
+ return this.navigationUnlessStep(() => this.scrollWindowUntil(sel, options));
1583
+ }
1584
+ async scrollWindowUntil(sel, { direction = "down", timeoutMs = 15000 }) {
1452
1585
  const s = toSelector(sel);
1453
1586
  const deadline = this.now() + timeoutMs;
1454
1587
  let lookUntil = 0;
@@ -1517,7 +1650,11 @@ class Session {
1517
1650
  this.driver.log.push({ ...this.driver.context, cmd: `fling-${toward}-times`, t: this.now(), ms: this.now() - start, status, flings, refinds: this.refinds - refindsBefore });
1518
1651
  }
1519
1652
  }
1520
- async flingUntil(list, target, toward, { blind = 0, timeoutMs = FLING_TIMEOUT_MS } = {}) {
1653
+ async flingUntil(list, target, toward, options = {}) {
1654
+ await this.ready();
1655
+ return this.navigationUnlessStep(() => this.flingToTarget(list, target, toward, options));
1656
+ }
1657
+ async flingToTarget(list, target, toward, { blind = 0, timeoutMs = FLING_TIMEOUT_MS }) {
1521
1658
  await this.ready();
1522
1659
  const start = this.now();
1523
1660
  const refindsBefore = this.refinds;
@@ -1597,7 +1734,7 @@ class Session {
1597
1734
  this.token = token;
1598
1735
  if (token)
1599
1736
  this.opened.add(token);
1600
- this.runFrom = { commands: this.driver.log.length, gestureRetries: this.defects.gestureRetry };
1737
+ this.runFrom = { commands: this.driver.log.length, gestureRetries: this.defects.gestureRetry, navStuck: this.defects.navStuck, navCatchUp: this.defects.navCatchUp };
1601
1738
  this.pendingReady = null;
1602
1739
  await this.relaunch();
1603
1740
  await this.waitVisible(home, 30000);
@@ -1612,10 +1749,10 @@ class Session {
1612
1749
  lastFlush = null;
1613
1750
  flushSeq = 0;
1614
1751
  async endRun() {
1615
- if (this.markers.some((m) => m.phase === "start"))
1752
+ if (this.declaresSteps())
1616
1753
  await this.memRead(await this.gc(true));
1617
1754
  if (this.runFrom && this.token)
1618
- this.runDefects.set(this.token, harnessDefects(this.driver.log.slice(this.runFrom.commands), this.defects.gestureRetry - this.runFrom.gestureRetries));
1755
+ this.runDefects.set(this.token, harnessDefects(this.driver.log.slice(this.runFrom.commands), this.defects.gestureRetry - this.runFrom.gestureRetries, { navStuck: this.defects.navStuck - this.runFrom.navStuck, navCatchUp: this.defects.navCatchUp - this.runFrom.navCatchUp }));
1619
1756
  this.runFrom = null;
1620
1757
  await sendMarkers(this.fetch, this.api, this.markers, { sleep: (ms) => this.driver.sleep(ms), now: () => this.now(), deviceUnit: this.driver.deviceUnit, capture: this.capture, defects: (token) => this.runDefects.get(token), tokens: [...this.opened], reported: this.told, note: (line) => this.driver.log.push({ ...this.driver.context, cmd: "driver-report", t: this.now(), ms: 0, status: 1, answer: line }) });
1621
1758
  const evidence = { linkAt: this.now(), resentAt: null, ackAt: null, gaveUpAt: null };
@@ -2005,7 +2142,7 @@ async function runIterations(t, module, ctx, iterations, progress) {
2005
2142
  await t.ready();
2006
2143
  const t2 = t.now();
2007
2144
  t.marker("run:complete", "end");
2008
- rec.steps = t.markers.filter((m) => m.phase === "start").length;
2145
+ rec.steps = t.markers.filter((m) => m.phase === "start" && m.step !== NAV_STEP).length;
2009
2146
  const waits = t.readyWaits.slice(waitsBefore);
2010
2147
  rec.ready = { n: waits.length, ms: waits.reduce((x, y) => x + y, 0) };
2011
2148
  driver.context = { ...driver.context, phase: "end" };