@payconductor/houndsman 0.6.0 → 0.8.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.
Files changed (71) hide show
  1. package/README.md +20 -0
  2. package/package.json +1 -1
  3. package/src/cli/commands/command.ts +10 -1
  4. package/src/cli/commands/context-command.ts +40 -2
  5. package/src/cli/commands/hunt-command.ts +28 -2
  6. package/src/cli/commands/review-command.ts +30 -2
  7. package/src/cli/commands/server-command.ts +7 -1
  8. package/src/cli/commands/worktree-command.ts +118 -0
  9. package/src/cli/help.ts +13 -3
  10. package/src/cli/index.ts +14 -4
  11. package/src/core/ast/service.ts +7 -2
  12. package/src/core/brief.ts +1 -0
  13. package/src/core/commands/context.ts +9 -1
  14. package/src/core/commands/findings.ts +17 -6
  15. package/src/core/commands/review.ts +13 -0
  16. package/src/core/commands/scaffold.ts +4 -2
  17. package/src/core/config.ts +13 -0
  18. package/src/core/defaults.ts +8 -1
  19. package/src/core/engine/houndsman-engine.ts +26 -3
  20. package/src/core/engine/review-scope.ts +17 -22
  21. package/src/core/engine/review-session.ts +25 -2
  22. package/src/core/engine/review-workspace.ts +204 -0
  23. package/src/core/git-runner.ts +25 -13
  24. package/src/core/github-review.ts +10 -7
  25. package/src/core/jev/jev-detector.ts +10 -2
  26. package/src/core/orchestrator.ts +62 -13
  27. package/src/core/project-config.ts +12 -0
  28. package/src/core/services/gh-client.ts +38 -22
  29. package/src/core/services/git-repository.ts +46 -0
  30. package/src/core/services/pr-resolver.ts +147 -0
  31. package/src/core/services/worktree-manager.ts +269 -0
  32. package/src/core/storage/file/file-storage.ts +14 -1
  33. package/src/core/storage/paths.ts +30 -1
  34. package/src/core/storage/sqlite/mappers.ts +4 -0
  35. package/src/core/storage/sqlite/schema.ts +13 -3
  36. package/src/core/storage/sqlite/sqlite-storage.ts +28 -8
  37. package/src/core/storage/types.ts +12 -0
  38. package/src/core/storage/work-area.ts +20 -1
  39. package/src/core/types.ts +2 -0
  40. package/src/harness/hunt/hunt-options.ts +17 -0
  41. package/src/harness/hunt/hunt-session.ts +7 -20
  42. package/src/index.ts +31 -0
  43. package/src/server/client/App.tsx +86 -22
  44. package/src/server/client/CommentsPanel.tsx +255 -0
  45. package/src/server/client/DeleteRunModal.tsx +163 -0
  46. package/src/server/client/FindingCard.tsx +12 -4
  47. package/src/server/client/FindingsList.tsx +4 -1
  48. package/src/server/client/ResumePanel.tsx +8 -8
  49. package/src/server/client/Sidebar.tsx +452 -0
  50. package/src/server/client/SubmitModal.tsx +61 -5
  51. package/src/server/client/icons.tsx +29 -0
  52. package/src/server/client/index.tsx +1 -1
  53. package/src/server/client/utils/constants.ts +2 -0
  54. package/src/server/client/utils/format.ts +47 -2
  55. package/src/server/client/utils/types.ts +108 -0
  56. package/src/server/editor.ts +129 -0
  57. package/src/server/index.ts +205 -3
  58. package/src/server/pr-comments.ts +256 -0
  59. package/src/server/pr-inbox.ts +213 -0
  60. package/src/server/run-repository.ts +136 -3
  61. package/src/skill/README.md +11 -3
  62. package/src/skill/SKILL.md +44 -10
  63. package/src/skill/hunt.md +33 -7
  64. package/src/tests/__snapshots__/baseline.test.ts.snap +94 -24
  65. package/src/tests/ai-core.test.ts +2 -2
  66. package/src/tests/cli.test.ts +35 -1
  67. package/src/tests/github-review.test.ts +9 -0
  68. package/src/tests/server-editor.test.ts +100 -0
  69. package/src/tests/server.test.ts +333 -2
  70. package/src/tests/worktree.test.ts +369 -0
  71. package/src/server/client/RunList.tsx +0 -75
