@m8i-51/shoal 0.1.21 → 0.1.22

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/.env.example CHANGED
@@ -19,6 +19,14 @@
19
19
  # LLM_PROVIDER=codex
20
20
  # LLM_MODEL=gpt-5.1-codex-mini # 省略可
21
21
 
22
+ # --- Amazon Bedrock ---
23
+ # AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_REGION は AWS SDK が自動で読み取ります
24
+ # LLM_PROVIDER=bedrock
25
+ # AWS_ACCESS_KEY_ID=
26
+ # AWS_SECRET_ACCESS_KEY=
27
+ # AWS_REGION=us-east-1
28
+ # LLM_MODEL=anthropic.claude-3-5-haiku-20241022-v1:0 # 省略可 / クロスリージョン推論プロファイルも対応
29
+
22
30
  # --- Groq (無料枠あり・高速) ---
23
31
  # https://console.groq.com でキー取得
24
32
  # LLM_PROVIDER=groq
@@ -100,6 +108,9 @@ GITHUB_REPO=owner/repo
100
108
  MAX_EXPLORERS=4
101
109
  MAX_BROWSERS=2
102
110
 
111
+ # 1 に設定するとキャッシュ済みの product spec を無視し product discovery を再実行します
112
+ # REFRESH_SPEC=1
113
+
103
114
  # Persona templates (optional)
104
115
  # Path to a local YAML/JSON file, or an npm package name.
105
116
  # If omitted, shoal auto-discovers personas.yaml / personas.yml / personas.json in the project root.
package/README.md CHANGED
@@ -119,6 +119,7 @@ Opens at `http://localhost:4000`. From there you can:
119
119
  - **Generate an Agent Diary** — after a run completes, one LLM call turns the raw log into a story-style narrative of the exploration, readable by anyone on the team
120
120
  - **Hall of Issues** — browse all findings across every run with full-text search and category filter. Export as JSON to share, or paste a community findings URL to import findings from other projects.
121
121
  - **Edit app goals** — guide the goal-gap detector by defining what the app should achieve
