scenescout 3.11.1 โ 3.13.0
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/CHANGELOG.md +31 -0
- package/README.md +29 -3
- package/dist/check-run.js +4 -2
- package/dist/ci-run.js +366 -0
- package/dist/cli.js +69 -0
- package/dist/engine/bench.js +147 -14
- package/dist/engine/browser.js +63 -34
- package/dist/engine/calibration.js +23 -6
- package/dist/engine/check.js +89 -15
- package/dist/engine/ci.js +633 -0
- package/dist/engine/dedup.js +237 -0
- package/dist/engine/design.js +15 -1
- package/dist/engine/fingerprint.js +57 -0
- package/dist/engine/lane.js +57 -3
- package/dist/engine/memory.js +115 -8
- package/dist/engine/policy.js +42 -0
- package/dist/engine/provider.js +193 -0
- package/dist/engine/report.js +37 -2
- package/dist/engine/verify.js +4 -3
- package/dist/mcp-server.js +23 -9
- package/package.json +6 -3
- package/skills/scenescout/SKILL.md +4 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# scenescout
|
|
2
2
|
|
|
3
|
+
## 3.13.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- a6bc077: Add a QA review started from a pull-request comment. An account the repository allows (by default its owners) comments `/scenescout qa` on a pull request, optionally with a preview URL and a focus, and an unattended `scenescout ci` run explores that pull request's deployed preview and posts its results as a reply, with a link to the full report. The job that holds the model's key checks out nothing and runs SceneScout from an exact release tag, so the pull request's code never runs beside the key; pull requests from forks are refused unless the repository allows them. The workflow to copy is `examples/workflows/scenescout-qa.yml`, and the new `brunoboto96/SceneScout/qa` action runs its keyless steps.
|
|
8
|
+
- 7280e29: Add `scenescout ci <url>`, an exploratory run with no person present: a model reached through the Anthropic Messages API or the OpenAI Responses API drives the scout_* tools by the SceneScout method and the run ends in the ordinary report. It reports and never gates: it exits 0 when the run ran, whatever it found, and 2 when it could not run.
|
|
9
|
+
|
|
10
|
+
- The key is read from `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` only and is redacted from everything the run prints or writes; with both set, `--provider` chooses. `--model`, `--effort` (default `low`) and `--base-url` override the defaults (`claude-sonnet-5`, `gpt-6-luna`).
|
|
11
|
+
- The run stops at the first of 40 turns, 1,500,000 tokens or 20 minutes (`--max-turns`, `--max-tokens`, `--max-minutes`), still writes the report, and says which cap ended it.
|
|
12
|
+
- It runs in `read-only` mode by default (`--mode observe|read-only|safe-write`, or `destructive` together with `--allow-destructive`) at level `medium` (`--level`).
|
|
13
|
+
- It writes `report.md`, `report.html`, `summary.md` (also on the GitHub job summary), `ci.json` and `ci.sarif`, with a usage line of turns, tokens, time and an estimated cost. `--price-in`, `--price-cached-in` and `--price-out` set the prices the estimate uses, for any model.
|
|
14
|
+
- A second GitHub Action, `brunoboto96/SceneScout/ci`, runs it; its inputs are the command's options.
|
|
15
|
+
|
|
16
|
+
### Patch Changes
|
|
17
|
+
|
|
18
|
+
- d392192: `scenescout check` no longer lets a write out under the crawl's rule when a saved flow's page sent it under the flow's: a beacon a page sent as the flow left it, heard of a moment after the hand-back, could reach the server under `--flow-writes never` about one run in five in Chromium, and a page a step had opened a tab from stayed open and sent its leaving beacon when the session closed. Every page in the context is now left before the crawl's rule comes back, and the flow's rule keeps judging for five seconds after.
|
|
19
|
+
|
|
20
|
+
## 3.12.0
|
|
21
|
+
|
|
22
|
+
### Minor Changes
|
|
23
|
+
|
|
24
|
+
- 967e98c: Add a "worth a look" tier for observations that are defects only under a convention of the project the run cannot see, such as a spacing scale, link styling in navigation, or test ids on every control. SceneScout reports each one with the convention that would decide it, and never counts it as a defect.
|
|
25
|
+
|
|
26
|
+
- Lane reports accept the verdict `worth_a_look`, which must name its `convention`. A `convention` on any other verdict is ignored, and the fold says so. Lane calibration and the benchmark's key calibration leave it unscored and count it under its own reason.
|
|
27
|
+
- `scout_finding` takes an optional `convention`, which files the finding in this tier. The report lists these findings under "Worth a look", below the findings, as "a defect only if your project uses โฆ", and leaves them out of every defect total. Filing the same thing again as a defect promotes it, at the defect's severity, and the reply says so. The benchmark sets such findings aside from recall and precision, and the fold lists a lane's judged defect as unfiled when it was filed only as worth a look.
|
|
28
|
+
- `scenescout check` reports two new rules, `off-grid-spacing` and `indistinct-link`, in this tier. They have no severity and never fail the gate at any `--fail-on`. SARIF reports them at level `note`. The report and the job summary list them in their own section, `check.json` puts them under `worthALook`, separate from `issues`, and the GitHub Action publishes their number as the `worth-a-look` output. Every existing rule is unchanged.
|
|
29
|
+
|
|
30
|
+
### Patch Changes
|
|
31
|
+
|
|
32
|
+
- c40892f: `scout_lane_report` keeps the routes each lane's accepted report lists in the project's memory (`laneRoutes` in `.scenescout/memory.json`), with query strings and fragments removed so no token in an address is stored, and `npm run bench -- --archive` carries them into the run archive. The benchmark then decides whether a lane's "not a defect" is a remark about another lane's page by where the matched defect is, not by how the verdict is worded: a defect none of whose pages the lane covered is set aside as another lane's and listed on the scorecard, and one on the lane's own page, or one every lane can reach, is scored. Answer-key entries can name further pages with `alsoOn` and mark a defect every lane can reach with `everyPage`. Archives made before routes were kept, and lanes with a route that names no page, are scored by wording, as before.
|
|
33
|
+
|
|
3
34
|
## 3.11.1
|
|
4
35
|
|
|
5
36
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -332,6 +332,7 @@ A `๐ก WRITE-POLICY blocked` notice is the safety net doing its job, not an app
|
|
|
332
332
|
`.scenescout/report.md` โ a deduplicated, worst-first report with:
|
|
333
333
|
|
|
334
334
|
- ๐ **Findings** with repro traces and generated Playwright regression-test skeletons.
|
|
335
|
+
- ๐ **Worth a look** โ observations that are defects only under a convention of your project the run cannot see (a spacing scale, link styling in navigation, test ids on every control), each naming that convention. Listed below the findings and not counted as defects ([ADR 13](docs/adr/0013-a-convention-is-the-projects-to-decide.md)).
|
|
335
336
|
- ๐ฏ **Page scores** (0โ100: a11y ยท craft ยท consistency ยท task-clarity), ranked worst-first, with stale scores from old runs marked as such.
|
|
336
337
|
- ๐ฅ **A role capability matrix** โ what each role could and couldn't reach.
|
|
337
338
|
- ๐งพ **A gap ledger** โ everything *not* done, so the report is honest about its own coverage.
|
|
@@ -355,6 +356,8 @@ An exploratory run is driven by a model, so two runs never find exactly the same
|
|
|
355
356
|
- contrast and focus
|
|
356
357
|
- pages with no way out
|
|
357
358
|
|
|
359
|
+
Some of what it measures is a defect only under a convention the check cannot see: paddings off a 4px grid, and links styled like body text. Those are listed under **Worth a look**, each with the convention that would make it a defect. They are never counted and never fail the gate, at any `--fail-on`; SARIF reports them at level `note` ([ADR 13](docs/adr/0013-a-convention-is-the-projects-to-decide.md)).
|
|
360
|
+
|
|
358
361
|
```bash
|
|
359
362
|
npx scenescout check http://127.0.0.1:3000 --fail-on high
|
|
360
363
|
```
|
|
@@ -380,7 +383,7 @@ On GitHub Actions, this repository is also an action that installs everything an
|
|
|
380
383
|
With the default settings its saved flows send no HTTP write (they replay under observe's rule), and its crawl runs under `--mode observe` or `read-only`; `--flow-writes allow` lets flows write as `--mode` allows. By default it fails only on facts that mean a page is broken: a page that did not load, an uncaught exception, a 5xx, a failure shown as success. Other options:
|
|
381
384
|
|
|
382
385
|
- `--fail-on medium` or `low` makes the gate stricter.
|
|
383
|
-
- `--ignore <rule>` drops a rule.
|
|
386
|
+
- `--ignore <rule>` drops a rule, worth-a-look rules included.
|
|
384
387
|
- `--paths /a,/b` checks only those pages.
|
|
385
388
|
- `--storage-state <file>` checks while signed in.
|
|
386
389
|
- `--flows <dir>` or `off` chooses which saved flows to replay; `--retest off` skips re-testing open findings.
|
|
@@ -396,6 +399,25 @@ It also replays the flows saved in `.scenescout/flows/*.json`, with no model: th
|
|
|
396
399
|
|
|
397
400
|
Beyond those flows it explores nothing and fills no forms. That is the exploratory run's job, and its findings belong in a report, not a gate.
|
|
398
401
|
|
|
402
|
+
## ๐ค In CI: an unattended exploratory run
|
|
403
|
+
|
|
404
|
+
`scenescout ci` runs the exploratory side in a CI job, with no person and no coding agent. A model reached through its API drives the same `scout_*` tools by the same method, and the run ends in the ordinary report:
|
|
405
|
+
|
|
406
|
+
```bash
|
|
407
|
+
export OPENAI_API_KEY=โฆ # or ANTHROPIC_API_KEY; read from the environment only
|
|
408
|
+
npx scenescout ci http://127.0.0.1:3000
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
- **It reports and never gates.** Exit 0 when the run ran, whatever it found; exit 2 when it could not run (no key, a key the API refused, an app that never answered). Two runs of the same app find different things, so a finding is something to read, never a reason to fail a build. `scenescout check` is the gate.
|
|
412
|
+
- **Providers:** the Anthropic Messages API (default model `claude-sonnet-5`) or the OpenAI Responses API (default `gpt-6-luna`), chosen by which key is set; with both set, `--provider` decides. `--model` and `--effort` (default `low`) override; `--base-url` points at another endpoint that implements the same API.
|
|
413
|
+
- **Caps:** at most 40 model turns, 1,500,000 tokens and 20 minutes (`--max-turns`, `--max-tokens`, `--max-minutes`). The first cap reached ends the exploration; the report is still written, and says which cap ended it.
|
|
414
|
+
- **Mode:** `read-only` by default; `--mode observe` sends no form at all, `--mode safe-write` lets the run create records and change only the ones it created. `--mode destructive` runs only with `--allow-destructive` as well.
|
|
415
|
+
- **Output**, in `.scenescout/ci/` (or `--out`): `report.md` and `report.html` (the report an agent's run writes), `summary.md` (also appended to the GitHub job summary), `ci.json` and `ci.sarif`, with a usage line: turns, tokens, time and an estimated cost where the model's price is known (`--price-in`, `--price-out` give one for any model).
|
|
416
|
+
|
|
417
|
+
There is a GitHub Action for it (`uses: brunoboto96/SceneScout/ci@โฆ`). [docs/ci.md](docs/ci.md#an-unattended-exploratory-run) has the workflow and every option; [ADR 14](docs/adr/0014-an-unattended-run-reports-and-never-gates.md) says why it works this way.
|
|
418
|
+
|
|
419
|
+
On a pull request, an allowed account can comment `/scenescout qa` to run it against that pull request's deployed preview and get the results as a reply. The job that holds the key checks out nothing and runs SceneScout from an exact release tag, so the pull request's code never runs beside the key. [docs/ci.md](docs/ci.md#a-qa-review-from-a-pull-request-comment) has the workflow to copy and what a project configures; [ADR 15](docs/adr/0015-a-qa-comment-tests-a-preview-and-never-runs-the-pull-requests-code.md) says why.
|
|
420
|
+
|
|
399
421
|
---
|
|
400
422
|
|
|
401
423
|
## ๐ฉบ Troubleshooting
|
|
@@ -603,8 +625,9 @@ npx -y scenescout watch <path> # the same, live in your browser, with each
|
|
|
603
625
|
src/
|
|
604
626
|
mcp-server.ts the 29 tools + per-session dispatch
|
|
605
627
|
scan.ts project discovery (framework, routes, auth)
|
|
606
|
-
cli.ts scan ยท serve ยท install ยท doctor ยท check ยท status
|
|
628
|
+
cli.ts scan ยท serve ยท install ยท doctor ยท check ยท ci ยท status
|
|
607
629
|
check-run.ts drives a check: attach, crawl every route, collect what was measured
|
|
630
|
+
ci-run.ts drives a CI run: the MCP server as a child, the model's API, the agent loop
|
|
608
631
|
installer.ts setup logic (skill link, MCP registration, diagnostics)
|
|
609
632
|
engine/
|
|
610
633
|
browser.ts the engine class: attach, snapshot, actions, crawl, plans
|
|
@@ -627,9 +650,11 @@ src/
|
|
|
627
650
|
memory.ts cross-run storage + finding dedup
|
|
628
651
|
report.ts the gap ledger + report generation
|
|
629
652
|
check.ts the check's rules, gate, report and SARIF
|
|
653
|
+
ci.ts a CI run's options, provider choice, caps, key redaction, tools and files
|
|
654
|
+
provider.ts the Anthropic and OpenAI message shapes, and retries
|
|
630
655
|
replay.ts the run as one page: steps, tasks, frames under each finding
|
|
631
656
|
โฆ collector ยท dispatch ยท fixtures ยท authloss ยท reaper
|
|
632
|
-
scripts/ the
|
|
657
|
+
scripts/ the test suites (smoke/ holds the real-browser ones)
|
|
633
658
|
test-app/ fixtures for the real-browser smoke tests
|
|
634
659
|
skills/scenescout/ the testing method (SKILL.md): a skill in Claude Code, served by the server everywhere else
|
|
635
660
|
docs/how-it-works.md what happens at each stage, in diagrams
|
|
@@ -661,6 +686,7 @@ The load-bearing choices are recorded as ADRs โ read the relevant one before c
|
|
|
661
686
|
- [10 ยท A lane's confidence is checked, not trusted](docs/adr/0010-a-confidence-is-checked-not-trusted.md)
|
|
662
687
|
- [11 ยท A gate is deterministic, and fails only on what it can prove](docs/adr/0011-a-gate-is-deterministic-and-fails-only-on-what-it-can-prove.md)
|
|
663
688
|
- [12 ยท A check replays saved flows and re-tests open findings, within settings whose defaults do the least harm](docs/adr/0012-a-check-replays-saved-flows-and-reports-re-tests.md)
|
|
689
|
+
- [13 ยท What depends on a project's convention is the project's to decide](docs/adr/0013-a-convention-is-the-projects-to-decide.md)
|
|
664
690
|
|
|
665
691
|
---
|
|
666
692
|
|
package/dist/check-run.js
CHANGED
|
@@ -9,7 +9,7 @@ import fs from "node:fs";
|
|
|
9
9
|
import os from "node:os";
|
|
10
10
|
import path from "node:path";
|
|
11
11
|
import { BrowserEngine } from "./engine/browser.js";
|
|
12
|
-
import {
|
|
12
|
+
import { checkFindings, redactFlowRuns, redactRoute, redactRoutes, settingsOf, withoutOwnResponse, } from "./engine/check.js";
|
|
13
13
|
import { loadFlows, resolveFlowsDir } from "./engine/flow.js";
|
|
14
14
|
import { MemoryStore, MEMORY_DIRNAME } from "./engine/memory.js";
|
|
15
15
|
import { checkRetestPlan, retestResults, wellFormedFindings } from "./engine/verify.js";
|
|
@@ -123,13 +123,15 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
|
|
|
123
123
|
: null;
|
|
124
124
|
const measured = redactRoutes(routes.map(withoutOwnResponse));
|
|
125
125
|
const flows = redactFlowRuns(flowRuns);
|
|
126
|
+
const { issues, worthALook } = checkFindings(measured, start.origin, options.ignore, flows);
|
|
126
127
|
return {
|
|
127
128
|
url: redactRoute(options.url),
|
|
128
129
|
generatedAt: new Date().toISOString(),
|
|
129
130
|
mode: options.mode,
|
|
130
131
|
failOn: options.failOn,
|
|
131
132
|
routes: measured,
|
|
132
|
-
issues
|
|
133
|
+
issues,
|
|
134
|
+
worthALook,
|
|
133
135
|
// Routes that failed to load are issues already; "not visited" is only what --max-routes left out.
|
|
134
136
|
unvisited: options.paths ? [] : engine.crawlableRoutes().map(redactRoute),
|
|
135
137
|
ignored: options.ignore,
|
package/dist/ci-run.js
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs `scenescout ci`: starts the SceneScout MCP server as a child process,
|
|
3
|
+
* attaches it to the app, lets a model drive the scout_* tools until it is
|
|
4
|
+
* done or a cap ends the run, then has the report written and writes the CI
|
|
5
|
+
* files. The rules (options, caps, redaction, which tools, the files) are in
|
|
6
|
+
* engine/ci.ts and the message shapes in engine/provider.ts; this file only
|
|
7
|
+
* moves bytes between the model, the server and the disk.
|
|
8
|
+
*
|
|
9
|
+
* The server runs in its own process, over the same stdio protocol every
|
|
10
|
+
* coding agent uses, rather than in this one: the model then gets exactly the
|
|
11
|
+
* tools, schemas, watchdog and write policy an agent gets, and the server,
|
|
12
|
+
* which holds module-wide state, needs no second way to start. Its
|
|
13
|
+
* environment has no API key in it (childEnv).
|
|
14
|
+
*/
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
19
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
20
|
+
import { addUsage, wallLeftMs, guardToolArgs, capReached, childEnv, CI_DIRNAME, ciExitCode, ciKickoff, ciSarif, ciSummaryJson, ciSummaryMarkdown, ciSystemPrompt, ciToolArgs, ciTools, describeStop, findingsThisRun, NO_USAGE, readFindings, redactKeys, toolResultText, usageLine, } from "./engine/ci.js";
|
|
21
|
+
import { MEMORY_DIRNAME, writeSelfIgnore } from "./engine/memory.js";
|
|
22
|
+
import { AnthropicConversation, backoffMs, errorMessage, MalformedReply, OpenAIConversation, retryable, retryAfterMs, } from "./engine/provider.js";
|
|
23
|
+
import { loadPlaybook } from "./playbook.js";
|
|
24
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
/** From dist/ or, under tsx, from src/: the package root is one up either way. */
|
|
26
|
+
const packageRoot = path.resolve(here, "..");
|
|
27
|
+
const serverPath = path.join(packageRoot, "dist", "mcp-server.js");
|
|
28
|
+
/** The longest one attempt at a model call may take. An attempt that runs longer, with wall time left, is retried like a server error. */
|
|
29
|
+
export const MODEL_CALL_MS = 180_000;
|
|
30
|
+
/** Retries of one model call after its first attempt. */
|
|
31
|
+
export const MAX_RETRIES = 3;
|
|
32
|
+
/**
|
|
33
|
+
* The whole of what happens after the exploration ends: writing the report
|
|
34
|
+
* (a second, forced attempt when the level's contract is unmet) and closing
|
|
35
|
+
* the browser share this one budget, so a run ends at most this long after
|
|
36
|
+
* its time cap, plus the few seconds the files take.
|
|
37
|
+
*/
|
|
38
|
+
export const FINISH_MS = 180_000;
|
|
39
|
+
/** Attaching before the exploration, which counts towards the time cap. */
|
|
40
|
+
const ATTACH_MS = 120_000;
|
|
41
|
+
/** Tool calls run from one model reply; any beyond are answered as not run. Bounds what one turn can spend. Set here only. */
|
|
42
|
+
export const MAX_TOOL_CALLS_PER_TURN = 16;
|
|
43
|
+
/** A model call failed for good: after its retries, or on a status no retry can change (a refused key, a bad request, a redirect). */
|
|
44
|
+
export class ProviderError extends Error {
|
|
45
|
+
}
|
|
46
|
+
/** The time cap arrived during a model call, its retries or the wait between them. */
|
|
47
|
+
export class OutOfTime extends Error {
|
|
48
|
+
}
|
|
49
|
+
const attempts = (n) => `${n} attempt${n === 1 ? "" : "s"}`;
|
|
50
|
+
/** Whether a thrown fetch error is a refused redirect (undici reports it as a TypeError whose cause says so). */
|
|
51
|
+
function isRedirectError(err) {
|
|
52
|
+
const cause = err instanceof Error ? err.cause : undefined;
|
|
53
|
+
return [err, cause].some((e) => e instanceof Error && /redirect/i.test(e.message));
|
|
54
|
+
}
|
|
55
|
+
/** The real client: one conversation, one key, bounded retries with backoff and jitter. */
|
|
56
|
+
export class HttpModelClient {
|
|
57
|
+
conversation;
|
|
58
|
+
key;
|
|
59
|
+
deps;
|
|
60
|
+
constructor(conversation, key, deps = {}) {
|
|
61
|
+
this.conversation = conversation;
|
|
62
|
+
this.key = key;
|
|
63
|
+
this.deps = deps;
|
|
64
|
+
}
|
|
65
|
+
async next(wallLeftMs) {
|
|
66
|
+
const now = this.deps.now ?? Date.now;
|
|
67
|
+
const sleep = this.deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
68
|
+
const attemptMs = this.deps.attemptMs ?? MODEL_CALL_MS;
|
|
69
|
+
const wallDeadline = now() + wallLeftMs;
|
|
70
|
+
let last = "";
|
|
71
|
+
let made = 0;
|
|
72
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
73
|
+
const left = wallDeadline - now();
|
|
74
|
+
if (left <= 0)
|
|
75
|
+
throw new OutOfTime(`the time cap was reached${made > 0 ? ` after ${attempts(made)} (${last})` : ""}`);
|
|
76
|
+
const req = this.conversation.request(this.key);
|
|
77
|
+
let status = 0;
|
|
78
|
+
let body = "";
|
|
79
|
+
let retryAfter;
|
|
80
|
+
made += 1;
|
|
81
|
+
try {
|
|
82
|
+
const res = await (this.deps.fetch ?? fetch)(req.url, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: req.headers,
|
|
85
|
+
body: JSON.stringify(req.body),
|
|
86
|
+
// Never followed: a redirect would carry the request, and a key sent
|
|
87
|
+
// as x-api-key (which fetch does not strip across origins) with it.
|
|
88
|
+
redirect: "error",
|
|
89
|
+
signal: AbortSignal.timeout(Math.min(attemptMs, left)),
|
|
90
|
+
});
|
|
91
|
+
status = res.status;
|
|
92
|
+
body = await res.text();
|
|
93
|
+
retryAfter = retryAfterMs(res.headers.get("retry-after"), now());
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
if (isRedirectError(err))
|
|
97
|
+
throw new ProviderError("the API answered with a redirect, which is not followed");
|
|
98
|
+
const timedOut = err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError");
|
|
99
|
+
if (timedOut && wallDeadline - now() <= 0)
|
|
100
|
+
throw new OutOfTime(`the time cap was reached during a model call (${attempts(made)})`);
|
|
101
|
+
last = timedOut
|
|
102
|
+
? `the model call took longer than ${Math.round(attemptMs / 1000)}s`
|
|
103
|
+
: `the request failed (${err instanceof Error ? err.message : String(err)})`;
|
|
104
|
+
}
|
|
105
|
+
if (status >= 300 && status < 400)
|
|
106
|
+
throw new ProviderError(`HTTP ${status}: the API answered with a redirect, which is not followed`);
|
|
107
|
+
if (status >= 200 && status < 300) {
|
|
108
|
+
try {
|
|
109
|
+
return this.conversation.accept(JSON.parse(body));
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
// A reply that is not what the API promises is retried like a server error, never run.
|
|
113
|
+
last = err instanceof MalformedReply ? `a malformed reply: ${err.message}` : "a reply that is not JSON";
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
else if (status !== 0) {
|
|
117
|
+
last = errorMessage(status, body);
|
|
118
|
+
if (!retryable(status))
|
|
119
|
+
throw new ProviderError(last);
|
|
120
|
+
}
|
|
121
|
+
if (attempt < MAX_RETRIES) {
|
|
122
|
+
const wait = backoffMs(attempt + 1, this.deps.random ?? Math.random, { retryAfterMs: retryAfter });
|
|
123
|
+
// Waiting past the time cap would only end the run there: end it now, as a cap and not a failure.
|
|
124
|
+
if (now() + wait >= wallDeadline)
|
|
125
|
+
throw new OutOfTime(`the time cap was reached after ${attempts(made)} (${last})`);
|
|
126
|
+
await sleep(wait);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
throw new ProviderError(`${last} (after ${attempts(made)})`);
|
|
130
|
+
}
|
|
131
|
+
addResults(results) {
|
|
132
|
+
this.conversation.addResults(results);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
export function httpClient(resolved, key, system, tools, kickoff) {
|
|
136
|
+
const o = { baseUrl: resolved.baseUrl, model: resolved.model, effort: resolved.effort, system, tools };
|
|
137
|
+
const conversation = resolved.provider === "anthropic" ? new AnthropicConversation(o, kickoff) : new OpenAIConversation(o, kickoff);
|
|
138
|
+
return new HttpModelClient(conversation, key);
|
|
139
|
+
}
|
|
140
|
+
async function startServer(log) {
|
|
141
|
+
if (!fs.existsSync(serverPath))
|
|
142
|
+
throw new Error(`${serverPath} is missing: run \`npm run build\` first`);
|
|
143
|
+
const transport = new StdioClientTransport({ command: process.execPath, args: [serverPath], env: childEnv(process.env), stderr: "pipe" });
|
|
144
|
+
transport.stderr?.on("data", (chunk) => {
|
|
145
|
+
for (const line of chunk.toString("utf8").split(/\r?\n/))
|
|
146
|
+
if (line.trim())
|
|
147
|
+
log(` [server] ${line}`);
|
|
148
|
+
});
|
|
149
|
+
const client = new Client({ name: "scenescout-ci", version: "1" });
|
|
150
|
+
await client.connect(transport);
|
|
151
|
+
return {
|
|
152
|
+
tools: async () => (await client.listTools()).tools,
|
|
153
|
+
call: async (name, args, timeoutMs) => {
|
|
154
|
+
const r = (await client.callTool({ name, arguments: args }, undefined, { timeout: timeoutMs }));
|
|
155
|
+
return { text: toolResultText(r.content), isError: r.isError === true };
|
|
156
|
+
},
|
|
157
|
+
close: async () => {
|
|
158
|
+
await client.close();
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function readMemoryFindings(projectDir) {
|
|
163
|
+
try {
|
|
164
|
+
return readFindings(JSON.parse(fs.readFileSync(path.join(projectDir, MEMORY_DIRNAME, "memory.json"), "utf8")));
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// No memory yet (a first run), or one that does not parse: nothing earlier to tell this run's findings from.
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* The agent loop. Before each model call the caps are checked; the call runs
|
|
173
|
+
* with at most the time that is left; each tool call it asks for is run in
|
|
174
|
+
* order, on the one session, and none runs past the time cap: a call reached
|
|
175
|
+
* after it is answered as not run, as is any beyond MAX_TOOL_CALLS_PER_TURN.
|
|
176
|
+
* A call the loop cannot run (a tool it was not given, arguments that are not
|
|
177
|
+
* JSON, a scan of another directory) goes back to the model as an error
|
|
178
|
+
* result, so a malformed reply costs a turn, never the run.
|
|
179
|
+
*/
|
|
180
|
+
export async function agentLoop(o) {
|
|
181
|
+
const now = o.now ?? Date.now;
|
|
182
|
+
const spend = { turns: 0, usage: { ...NO_USAGE }, startedAt: o.startedAt ?? now() };
|
|
183
|
+
const allowed = new Set(o.tools.map((t) => t.name));
|
|
184
|
+
for (;;) {
|
|
185
|
+
const cap = capReached(spend, o.caps, now());
|
|
186
|
+
if (cap)
|
|
187
|
+
return { stop: cap, spend };
|
|
188
|
+
let turn;
|
|
189
|
+
try {
|
|
190
|
+
turn = await o.client.next(wallLeftMs(spend, o.caps, now()));
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
if (err instanceof OutOfTime)
|
|
194
|
+
return { stop: "time", spend };
|
|
195
|
+
return { stop: "provider-error", stopDetail: err instanceof Error ? err.message : String(err), spend };
|
|
196
|
+
}
|
|
197
|
+
spend.turns += 1;
|
|
198
|
+
spend.usage = addUsage(spend.usage, turn.usage);
|
|
199
|
+
if (turn.resume)
|
|
200
|
+
continue;
|
|
201
|
+
if (turn.calls.length === 0) {
|
|
202
|
+
if (turn.text.trim())
|
|
203
|
+
o.log(` model: ${turn.text.trim().slice(0, 600)}`);
|
|
204
|
+
return { stop: "done", ...(turn.note ? { stopDetail: turn.note } : {}), spend };
|
|
205
|
+
}
|
|
206
|
+
o.log(` turn ${spend.turns}: ${turn.calls.map((c) => c.name || "(unnamed)").join(", ")} โ ${(spend.usage.input + spend.usage.output).toLocaleString("en-US")} tokens so far`);
|
|
207
|
+
const results = [];
|
|
208
|
+
for (const [i, call] of turn.calls.entries()) {
|
|
209
|
+
if (i >= MAX_TOOL_CALLS_PER_TURN) {
|
|
210
|
+
results.push({ id: call.id, isError: true, text: `${call.name} was not run: at most ${MAX_TOOL_CALLS_PER_TURN} tool calls are run from one reply.` });
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (!allowed.has(call.name)) {
|
|
214
|
+
results.push({ id: call.id, isError: true, text: `There is no tool named "${call.name}" in this run. The tools are: ${[...allowed].join(", ")}.` });
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (call.argsError) {
|
|
218
|
+
results.push({ id: call.id, isError: true, text: `${call.name} was not run: ${call.argsError}. Send the arguments as one JSON object.` });
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const args = ciToolArgs(call.input);
|
|
222
|
+
const guarded = args.ok ? guardToolArgs(call.name, args.args, o.projectDir) : args;
|
|
223
|
+
if (!guarded.ok) {
|
|
224
|
+
results.push({ id: call.id, isError: true, text: `${call.name} was not run: ${guarded.error}.` });
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const left = wallLeftMs(spend, o.caps, now());
|
|
228
|
+
if (left <= 0) {
|
|
229
|
+
results.push({ id: call.id, isError: true, text: `${call.name} was not run: the time cap was reached.` });
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
const r = await o.host.call(call.name, guarded.args, Math.min(600_000, left));
|
|
234
|
+
results.push({ id: call.id, isError: r.isError, text: r.text });
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
results.push({ id: call.id, isError: true, text: `${call.name} failed: ${err instanceof Error ? err.message : String(err)}` });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
o.client.addResults(results);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* The whole run. `makeClient` is how the model is reached: the HTTP client in
|
|
245
|
+
* the CLI, a scripted one in the tests. Everything logged or written passes
|
|
246
|
+
* through the key redaction first.
|
|
247
|
+
*/
|
|
248
|
+
export async function runCi(options, resolved, deps) {
|
|
249
|
+
const secrets = deps.secrets ?? [];
|
|
250
|
+
const log = (line) => (deps.log ?? (() => { }))(redactKeys(line, secrets));
|
|
251
|
+
const now = deps.now ?? Date.now;
|
|
252
|
+
const startedAt = now();
|
|
253
|
+
const outDir = options.outDir ?? path.join(options.projectDir, MEMORY_DIRNAME, CI_DIRNAME);
|
|
254
|
+
const before = readMemoryFindings(options.projectDir);
|
|
255
|
+
let outcome = { stop: "could-not-start", spend: { turns: 0, usage: { ...NO_USAGE }, startedAt } };
|
|
256
|
+
let contractMet = false;
|
|
257
|
+
let reportWritten = false;
|
|
258
|
+
let host = null;
|
|
259
|
+
// Set when the exploration ends (or never starts): the report and the close share FINISH_MS from then.
|
|
260
|
+
let finishBy = 0;
|
|
261
|
+
const finishLeft = () => Math.max(1_000, finishBy - now());
|
|
262
|
+
try {
|
|
263
|
+
host = await startServer(log);
|
|
264
|
+
const attached = await host.call("scout_attach", {
|
|
265
|
+
url: options.url,
|
|
266
|
+
projectPath: options.projectDir,
|
|
267
|
+
mode: options.mode,
|
|
268
|
+
objective: `CI run: explore at level ${options.level}${options.focus ? `, focusing on ${options.focus}` : ""}`.slice(0, 300),
|
|
269
|
+
task: "Starting the CI run",
|
|
270
|
+
...(options.storageStatePath ? { storageStatePath: options.storageStatePath } : {}),
|
|
271
|
+
...(options.browser ? { browser: options.browser } : {}),
|
|
272
|
+
}, Math.min(ATTACH_MS, options.caps.wallMs));
|
|
273
|
+
const authFailed = attached.text.split("\n").find((l) => l.startsWith("โ AUTH FAILED"));
|
|
274
|
+
if (attached.isError || /^ERROR:/.test(attached.text) || authFailed) {
|
|
275
|
+
outcome = { ...outcome, stopDetail: (authFailed ?? attached.text).replace(/^ERROR:\s*/, "").slice(0, 400) };
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
log(`Attached to ${options.url} in ${options.mode} mode.`);
|
|
279
|
+
const tools = ciTools(await host.tools());
|
|
280
|
+
const system = ciSystemPrompt(loadPlaybook(packageRoot), options);
|
|
281
|
+
const kickoff = ciKickoff(options);
|
|
282
|
+
outcome = await agentLoop({
|
|
283
|
+
client: deps.makeClient(system, tools, kickoff),
|
|
284
|
+
host,
|
|
285
|
+
tools,
|
|
286
|
+
caps: options.caps,
|
|
287
|
+
log,
|
|
288
|
+
now,
|
|
289
|
+
startedAt,
|
|
290
|
+
projectDir: options.projectDir,
|
|
291
|
+
});
|
|
292
|
+
log(`Run ended: ${describeStop(outcome.stop, options.caps, outcome.stopDetail)}.`);
|
|
293
|
+
finishBy = now() + FINISH_MS;
|
|
294
|
+
// The report is written whatever ended the run. A forced report still prints its gaps.
|
|
295
|
+
let report = await host.call("scout_report", { level: options.level }, finishLeft());
|
|
296
|
+
contractMet = !report.isError && !/NOT GENERATED/.test(report.text);
|
|
297
|
+
if (!contractMet && !report.isError)
|
|
298
|
+
report = await host.call("scout_report", { level: options.level, force: true }, finishLeft());
|
|
299
|
+
reportWritten = !report.isError && !/^ERROR:/.test(report.text) && fs.existsSync(path.join(options.projectDir, MEMORY_DIRNAME, "report.md"));
|
|
300
|
+
if (!reportWritten)
|
|
301
|
+
log(`The report could not be generated: ${report.text.slice(0, 400)}`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
catch (err) {
|
|
305
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
306
|
+
if (outcome.stop === "could-not-start")
|
|
307
|
+
outcome = { ...outcome, stopDetail: message };
|
|
308
|
+
else
|
|
309
|
+
log(`The run could not finish cleanly: ${message}`);
|
|
310
|
+
}
|
|
311
|
+
finally {
|
|
312
|
+
if (host) {
|
|
313
|
+
if (finishBy === 0)
|
|
314
|
+
finishBy = now() + FINISH_MS;
|
|
315
|
+
await host
|
|
316
|
+
.call("scout_close", { all: true }, finishLeft())
|
|
317
|
+
.catch((err) => log(`Closing the browser failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
318
|
+
await host.close().catch(() => { });
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
const endedAt = now();
|
|
322
|
+
const result = {
|
|
323
|
+
url: options.url,
|
|
324
|
+
provider: resolved.provider,
|
|
325
|
+
model: resolved.model,
|
|
326
|
+
effort: resolved.effort,
|
|
327
|
+
mode: options.mode,
|
|
328
|
+
level: options.level,
|
|
329
|
+
caps: options.caps,
|
|
330
|
+
...(options.price ? { price: options.price } : {}),
|
|
331
|
+
stop: outcome.stop,
|
|
332
|
+
...(outcome.stopDetail ? { stopDetail: outcome.stopDetail } : {}),
|
|
333
|
+
contractMet,
|
|
334
|
+
spend: outcome.spend,
|
|
335
|
+
endedAt,
|
|
336
|
+
findings: findingsThisRun(before, readMemoryFindings(options.projectDir)),
|
|
337
|
+
};
|
|
338
|
+
const written = [];
|
|
339
|
+
try {
|
|
340
|
+
if (!options.outDir)
|
|
341
|
+
writeSelfIgnore(path.dirname(outDir));
|
|
342
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
343
|
+
const write = (name, content) => {
|
|
344
|
+
fs.writeFileSync(path.join(outDir, name), redactKeys(content, secrets));
|
|
345
|
+
written.push(name);
|
|
346
|
+
};
|
|
347
|
+
if (reportWritten) {
|
|
348
|
+
write("report.md", fs.readFileSync(path.join(options.projectDir, MEMORY_DIRNAME, "report.md"), "utf8"));
|
|
349
|
+
const html = path.join(options.projectDir, MEMORY_DIRNAME, "report.html");
|
|
350
|
+
if (fs.existsSync(html))
|
|
351
|
+
write("report.html", fs.readFileSync(html, "utf8"));
|
|
352
|
+
}
|
|
353
|
+
const summary = ciSummaryMarkdown(result, secrets);
|
|
354
|
+
write("summary.md", summary);
|
|
355
|
+
write("ci.json", JSON.stringify(ciSummaryJson(result, deps.version, secrets), null, 2) + "\n");
|
|
356
|
+
write("ci.sarif", JSON.stringify(ciSarif(result, deps.version, secrets), null, 2) + "\n");
|
|
357
|
+
if (process.env.GITHUB_STEP_SUMMARY)
|
|
358
|
+
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, redactKeys(summary, secrets));
|
|
359
|
+
}
|
|
360
|
+
catch (err) {
|
|
361
|
+
log(`Could not write the results to ${outDir}: ${err instanceof Error ? err.message : String(err)}`);
|
|
362
|
+
reportWritten = false;
|
|
363
|
+
}
|
|
364
|
+
log(`Usage: ${usageLine(result.spend, result.model, endedAt, result.price)}`);
|
|
365
|
+
return { result, exitCode: ciExitCode(result.stop, reportWritten), written };
|
|
366
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* scenescout install Install the skill, download the browser, register the MCP server
|
|
8
8
|
* scenescout doctor Check every piece of the setup and say how to fix what is missing
|
|
9
9
|
* scenescout check <url> Visit every route, measure it, and pass or fail (no model involved)
|
|
10
|
+
* scenescout ci <url> An exploratory run driven by a model's API, unattended, that reports
|
|
10
11
|
*/
|
|
11
12
|
import { spawnSync } from "node:child_process";
|
|
12
13
|
import fs from "node:fs";
|
|
@@ -18,6 +19,8 @@ import { APPROX_DISK_MB, BROWSER_ENGINES, browserPresence, defaultAttachNote, de
|
|
|
18
19
|
import { CLIENT_LABELS, firstMessageHint, manualFor, parseClients, registerWithClient, vscodeBinary } from "./clients.js";
|
|
19
20
|
import { CLI_NAME, diagnose, ensureCommand, findOnUserPath, installSkill, isEphemeralRoot, launchCommand, manualRegisterCommand, planCommand, registerMcp, resolveClaudeDir, spawnRunner, } from "./installer.js";
|
|
20
21
|
import { defaultCheckDir, readCheckInputs, runCheck } from "./check-run.js";
|
|
22
|
+
import { httpClient, runCi } from "./ci-run.js";
|
|
23
|
+
import { detectProvider, EXIT_CI, KEY_ENV, parseCiArgs, redactKeys, secretValues } from "./engine/ci.js";
|
|
21
24
|
import { EXIT, exitCodeOf, formatCheck, parseCheckArgs, refusedFlowReason, toSarif, toSummaryJson, unmeasuredReason } from "./engine/check.js";
|
|
22
25
|
import { LEGACY_MEMORY_DIRNAME, MEMORY_DIRNAME, writeSelfIgnore } from "./engine/memory.js";
|
|
23
26
|
import { formatStatus, liveEngines, liveTokenFileName, localClock, LIVE_TOKEN_FILE, pidAlive, watchTarget, wholeSessions, } from "./engine/live.js";
|
|
@@ -64,6 +67,25 @@ Usage:
|
|
|
64
67
|
--gate-retests never|high|all: which still-reproducing findings fail the gate
|
|
65
68
|
(default high: those filed high))
|
|
66
69
|
Exit code: 0 passed, 1 failed the gate, 2 could not run.
|
|
70
|
+
scenescout ci <url> An exploratory run with no person present: a model reached through its API
|
|
71
|
+
drives the tools by the SceneScout method and the run ends in the report.
|
|
72
|
+
It reports and never gates. The key is read from ANTHROPIC_API_KEY or
|
|
73
|
+
OPENAI_API_KEY only. Writes report.md, summary.md, ci.json and ci.sarif.
|
|
74
|
+
(--provider anthropic|openai: needed only when both keys are set;
|
|
75
|
+
--model id (default claude-sonnet-5 / gpt-6-luna); --effort none|low|medium|
|
|
76
|
+
high|xhigh|max (default low; none is OpenAI only); --base-url https://โฆ/v1 for
|
|
77
|
+
another endpoint that implements the same API;
|
|
78
|
+
--max-turns N (default 40); --max-tokens N (default 1500000);
|
|
79
|
+
--max-minutes N (default 20): the run stops at the first cap reached and still
|
|
80
|
+
writes the report;
|
|
81
|
+
--price-in, --price-cached-in, --price-out: US dollars per million tokens,
|
|
82
|
+
over the built-in prices, for the cost estimate of any model;
|
|
83
|
+
--mode observe|read-only|safe-write|destructive (default read-only;
|
|
84
|
+
destructive only with --allow-destructive as well); --level minimal|medium|
|
|
85
|
+
extensive (default medium); --focus "an area or flow";
|
|
86
|
+
--storage-state file; --browser chromium|firefox|webkit;
|
|
87
|
+
--project dir (default: here); --out dir (default: .scenescout/ci))
|
|
88
|
+
Exit code: 0 the run ran (findings never change it), 2 could not run.
|
|
67
89
|
scenescout status [projectPath] What is the engine doing right now? (every session + recent actions)
|
|
68
90
|
scenescout watch [projectPath] Open the live view in a browser: what each session is doing, a thumbnail
|
|
69
91
|
of its page, and a live stream you can switch on per session
|
|
@@ -533,6 +555,49 @@ async function check(args) {
|
|
|
533
555
|
console.error(`scenescout check: could not run a saved flow: ${refused}`);
|
|
534
556
|
process.exit(exitCodeOf(result));
|
|
535
557
|
}
|
|
558
|
+
/** `scenescout ci`: exit 0 when the run ran, 2 when it could not. Findings never change the exit code. */
|
|
559
|
+
async function ci(args) {
|
|
560
|
+
if (args.includes("--help") || args.includes("-h"))
|
|
561
|
+
usage(0);
|
|
562
|
+
const secrets = secretValues(process.env);
|
|
563
|
+
const say = (line) => console.log(redactKeys(line, secrets));
|
|
564
|
+
const fail = (message) => {
|
|
565
|
+
console.error(redactKeys(`scenescout ci: ${message}`, secrets));
|
|
566
|
+
process.exit(EXIT_CI.couldNotRun);
|
|
567
|
+
};
|
|
568
|
+
const parsed = parseCiArgs(args, process.cwd());
|
|
569
|
+
if (!parsed.ok)
|
|
570
|
+
return fail(parsed.error);
|
|
571
|
+
const options = parsed.options;
|
|
572
|
+
const provider = detectProvider(process.env, options);
|
|
573
|
+
if (!provider.ok)
|
|
574
|
+
return fail(provider.error);
|
|
575
|
+
const resolved = provider.resolved;
|
|
576
|
+
const key = (process.env[KEY_ENV[resolved.provider]] ?? "").trim();
|
|
577
|
+
say(`Exploring ${options.url} with ${resolved.provider} ${resolved.model} (effort ${resolved.effort}), ${options.mode} mode, level ${options.level} โฆ`);
|
|
578
|
+
let run;
|
|
579
|
+
try {
|
|
580
|
+
run = await runCi(options, resolved, {
|
|
581
|
+
makeClient: (system, tools, kickoff) => httpClient(resolved, key, system, tools, kickoff),
|
|
582
|
+
log: say,
|
|
583
|
+
secrets,
|
|
584
|
+
version: packageVersion(),
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
catch (err) {
|
|
588
|
+
return fail(`could not run: ${err instanceof Error ? err.message : String(err)}`);
|
|
589
|
+
}
|
|
590
|
+
const { result, exitCode, written } = run;
|
|
591
|
+
if (written.length > 0)
|
|
592
|
+
say(`Wrote ${written.join(", ")} to ${options.outDir ?? path.join(options.projectDir, MEMORY_DIRNAME, "ci")}`);
|
|
593
|
+
if (exitCode !== EXIT_CI.completed) {
|
|
594
|
+
const why = result.stop === "could-not-start" || result.stop === "provider-error"
|
|
595
|
+
? `${result.stop === "provider-error" ? "the model's API failed" : "could not start"}${result.stopDetail ? `: ${result.stopDetail}` : ""}`
|
|
596
|
+
: "the report could not be written";
|
|
597
|
+
fail(`could not run: ${why}`);
|
|
598
|
+
}
|
|
599
|
+
process.exit(exitCode);
|
|
600
|
+
}
|
|
536
601
|
const [, , command, ...args] = process.argv;
|
|
537
602
|
// A CLI's failure mode should be a sentence, not a stack trace. `scan` on a
|
|
538
603
|
// path that does not exist and `status` on a half-written status.json both
|
|
@@ -575,6 +640,10 @@ try {
|
|
|
575
640
|
await check(args);
|
|
576
641
|
break;
|
|
577
642
|
}
|
|
643
|
+
case "ci": {
|
|
644
|
+
await ci(args);
|
|
645
|
+
break;
|
|
646
|
+
}
|
|
578
647
|
case "status": {
|
|
579
648
|
status(path.resolve(args[0] ?? process.cwd()));
|
|
580
649
|
break;
|