@@ -83,6 +83,9 @@ export type ArtifactMeta = {
83
83
  export type RunSummary = {
84
84
  slug: string;
85
85
  branch: string;
86
+ /** Worktree onde a run foi executada (ausente = clone principal). */
87
+ worktreePath?: string;
88
+ prNumber?: number;
86
89
  updatedAt?: string;
87
90
  startedAt?: string;
88
91
  /** Último sinal de vida de uma execução ativa (revisão/correção). */
@@ -97,6 +100,109 @@ export type FindingsResponse = {
97
100
  summary: Summary;
98
101
  };
99
102
 
103
+ /** Editor detectado pelo servidor (base dos links de código). */
104
+ export type EditorKind = "vscode" | "jetbrains" | "none";
105
+
106
+ export type EditorContext = {
107
+ kind: EditorKind;
108
+ project?: string;
109
+ product?: string;
110
+ };
111
+
112
+ /** Estado da árvore de trabalho (bloqueia o checkout quando suja). */
113
+ export type WorktreeState = {
114
+ available: boolean;
115
+ clean: boolean;
116
+ changes: number;
117
+ sample: string[];
118
+ };
119
+
120
+ /** PR aberta exibida na sidebar. */
121
+ export type PrListItem = {
122
+ number: number;
123
+ title: string;
124
+ headRefName: string;
125
+ baseRefName: string;
126
+ author: string;
127
+ updatedAt?: string;
128
+ isDraft: boolean;
129
+ reviewDecision?: string;
130
+ url?: string;
131
+ runSlug?: string;
132
+ /** Worktree ativo da PR (revisão paralela). */
133
+ worktreePath?: string;
134
+ };
135
+
136
+ export type PrListResponse = {
137
+ available: boolean;
138
+ reason?: string;
139
+ currentBranch: string | null;
140
+ worktree: WorktreeState;
141
+ prs: PrListItem[];
142
+ };
143
+
144
+ export type CheckoutResponse =
145
+ | { ok: true; number: number; branch: string | null }
146
+ | {
147
+ ok: false;
148
+ reason: "dirty" | "git" | "gh";
149
+ detail: string;
150
+ changes?: string[];
151
+ };
152
+
153
+ /** Resultado de `DELETE /api/runs/:slug`. */
154
+ export type DeleteRunResponse = {
155
+ deleted: boolean;
156
+ worktreeRequested: boolean;
157
+ worktreeRemoved: boolean;
158
+ worktreePath?: string;
159
+ warning?: string;
160
+ };
161
+
162
+ export type PrWorktreeResponse =
163
+ | {
164
+ ok: true;
165
+ number: number;
166
+ branch: string;
167
+ path: string;
168
+ head: string;
169
+ created: boolean;
170
+ warning?: string;
171
+ }
172
+ | { ok: false; reason: string; detail: string };
173
+
174
+ /** Comentário normalizado da PR (thread ou geral). */
175
+ export type PrCommentView = {
176
+ id: number;
177
+ author: string;
178
+ authorType: string;
179
+ body: string;
180
+ createdAt?: string;
181
+ url?: string;
182
+ path?: string;
183
+ line: number | null;
184
+ absFile?: string;
185
+ replyTo?: number | null;
186
+ };
187
+
188
+ export type PrCommentThread = {
189
+ id: number;
190
+ path: string;
191
+ line: number | null;
192
+ absFile: string;
193
+ comments: PrCommentView[];
194
+ };
195
+
196
+ export type PrCommentsResponse = {
197
+ available: boolean;
198
+ reason?: string;
199
+ target?: { number: number; repo: string; url?: string };
200
+ fetchedAt: string;
201
+ threads: PrCommentThread[];
202
+ issueComments: PrCommentView[];
203
+ counts: { threads: number; inline: number; issue: number };
204
+ };
205
+
100
206
  /** Manifesto da run (subset consumido pela aba de resumo). */
101
207
  export type RunManifest = {
102
208
  branch?: string;
@@ -149,6 +255,8 @@ export type SubmitCandidate = {
149
255
  file: string;
150
256
  line: number | null;
151
257
  severity: Severity;
258
+ /** Decisão da triagem (`execute` = "a executar"). */
259
+ decision: Decision;
152
260
  execState: ExecState;
153
261
  rule: string;
154
262
  message: string;
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Detecção do editor que hospeda o terminal do servidor (biblioteca do servidor).
3
+ *
4
+ * VS Code e JetBrains expõem variáveis próprias no terminal integrado. O
5
+ * contexto alimenta os links de código da UI (`vscode://` / `jetbrains://`).
6
+ * A detecção é pura e fail-open: sem pistas, o editor fica `none`.
7
+ */
8
+
9
+ import { basename, resolve } from "node:path";
10
+
11
+ /** Editores com esquema de URL próprio. */
12
+ export type EditorKind = "vscode" | "jetbrains" | "none";
13
+
14
+ /** Contexto usado pelo cliente para montar os links de código. */
15
+ export type EditorContext = {
16
+ kind: EditorKind;
17
+ /** Nome do projeto aberto no IDE (basename da raiz do repositório). */
18
+ project?: string;
19
+ /** Código do produto JetBrains (`idea`, `webstorm`, `pycharm`…). */
20
+ product?: string;
21
+ };
22
+
23
+ type Env = Record<string, string | undefined>;
24
+
25
+ /** Bundle macOS → código do produto usado no esquema `jetbrains://`. */
26
+ const BUNDLE_PRODUCTS: Record<string, string> = {
27
+ "com.jetbrains.intellij": "idea",
28
+ "com.jetbrains.intellij.ce": "idea",
29
+ "com.jetbrains.pycharm": "pycharm",
30
+ "com.jetbrains.pycharm.ce": "pycharm",
31
+ "com.jetbrains.webstorm": "webstorm",
32
+ "com.jetbrains.goland": "goland",
33
+ "com.jetbrains.clion": "clion",
34
+ "com.jetbrains.rider": "rider",
35
+ "com.jetbrains.phpstorm": "phpstorm",
36
+ "com.jetbrains.rubymine": "rubymine",
37
+ "com.jetbrains.datagrip": "datagrip",
38
+ "com.jetbrains.appcode": "appcode",
39
+ "com.jetbrains.rustrover": "rustrover",
40
+ };
41
+
42
+ /** Variáveis `*_INITIAL_DIRECTORY` → produto JetBrains. */
43
+ const DIR_PRODUCTS: [string, string][] = [
44
+ ["IDEA_INITIAL_DIRECTORY", "idea"],
45
+ ["PYCHARM_INITIAL_DIRECTORY", "pycharm"],
46
+ ["WEBSTORM_INITIAL_DIRECTORY", "webstorm"],
47
+ ["GOLAND_INITIAL_DIRECTORY", "goland"],
48
+ ["CLION_INITIAL_DIRECTORY", "clion"],
49
+ ["RIDER_INITIAL_DIRECTORY", "rider"],
50
+ ["PHPSTORM_INITIAL_DIRECTORY", "phpstorm"],
51
+ ["RUBYMINE_INITIAL_DIRECTORY", "rubymine"],
52
+ ["DATAGRIP_INITIAL_DIRECTORY", "datagrip"],
53
+ ["RUSTROVER_INITIAL_DIRECTORY", "rustrover"],
54
+ ];
55
+
56
+ function projectName(repoRoot: string): string {
57
+ return basename(resolve(repoRoot));
58
+ }
59
+
60
+ /** Produto JetBrains detectado no ambiente (undefined quando ambíguo). */
61
+ export function detectJetBrainsProduct(env: Env): string | undefined {
62
+ const bundle = env.__CFBundleIdentifier?.toLowerCase();
63
+ if (bundle) {
64
+ const exact = BUNDLE_PRODUCTS[bundle];
65
+ if (exact) return exact;
66
+ const prefix = Object.keys(BUNDLE_PRODUCTS).find((key) => bundle.startsWith(key));
67
+ if (prefix) return BUNDLE_PRODUCTS[prefix];
68
+ }
69
+ for (const [name, product] of DIR_PRODUCTS) {
70
+ if (env[name]) return product;
71
+ }
72
+ return undefined;
73
+ }
74
+
75
+ function isJetBrains(env: Env): boolean {
76
+ const bundle = env.__CFBundleIdentifier?.toLowerCase() ?? "";
77
+ return (
78
+ /jetbrains/i.test(env.TERMINAL_EMULATOR ?? "") ||
79
+ bundle.startsWith("com.jetbrains.") ||
80
+ DIR_PRODUCTS.some(([name]) => Boolean(env[name]))
81
+ );
82
+ }
83
+
84
+ function isVsCode(env: Env): boolean {
85
+ return (
86
+ env.TERM_PROGRAM === "vscode" ||
87
+ env.TERM_PROGRAM === "vscode-insiders" ||
88
+ Boolean(env.VSCODE_PID || env.VSCODE_IPC_HOOK_CLI || env.VSCODE_GIT_IPC_HANDLE)
89
+ );
90
+ }
91
+
92
+ /** Detecta o editor a partir do ambiente do terminal integrado. */
93
+ export function detectEditor(env: Env, repoRoot: string): EditorContext {
94
+ const project = projectName(repoRoot);
95
+ if (isJetBrains(env)) {
96
+ return {
97
+ kind: "jetbrains",
98
+ project,
99
+ product: detectJetBrainsProduct(env) ?? "idea",
100
+ };
101
+ }
102
+ if (isVsCode(env)) return { kind: "vscode", project };
103
+ return { kind: "none", project };
104
+ }
105
+
106
+ /**
107
+ * Resolve a preferência do `--editor`.
108
+ *
109
+ * `auto` (ou ausente) detecta do ambiente; `vscode`/`jetbrains`/`none` forçam
110
+ * o comportamento. Valores desconhecidos caem na detecção automática.
111
+ */
112
+ export function resolveEditor(
113
+ preference: string | undefined,
114
+ env: Env,
115
+ repoRoot: string,
116
+ ): EditorContext {
117
+ const value = preference?.trim().toLowerCase();
118
+ const project = projectName(repoRoot);
119
+ if (value === "vscode") return { kind: "vscode", project };
120
+ if (value === "jetbrains") {
121
+ return {
122
+ kind: "jetbrains",
123
+ project,
124
+ product: detectJetBrainsProduct(env) ?? "idea",
125
+ };
126
+ }
127
+ if (value === "none") return { kind: "none", project };
128
+ return detectEditor(env, repoRoot);
129
+ }
@@ -9,16 +9,23 @@
9
9
  import { mediaPath } from "../core/assets";
10
10
  import type { HoundsmanConfig } from "../core/config";
11
11
  import type { FindingUpdate } from "../core/findings-state";
12
+ import { createGitRunner, type GitRunner } from "../core/git-runner";
12
13
  import {
13
14
  defaultGhRunner,
14
15
  type GhRunner,
15
16
  type SubmitSelection,
16
17
  } from "../core/github-review";
17
18
  import { logger } from "../core/logging";
19
+ import { GitRepository } from "../core/services/git-repository";
20
+ import { PrResolver } from "../core/services/pr-resolver";
21
+ import { WorktreeManager } from "../core/services/worktree-manager";
18
22
  import { createStorage } from "../core/storage/factory";
23
+ import { resolveWorktreesRoot } from "../core/storage/paths";
19
24
  import { resolveRepoIdentity } from "../core/storage/repo-identity";
20
25
  import type { RepoIdentity, StorageProvider } from "../core/storage/types";
21
26
  import { buildClientBundle } from "./client-build";
27
+ import { type EditorContext, resolveEditor } from "./editor";
28
+ import { PrInbox } from "./pr-inbox";
22
29
  import { RunRepository } from "./run-repository";
23
30
 
24
31
  type BunServer = ReturnType<typeof Bun.serve>;
@@ -59,8 +66,13 @@ function notFound(detail = "não encontrado"): Response {
59
66
  /** Repositório das runs (dados) — ver `run-repository.ts`. */
60
67
  type ServerDeps = {
61
68
  ghRunner?: GhRunner;
69
+ gitRunner?: GitRunner;
62
70
  storage?: StorageProvider;
63
71
  repo?: RepoIdentity;
72
+ /** Contexto do editor injetado (testes). */
73
+ editor?: EditorContext;
74
+ /** Preferência do `--editor` (CLI). */
75
+ editorPreference?: string;
64
76
  };
65
77
 
66
78
  // ---------------------------------------------------------------------------
@@ -83,6 +95,10 @@ async function route(
83
95
  request: Request,
84
96
  runs: RunRepository,
85
97
  ghRunner: GhRunner,
98
+ prs: PrInbox,
99
+ editor: EditorContext,
100
+ worktrees: WorktreeManager,
101
+ git: GitRepository,
86
102
  ): Promise<Response> {
87
103
  const url = new URL(request.url);
88
104
  const path = url.pathname;
@@ -95,6 +111,75 @@ async function route(
95
111
  }
96
112
  if (path === `/assets/${LOGO_FILE}`) return serveLogo();
97
113
  if (path === "/api/health") return json({ ok: true });
114
+ if (path === "/api/editor" && request.method === "GET") return json(editor);
115
+ if (path === "/api/prs" && request.method === "GET") {
116
+ return json(prs.list(runs.workspaceIndex()));
117
+ }
118
+ if (path === "/api/worktrees" && request.method === "GET") {
119
+ return json({
120
+ worktrees: worktrees.managed().map((entry) => ({
121
+ path: entry.path,
122
+ branch: entry.branch?.replace(/^refs\/heads\//, "") ?? null,
123
+ head: entry.head ?? null,
124
+ prunable: entry.prunable,
125
+ })),
126
+ });
127
+ }
128
+ if (path === "/api/prs/worktree" && request.method === "POST") {
129
+ const body = (await request.json().catch(() => ({}))) as { number?: unknown };
130
+ const number = Number(body.number);
131
+ if (!Number.isInteger(number) || number <= 0) {
132
+ return json(
133
+ { ok: false, reason: "invalid", detail: "Número de PR inválido." },
134
+ 400,
135
+ );
136
+ }
137
+ const resolver = new PrResolver(ghRunner, git);
138
+ const resolved = resolver.resolve(number);
139
+ if (!resolved.ok) {
140
+ return json({ ok: false, reason: resolved.reason, detail: resolved.detail });
141
+ }
142
+ const refs = resolver.prepareRefs(resolved.pr);
143
+ try {
144
+ const worktree = worktrees.ensure({
145
+ branch: resolved.pr.headRefName,
146
+ ref: refs.head,
147
+ });
148
+ return json({
149
+ ok: true,
150
+ number,
151
+ branch: resolved.pr.headRefName,
152
+ path: worktree.path,
153
+ head: worktree.head,
154
+ created: worktree.created,
155
+ warning: refs.warning,
156
+ });
157
+ } catch (error) {
158
+ return json({
159
+ ok: false,
160
+ reason: "git",
161
+ detail: error instanceof Error ? error.message : String(error),
162
+ });
163
+ }
164
+ }
165
+ const worktreeMatch = path.match(/^\/api\/worktrees\/(.+)$/);
166
+ if (worktreeMatch && request.method === "DELETE") {
167
+ const branch = decodeURIComponent(worktreeMatch[1]);
168
+ const force = url.searchParams.get("force") === "1";
169
+ const result = worktrees.remove(branch, { force });
170
+ return json(result, result.removed ? 200 : 409);
171
+ }
172
+ if (path === "/api/prs/checkout" && request.method === "POST") {
173
+ const body = (await request.json().catch(() => ({}))) as { number?: unknown };
174
+ const number = Number(body.number);
175
+ if (!Number.isInteger(number) || number <= 0) {
176
+ return json(
177
+ { ok: false, reason: "invalid", detail: "Número de PR inválido." },
178
+ 400,
179
+ );
180
+ }
181
+ return json(prs.checkout(number));
182
+ }
98
183
 
99
184
  if (path === "/api/runs" && request.method === "GET") {
100
185
  return json({ runs: runs.list() });
@@ -106,8 +191,21 @@ async function route(
106
191
  const rest = match[2] ?? "";
107
192
  if (!runs.exists(slug)) return notFound(`run '${slug}' não encontrada`);
108
193
 
194
+ if (rest === "" && request.method === "DELETE") {
195
+ const removeWorktree = ["1", "true"].includes(
196
+ url.searchParams.get("worktree") ?? "",
197
+ );
198
+ const result = runs.delete(slug, { removeWorktree });
199
+ return result ? json(result) : notFound(`run '${slug}' não encontrada`);
200
+ }
201
+
109
202
  if (rest === "" || rest === "/") return json(runs.detail(slug));
110
203
 
204
+ if (rest === "/comments" && request.method === "GET") {
205
+ return json(
206
+ runs.comments(slug, { refresh: url.searchParams.get("refresh") === "1" }),
207
+ );
208
+ }
111
209
  if (rest === "/findings" && request.method === "GET") {
112
210
  return json(runs.findings(slug, url.searchParams));
113
211
  }
@@ -119,6 +217,7 @@ async function route(
119
217
  const body = (await request.json().catch(() => ({}))) as SubmitSelection;
120
218
  const selection: SubmitSelection = {
121
219
  severities: body.severities,
220
+ decisions: body.decisions,
122
221
  execStates: body.execStates,
123
222
  includeSent: body.includeSent,
124
223
  };
@@ -161,19 +260,46 @@ async function route(
161
260
  export class HoundsmanServer {
162
261
  private readonly runs: RunRepository;
163
262
  private readonly ghRunner: GhRunner;
263
+ private readonly prs: PrInbox;
264
+ private readonly editor: EditorContext;
265
+ private readonly worktrees: WorktreeManager;
266
+ private readonly git: GitRepository;
164
267
 
165
268
  constructor(config: HoundsmanConfig, deps: ServerDeps = {}) {
269
+ const repo = deps.repo ?? resolveRepoIdentity();
270
+ this.ghRunner = deps.ghRunner ?? defaultGhRunner;
271
+ this.git = new GitRepository(deps.gitRunner ?? createGitRunner(repo.root));
272
+ this.prs = new PrInbox(this.ghRunner, this.git);
273
+ this.worktrees = new WorktreeManager(
274
+ new GitRepository(createGitRunner(repo.root)),
275
+ {
276
+ repoKey: repo.key,
277
+ repoRoot: repo.root,
278
+ baseDir: resolveWorktreesRoot(config.worktrees?.location, repo.root),
279
+ },
280
+ );
166
281
  this.runs = new RunRepository(
167
282
  deps.storage ?? createStorage(config),
168
- deps.repo ?? resolveRepoIdentity(),
283
+ repo,
284
+ this.ghRunner,
285
+ this.worktrees,
169
286
  );
170
- this.ghRunner = deps.ghRunner ?? defaultGhRunner;
287
+ this.editor =
288
+ deps.editor ?? resolveEditor(deps.editorPreference, process.env, repo.root);
171
289
  }
172
290
 
173
291
  /** Handler de requisições (testável sem abrir porta). */
174
292
  readonly handler = async (request: Request): Promise<Response> => {
175
293
  try {
176
- return await route(request, this.runs, this.ghRunner);
294
+ return await route(
295
+ request,
296
+ this.runs,
297
+ this.ghRunner,
298
+ this.prs,
299
+ this.editor,
300
+ this.worktrees,
301
+ this.git,
302
+ );
177
303
  } catch (error) {
178
304
  return json(
179
305
  { error: error instanceof Error ? error.message : String(error) },
@@ -223,17 +349,26 @@ export type ServerOptions = {
223
349
  host?: string;
224
350
  /** Runner do `gh` (injetável nos testes). */
225
351
  ghRunner?: GhRunner;
352
+ /** Runner do git (injetável nos testes). */
353
+ gitRunner?: GitRunner;
226
354
  /** Provider/storage injetável (testes). */
227
355
  storage?: StorageProvider;
228
356
  repo?: RepoIdentity;
357
+ /** Contexto de editor injetado (testes). */
358
+ editor?: EditorContext;
359
+ /** Preferência do `--editor` (auto|vscode|jetbrains|none). */
360
+ editorPreference?: string;
229
361
  };
230
362
 
231
363
  /** Sobe o servidor e devolve a instância + URL. */
232
364
  export function startServer(options: ServerOptions): { server: BunServer; url: string } {
233
365
  return new HoundsmanServer(options.config, {
234
366
  ghRunner: options.ghRunner,
367
+ gitRunner: options.gitRunner,
235
368
  storage: options.storage,
236
369
  repo: options.repo,
370
+ editor: options.editor,
371
+ editorPreference: options.editorPreference,
237
372
  }).start({
238
373
  port: options.port,
239
374
  host: options.host,
@@ -295,6 +430,66 @@ code, pre, .mono { font-family:var(--font-mono); tab-size:4; }
295
430
  .status-review_done { color:var(--suggestion); }
296
431
  .status-fix_done { color:var(--success); }
297
432
  .status-timeout { color:var(--blocking); }
433
+ .side-tabs { display:flex; gap:4px; padding:4px; background:var(--surface-2); border:1px solid var(--border); border-radius:999px; }
434
+ .side-tab { flex:1; display:inline-flex; align-items:center; justify-content:center; gap:7px; font:inherit; font-size:12px; font-weight:600; cursor:pointer; color:var(--muted);
435
+ padding:7px 10px; background:transparent; border:1px solid transparent; border-radius:999px; transition:color .15s ease, background .15s ease; }
436
+ .side-tab:hover { color:var(--fg); }
437
+ .side-tab.active { color:#1b1305; background:linear-gradient(180deg, var(--accent-strong), var(--accent)); }
438
+ .side-count { padding:1px 6px; font-family:var(--font-mono); font-size:10px; color:var(--muted); background:rgba(255,255,255,.08); border-radius:999px; }
439
+ .side-tab.active .side-count { color:#1b1305; background:rgba(0,0,0,.14); }
440
+ .icon-btn { display:inline-grid; place-items:center; width:26px; height:26px; color:var(--muted); background:transparent; border:1px solid var(--border); border-radius:8px; cursor:pointer; text-decoration:none; }
441
+ .icon-btn:hover { color:var(--fg); border-color:var(--border-strong); }
442
+ .icon-btn:disabled { opacity:.5; cursor:not-allowed; }
443
+ .pr-toolbar { display:flex; align-items:center; justify-content:space-between; gap:8px; }
444
+ .pr-toolbar .section-label { margin:0; }
445
+ .prs { display:flex; flex-direction:column; gap:8px; }
446
+ .pr { display:flex; flex-direction:column; gap:7px; padding:11px 12px; background:var(--card); border:1px solid var(--border); border-radius:var(--radius); }
447
+ .pr.current { background:var(--accent-soft); border-color:var(--accent-line); }
448
+ .pr-head { display:flex; align-items:center; gap:7px; font-family:var(--font-mono); font-size:11px; color:var(--muted); }
449
+ .pr-icon { color:var(--muted-2); }
450
+ .pr.current .pr-icon { color:var(--accent); }
451
+ .pr-number { font-weight:600; color:var(--fg); }
452
+ .pr-current { padding:1px 6px; font-size:9px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--accent); background:rgba(240,165,44,.12); border-radius:999px; }
453
+ .pr-title { margin:0; font-size:12.5px; font-weight:600; line-height:1.4; word-break:break-word; }
454
+ .pr-meta { display:flex; flex-wrap:wrap; align-items:center; gap:1px 5px; margin:0; font-family:var(--font-mono); font-size:10.5px; color:var(--muted-2); }
455
+ .pr-branch { color:var(--suggestion); }
456
+ .pr-review { margin:0; font-size:10.5px; font-weight:600; }
457
+ .pr-review-APPROVED { color:var(--success); }
458
+ .pr-review-CHANGES_REQUESTED { color:var(--blocking); }
459
+ .pr-review-REVIEW_REQUIRED { color:var(--attention); }
460
+ .pr-actions { display:flex; flex-wrap:wrap; gap:6px; margin-top:2px; }
461
+ .pr-btn { display:inline-flex; align-items:center; gap:5px; font:inherit; font-size:10.5px; font-weight:600; cursor:pointer; padding:4px 9px; color:var(--muted);
462
+ background:transparent; border:1px solid var(--border-strong); border-radius:999px; text-decoration:none; }
463
+ .pr-btn:hover { color:var(--fg); border-color:var(--accent-line); }
464
+ .pr-btn:disabled { opacity:.45; cursor:not-allowed; }
465
+ .pr-btn.danger { color:var(--accent); background:var(--accent-soft); border-color:var(--accent-line); }
466
+ .pr-warning { margin:0; padding:8px 10px; font-size:11px; line-height:1.5; color:var(--attention);
467
+ background:rgba(237,161,58,.08); border:1px solid rgba(237,161,58,.24); border-radius:10px; }
468
+ .pr-notice { margin:0; padding:8px 10px; font-size:11px; line-height:1.5; color:var(--success);
469
+ background:rgba(69,192,122,.08); border:1px solid rgba(69,192,122,.24); border-radius:10px; }
470
+ .pr-current-branch { margin:0; font-family:var(--font-mono); font-size:10.5px; color:var(--muted-2); }
471
+ .pr-current-branch code { color:var(--fg); }
472
+ .comments { display:flex; flex-direction:column; gap:18px; }
473
+ .comments-toolbar { display:flex; align-items:center; justify-content:space-between; gap:12px; }
474
+ .comments-counts { display:flex; flex-wrap:wrap; align-items:center; gap:12px; font-size:11.5px; color:var(--muted); }
475
+ .comments-counts b { font-family:var(--font-mono); font-weight:600; color:var(--fg); }
476
+ .comment-group { display:flex; flex-direction:column; gap:10px; }
477
+ .comment-group-title { margin:0; font-size:11px; font-weight:700; letter-spacing:.16em; text-transform:uppercase; color:var(--muted-2); }
478
+ .comment-file-group { display:flex; flex-direction:column; gap:8px; }
479
+ .comment-group-label { display:flex; align-items:center; gap:8px; margin:0; font-family:var(--font-mono); font-size:11.5px; color:var(--suggestion); }
480
+ .comment-thread { display:flex; flex-direction:column; overflow:hidden; background:var(--card); border:1px solid var(--border); border-radius:var(--radius-lg); }
481
+ .comment-thread-head { display:flex; align-items:center; gap:8px; padding:9px 13px; font-family:var(--font-mono); font-size:11px; color:var(--muted);
482
+ background:var(--surface-2); border-bottom:1px solid var(--border); }
483
+ .comment-file { overflow:hidden; color:var(--fg); text-overflow:ellipsis; white-space:nowrap; }
484
+ .pr-comment { display:flex; flex-direction:column; gap:7px; padding:12px 14px; border-top:1px solid var(--border); }
485
+ .comment-thread .pr-comment:first-of-type { border-top:0; }
486
+ .pr-comment.reply { margin-left:26px; background:rgba(255,255,255,.012); border-left:2px solid var(--accent-line); }
487
+ .pr-comment-head { display:flex; align-items:center; gap:9px; font-size:11px; color:var(--muted-2); }
488
+ .pr-comment-author { font-size:12px; font-weight:600; color:var(--fg); }
489
+ .pr-comment-link { display:inline-flex; align-items:center; gap:5px; font-size:11px; color:var(--muted); text-decoration:none; }
490
+ .pr-comment-link:hover { color:var(--accent); }
491
+ .pr-comment-body { font-size:12.5px; line-height:1.6; color:#f0ece6; }
492
+ .pr-comment-body .md-p, .pr-comment-body .md-list { font-size:12.5px; }
298
493
  .main { flex:1; min-width:0; display:flex; flex-direction:column; overflow-y:auto; }
299
494
  .topbar { position:sticky; top:0; z-index:10; display:flex; align-items:center; justify-content:space-between; gap:16px;
300
495
  padding:13px 24px; border-bottom:1px solid var(--border); background:rgba(16,16,20,.74);
@@ -314,6 +509,13 @@ code, pre, .mono { font-family:var(--font-mono); tab-size:4; }
314
509
  transition:background .15s ease, border-color .15s ease, color .15s ease; }
315
510
  .btn:hover { background:#202027; border-color:var(--accent-line); }
316
511
  .btn:disabled { opacity:.55; cursor:not-allowed; }
512
+ .btn.danger { color:#ffb3b3; background:rgba(255,86,86,.12); border-color:rgba(255,86,86,.4); }
513
+ .btn.danger:hover { background:rgba(255,86,86,.2); border-color:rgba(255,86,86,.62); }
514
+ .modal-check { display:flex; align-items:flex-start; gap:10px; padding:12px 14px; font-size:12.5px; line-height:1.55;
515
+ color:var(--fg); background:var(--surface-2); border:1px solid var(--border); border-radius:12px; cursor:pointer; }
516
+ .modal-check input { margin:3px 0 0; flex:none; accent-color:var(--accent); }
517
+ .modal-check code { font-family:var(--font-mono); font-size:11.5px; color:var(--muted); word-break:break-all; }
518
+ .modal-warn { margin:0; font-size:12px; line-height:1.55; color:var(--muted); }
317
519
  .icon.spin { animation:spin .8s linear infinite; }
318
520
  .content { width:100%; max-width:1180px; margin:0 auto; padding:22px 24px 64px; }
319
521
  .page-header { display:flex; align-items:flex-start; justify-content:space-between; gap:18px; margin-bottom:16px; }