122
+ - **Schedule a weekly run** — pick a day and time directly in the dashboard for automatic recurring runs (the `shoal serve` process must stay running; for a serverless alternative see [Scheduled runs](#scheduled-runs) below)
122
123
 
123
124
  ---
124
125
 
@@ -246,6 +247,8 @@ shoal defaults to Anthropic Claude. To use a different provider, set these varia
246
247
  | Amazon Bedrock | `LLM_PROVIDER=bedrock`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION` |
247
248
  | OpenAI | `LLM_PROVIDER=openai`, `LLM_API_KEY`, `LLM_MODEL` |
248
249
  | OpenRouter | `LLM_PROVIDER=openrouter`, `LLM_API_KEY`, `LLM_MODEL` |
250
+ | Groq | `LLM_PROVIDER=groq`, `LLM_API_KEY`, `LLM_MODEL` |
251
+ | Gemini | `LLM_PROVIDER=gemini`, `LLM_API_KEY`, `LLM_MODEL` |
249
252
  | Codex (ChatGPT subscription) | run `npm run auth:codex` once, then `LLM_PROVIDER=codex` |
250
253
  | Ollama | `LLM_BASE_URL=http://localhost:11434/v1`, `LLM_MODEL` |
251
254
  | LM Studio | `LLM_BASE_URL=http://localhost:1234/v1`, `LLM_MODEL` |
@@ -1,6 +1,6 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
- import type { Finding } from "./types";
3
+ import { isFinding, type Finding } from "./types";
4
4
  import type { Scenario } from "./scenario-designer";
5
5
 
6
6
  export interface RunCoverage {
@@ -233,8 +233,8 @@ export function getFindingHotspots(topN = 12): FindingHotspot[] {
233
233
  for (const file of fs.readdirSync(dir)) {
234
234
  if (!file.endsWith(".json") || file === "triage_result.json") continue;
235
235
  try {
236
- const f: Finding = JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8"));
237
- if (typeof f.category !== "string") continue;
236
+ const f: unknown = JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8"));
237
+ if (!isFinding(f)) continue;
238
238
  const p = extractPath(f);
239
239
  const entry = counts.get(p) ?? { total: 0, categories: {} };
240
240
  entry.total++;
@@ -1,7 +1,7 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import { createLLMClient } from "./llm-client.js";
4
- import type { Finding } from "./types.js";
4
+ import { isFinding, type Finding } from "./types.js";
5
5
 
6
6
  function loadFindings(runId: string): Finding[] {
7
7
  const dir = path.join(process.cwd(), "findings", runId);
@@ -10,8 +10,8 @@ function loadFindings(runId: string): Finding[] {
10
10
  for (const file of fs.readdirSync(dir)) {
11
11
  if (!file.endsWith(".json") || file === "triage_result.json") continue;
12
12
  try {
13
- const f: Finding = JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8"));
14
- if (typeof f.timestamp !== "string") continue;
13
+ const f: unknown = JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8"));
14
+ if (!isFinding(f)) continue;
15
15
  out.push(f);
16
16
  } catch { /* skip */ }
17
17
  }
@@ -200,7 +200,10 @@ class OpenAICompatClient {
200
200
  private defaultModel: string;
201
201
 
202
202
  constructor(apiKey: string, baseURL?: string, defaultModel?: string) {
203
- this.client = new OpenAI({ apiKey, baseURL });
203
+ // ローカルプロバイダ(Ollama/LM Studio)は空のキーで使われることがある。
204
+ // openai SDK は空文字列のキーをコンストラクタで拒否するバージョンがあるため、
205
+ // ダミー値で代替する(API互換エンドポイント側はキーを検証しない)。
206
+ this.client = new OpenAI({ apiKey: apiKey || "not-needed", baseURL });
204
207
  this.defaultModel = defaultModel ?? "gpt-4o-mini";
205
208
  }
206
209
 
@@ -25,6 +25,17 @@ export interface Finding {
25
25
  screenshotPath?: string;
26
26
  }
27
27
 
28
+ /**
29
+ * findings/run_<id>/ ディレクトリには各 finding の JSON と並んで
30
+ * triage_result.json(集計ファイル、Finding ではない)が置かれる。
31
+ * 読み込んだ JSON が実際に Finding かどうかをここで一括判定する。
32
+ */
33
+ export function isFinding(v: unknown): v is Finding {
34
+ if (typeof v !== "object" || v === null) return false;
35
+ const o = v as Record<string, unknown>;
36
+ return typeof o.id === "string" && typeof o.category === "string" && typeof o.timestamp === "string";
37
+ }
38
+
28
39
  export interface RegressionCheck {
29
40
  issueNumber: number;
30
41
  issueTitle: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m8i-51/shoal",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
4
4
  "type": "module",
5
5
  "description": "Multi-agent web exploration framework — finds bugs, UX issues, and missing features by running AI agents against your app",
6
6
  "repository": {
@@ -18,7 +18,8 @@
18
18
  "web/dist/",
19
19
  "run.ts",
20
20
  "triage-only.ts",
21
- ".env.example"
21
+ ".env.example",
22
+ "!**/__tests__/**"
22
23
  ],
23
24
  "scripts": {
24
25
  "prepublishOnly": "npm run build:web",
@@ -31,7 +32,8 @@
31
32
  "build:web": "vite build web",
32
33
  "auth:codex": "tsx scripts/auth-codex.ts",
33
34
  "test": "vitest run",
34
- "test:watch": "vitest"
35
+ "test:watch": "vitest",
36
+ "test:coverage": "vitest run --coverage"
35
37
  },
36
38
  "dependencies": {
37
39
  "@anthropic-ai/bedrock-sdk": "^0.29.1",
package/server/index.ts CHANGED
@@ -9,7 +9,7 @@ import { activeSessions, spawnRun, cancelSession } from "./runner.js";
9
9
  import { loadSchedule, saveSchedule, startScheduler, type ScheduleConfig } from "./scheduler.js";
10
10
  import { generateDiary, getDiaryPath } from "../framework/diary.js";
11
11
  import { findCrossRunDuplicates } from "../framework/cross-run-dedup.js";
12
- import type { Finding } from "../framework/types.js";
12
+ import { isFinding, type Finding } from "../framework/types.js";
13
13
 
14
14
  function specFilePath(baseUrl: string): string {
15
15
  try {
@@ -144,8 +144,8 @@ function loadAllFindings(): (Finding & { runId: string })[] {
144
144
  for (const file of readdirSync(dir)) {
145
145
  if (!file.endsWith(".json") || file === "triage_result.json") continue;
146
146
  try {
147
- const f: Finding = JSON.parse(readFileSync(join(dir, file), "utf-8"));
148
- if (typeof f.timestamp !== "string") continue;
147
+ const f: unknown = JSON.parse(readFileSync(join(dir, file), "utf-8"));
148
+ if (!isFinding(f)) continue;
149
149
  all.push({ ...f, runId: runDir });
150
150
  } catch { /* skip */ }
151
151
  }
package/server/runs.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
- import type { RunLog, Finding } from "../framework/types";
3
+ import { isFinding, type RunLog } from "../framework/types";
4
4
 
5
5
  export interface RunSummary {
6
6
  runId: string;
@@ -27,8 +27,8 @@ function countFindings(runId: string): { total: number; byCategory: Record<strin
27
27
  for (const file of fs.readdirSync(dir)) {
28
28
  if (!file.endsWith(".json") || file === "triage_result.json") continue;
29
29
  try {
30
- const f: Finding = JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8"));
31
- if (typeof f.category !== "string") continue;
30
+ const f: unknown = JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8"));
31
+ if (!isFinding(f)) continue;
32
32
  byCategory[f.category] = (byCategory[f.category] ?? 0) + 1;
33
33
  total++;
34
34
  } catch {