@payconductor/houndsman 0.2.0 → 0.4.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 (107) hide show
  1. package/README.md +18 -6
  2. package/package.json +1 -1
  3. package/src/cli/arg-parser.ts +8 -0
  4. package/src/cli/commands/command.ts +23 -0
  5. package/src/cli/commands/context-command.ts +40 -0
  6. package/src/cli/commands/doctor-command.ts +60 -0
  7. package/src/cli/commands/findings-command.ts +38 -0
  8. package/src/cli/commands/help-command.ts +13 -0
  9. package/src/cli/commands/registry.ts +34 -0
  10. package/src/cli/commands/review-command.ts +21 -0
  11. package/src/cli/commands/server-command.ts +37 -0
  12. package/src/cli/commands/setup-command.ts +34 -0
  13. package/src/cli/commands/watchdog-command.ts +34 -0
  14. package/src/cli/help.ts +47 -0
  15. package/src/cli/index.ts +60 -268
  16. package/src/core/arg-parser.ts +48 -0
  17. package/src/core/commands/context.ts +29 -13
  18. package/src/core/commands/findings.ts +185 -260
  19. package/src/core/commands/review.ts +21 -39
  20. package/src/core/commands/scaffold.ts +189 -80
  21. package/src/core/commands/setup.ts +16 -11
  22. package/src/core/commands/update.ts +7 -7
  23. package/src/core/commands/watchdog.ts +8 -80
  24. package/src/core/config.ts +24 -25
  25. package/src/core/defaults.ts +20 -0
  26. package/src/core/diff-source.ts +1 -13
  27. package/src/core/doc-discoverer.ts +25 -15
  28. package/src/core/domain/budget.ts +42 -0
  29. package/src/core/domain/classification.ts +94 -0
  30. package/src/core/domain/diff.ts +53 -0
  31. package/src/core/domain/watchdog.ts +96 -0
  32. package/src/core/engine/carryover.ts +67 -0
  33. package/src/core/engine/houndsman-engine.ts +65 -0
  34. package/src/core/engine/review-scope.ts +95 -0
  35. package/src/core/engine/review-session.ts +79 -0
  36. package/src/core/file-classifier.ts +11 -67
  37. package/src/core/findings-state.ts +193 -100
  38. package/src/core/git-revision.ts +14 -40
  39. package/src/core/git-runner.ts +46 -0
  40. package/src/core/github-review.ts +4 -33
  41. package/src/core/jev/jev-candidates.ts +49 -35
  42. package/src/core/orchestrator.ts +247 -421
  43. package/src/core/paths.ts +26 -11
  44. package/src/core/pr-context.ts +39 -51
  45. package/src/core/project-config.ts +56 -0
  46. package/src/core/renderers/batch-presenter.ts +40 -0
  47. package/src/core/renderers/brief-renderer.ts +23 -0
  48. package/src/core/renderers/findings-presenter.ts +74 -0
  49. package/src/core/renderers/manifest-writer.ts +123 -0
  50. package/src/core/renderers/report-renderer.ts +66 -0
  51. package/src/core/renderers/verify-presenter.ts +50 -0
  52. package/src/core/renderers/watchdog-presenter.ts +100 -0
  53. package/src/core/report-builder.ts +12 -7
  54. package/src/core/run-layout.ts +8 -0
  55. package/src/core/run-status.ts +29 -59
  56. package/src/core/services/clock.ts +12 -0
  57. package/src/core/services/file-classifier.ts +34 -0
  58. package/src/core/services/fix-batch-builder.ts +13 -0
  59. package/src/core/services/gh-client.ts +54 -0
  60. package/src/core/services/git-repository.ts +139 -0
  61. package/src/core/services/project-files.ts +91 -0
  62. package/src/core/services/verifier.ts +58 -0
  63. package/src/core/storage/factory.ts +24 -0
  64. package/src/core/storage/file/file-storage.ts +269 -0
  65. package/src/core/storage/paths.ts +64 -0
  66. package/src/core/storage/repo-identity.ts +70 -0
  67. package/src/core/storage/sqlite/mappers.ts +160 -0
  68. package/src/core/storage/sqlite/schema.ts +108 -0
  69. package/src/core/storage/sqlite/sqlite-storage.ts +427 -0
  70. package/src/core/storage/types.ts +143 -0
  71. package/src/core/storage/work-area.ts +191 -0
  72. package/src/core/verify.ts +2 -1
  73. package/src/core/watchdog-registry.ts +58 -16
  74. package/src/index.ts +57 -6
  75. package/src/server/artifacts.ts +8 -61
  76. package/src/server/client/ArtifactsPanel.tsx +137 -23
  77. package/src/server/client/FindingCard.tsx +37 -6
  78. package/src/server/client/MarkdownText.tsx +101 -0
  79. package/src/server/client/SubmitModal.tsx +9 -4
  80. package/src/server/client/code-highlight.ts +422 -0
  81. package/src/server/client/constants.ts +130 -24
  82. package/src/server/client/json-highlight.ts +55 -0
  83. package/src/server/client/markdown.ts +153 -0
  84. package/src/server/client/types.ts +23 -0
  85. package/src/server/index.ts +155 -245
  86. package/src/server/run-repository.ts +299 -0
  87. package/src/skill/OUTPUT-FORMAT.md +6 -4
  88. package/src/skill/README.md +22 -14
  89. package/src/skill/SETUP.md +4 -4
  90. package/src/skill/fix.md +5 -5
  91. package/src/skill/hunt.md +27 -18
  92. package/src/skill/train.md +1 -1
  93. package/src/skill/verify.md +5 -5
  94. package/src/tests/__snapshots__/baseline.test.ts.snap +825 -0
  95. package/src/tests/baseline.test.ts +555 -493
  96. package/src/tests/cli.test.ts +40 -19
  97. package/src/tests/code-highlight.test.ts +123 -0
  98. package/src/tests/domain.test.ts +328 -0
  99. package/src/tests/findings-commands.test.ts +153 -131
  100. package/src/tests/findings-state.test.ts +6 -3
  101. package/src/tests/fixtures/fake-project.ts +0 -1
  102. package/src/tests/infrastructure.test.ts +205 -0
  103. package/src/tests/markdown.test.ts +89 -0
  104. package/src/tests/review-scripts.test.ts +8 -5
  105. package/src/tests/run-status.test.ts +33 -23
  106. package/src/tests/server.test.ts +108 -75
  107. package/src/tests/storage.test.ts +386 -0
@@ -1,46 +1,24 @@
1
1
  /**
2
2
  * Servidor local do houndsman (`Bun.serve`) — UI de triagem + artefatos.
3
3
  *
4
- * Serve um SPA Preact (bundle em memória) + uma API JSON sobre o `state.json`
5
- * das runs em `.houndsman/.runs/`. Sem dependências além do Bun e do Preact.
6
- * Bind padrão em `127.0.0.1` — ferramenta local, sem autenticação.
4
+ * Serve um SPA Preact (bundle em memória) + uma API JSON sobre o storage global
5
+ * (SQLite por default), escopada ao repositório do cwd. Sem dependências além do
6
+ * Bun e do Preact. Bind padrão em `127.0.0.1` — ferramenta local, sem autenticação.
7
7
  */
8
8
 
9
- import { join, resolve } from "node:path";
10
9
  import { mediaPath } from "../core/assets";
11
- import { type HoundsmanConfig, resolveArtifactDir } from "../core/config";
10
+ import type { HoundsmanConfig } from "../core/config";
11
+ import type { FindingUpdate } from "../core/findings-state";
12
12
  import {
13
- applyFindingUpdate,
14
- buildStateFromRunDir,
15
- emptyState,
16
- type FindingDecision,
17
- type FindingExecState,
18
- type FindingState,
19
- type FindingsFilter,
20
- type FindingsStateFile,
21
- FindingsStateStore,
22
- type FindingsSummary,
23
- type FindingUpdate,
24
- fileReader,
25
- filterFindings,
26
- reorder,
27
- type SyncResult,
28
- summarize,
29
- syncState,
30
- } from "../core/findings-state";
31
- import {
32
- buildSubmitPreview,
33
13
  defaultGhRunner,
34
14
  type GhRunner,
35
- resolvePrTarget,
36
15
  type SubmitSelection,
37
- submitFindings,
38
16
  } from "../core/github-review";
39
- import { STATE_FILE } from "../core/paths";
40
- import { pingFindingsStatus, type RunStatus, readRunStatus } from "../core/run-status";
41
- import type { Severity } from "../core/types";
42
- import { listRunSlugs, readRunFile, readRunJson, runDirForSlug } from "./artifacts";
17
+ import { createStorage } from "../core/storage/factory";
18
+ import { resolveRepoIdentity } from "../core/storage/repo-identity";
19
+ import type { RepoIdentity, StorageProvider } from "../core/storage/types";
43
20
  import { buildClientBundle } from "./client-build";
21
+ import { RunRepository } from "./run-repository";
44
22
 
45
23
  type BunServer = ReturnType<typeof Bun.serve>;
46
24
 
@@ -77,181 +55,13 @@ function notFound(detail = "não encontrado"): Response {
77
55
  // Estado / dados
78
56
  // ---------------------------------------------------------------------------
79
57
 
80
- /** Item da lista de execuções (sidebar) — já ordenado do mais recente ao antigo. */
81
- type RunListItem = {
82
- slug: string;
83
- branch: string;
84
- updatedAt?: string;
85
- status: RunStatus | null;
86
- summary: FindingsSummary;
58
+ /** Repositório das runs (dados) — ver `run-repository.ts`. */
59
+ type ServerDeps = {
60
+ ghRunner?: GhRunner;
61
+ storage?: StorageProvider;
62
+ repo?: RepoIdentity;
87
63
  };
88
64
 
89
- function buildForRun(dir: string, slug: string): FindingsStateFile | null {
90
- const manifest = readRunJson<{ branch?: string }>(dir, "manifest.json");
91
- return buildStateFromRunDir(dir, manifest?.branch ?? slug, fileReader(process.cwd()));
92
- }
93
-
94
- /**
95
- * Lê o estado da run. Se ausente, reconstrói dos artefatos e PERSISTE — os ids
96
- * são aleatórios, então precisam ser estáveis entre requisições.
97
- */
98
- function loadState(dir: string, slug: string): FindingsStateFile {
99
- const store = new FindingsStateStore(join(dir, STATE_FILE), slug);
100
- const existing = store.read();
101
- if (existing) return existing;
102
- const built = buildForRun(dir, slug);
103
- if (built) {
104
- store.write(built);
105
- return built;
106
- }
107
- return emptyState(slug);
108
- }
109
-
110
- function listRuns(artifactDir: string): RunListItem[] {
111
- return (
112
- listRunSlugs(artifactDir)
113
- .map((slug) => {
114
- const dir = join(artifactDir, slug);
115
- const state = loadState(dir, slug);
116
- const manifest = readRunJson<{ branch?: string; generatedAt?: string }>(
117
- dir,
118
- "manifest.json",
119
- );
120
- const status = readRunStatus(dir);
121
- return {
122
- slug,
123
- branch: state.branch || manifest?.branch || slug,
124
- updatedAt: status?.updatedAt ?? state.updatedAt,
125
- status: status?.status ?? null,
126
- summary: summarize(state),
127
- };
128
- })
129
- // Mais recentes primeiro (ISO 8601 compara lexicograficamente).
130
- .sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""))
131
- );
132
- }
133
-
134
- /**
135
- * Finding + caminho absoluto. O cliente monta o link `vscode://file/...` e não
136
- * conhece o cwd do servidor — o caminho relativo do relatório não basta.
137
- */
138
- function toClientFinding(finding: FindingState): FindingState & { absFile: string } {
139
- return { ...finding, absFile: resolve(process.cwd(), finding.file) };
140
- }
141
-
142
- function runDetail(dir: string, slug: string): unknown {
143
- const state = loadState(dir, slug);
144
- const classification = readRunJson<{
145
- watchdogs?: Record<string, string[]>;
146
- unclassified?: string[];
147
- deleted?: string[];
148
- }>(dir, "classification.json");
149
- return {
150
- slug,
151
- branch: state.branch || slug,
152
- status: readRunStatus(dir),
153
- manifest: readRunJson(dir, "manifest.json"),
154
- summary: summarize(state),
155
- watchdogs: Object.keys(classification?.watchdogs ?? {}),
156
- unclassified: classification?.unclassified ?? [],
157
- deleted: classification?.deleted ?? [],
158
- };
159
- }
160
-
161
- function findingsFor(dir: string, slug: string, params: URLSearchParams): unknown {
162
- const state = loadState(dir, slug);
163
- const filter: FindingsFilter = {
164
- decision: (params.get("decision") as FindingDecision) || undefined,
165
- execState: (params.get("exec") as FindingExecState) || undefined,
166
- severity: (params.get("severity") as Severity) || undefined,
167
- watchdog: params.get("watchdog") || undefined,
168
- file: params.get("file") || undefined,
169
- includeAbsent: params.get("all") === "1",
170
- };
171
- return {
172
- branch: state.branch,
173
- findings: filterFindings(state, filter).map(toClientFinding),
174
- summary: summarize(state),
175
- };
176
- }
177
-
178
- async function updateFinding(
179
- dir: string,
180
- slug: string,
181
- id: string,
182
- request: Request,
183
- ): Promise<Response> {
184
- const body = (await request.json().catch(() => ({}))) as FindingUpdate;
185
- const store = new FindingsStateStore(join(dir, STATE_FILE), slug);
186
- const state = store.read() ?? buildForRun(dir, slug) ?? emptyState(slug);
187
- const finding = state.findings[id];
188
- if (!finding) return notFound(`finding '${id}' não encontrado`);
189
- applyFindingUpdate(finding, body);
190
- reorder(state);
191
- store.write(state);
192
- pingFindingsStatus(dir, state);
193
- return json({ finding: toClientFinding(finding) });
194
- }
195
-
196
- function syncRun(dir: string, slug: string): Response {
197
- const store = new FindingsStateStore(join(dir, STATE_FILE), slug);
198
- const state = store.read() ?? buildForRun(dir, slug) ?? emptyState(slug);
199
- const result: SyncResult = syncState(state, fileReader(process.cwd()));
200
- store.write(state);
201
- pingFindingsStatus(dir, state);
202
- return json({ result, summary: summarize(state) });
203
- }
204
-
205
- /** Prévia do envio da revisão: candidatos + disponibilidade da PR (via gh). */
206
- function submitPreview(dir: string, slug: string, ghRunner: GhRunner): Response {
207
- const state = loadState(dir, slug);
208
- const target = resolvePrTarget(ghRunner, state.branch || slug);
209
- return json(buildSubmitPreview(state, target));
210
- }
211
-
212
- /**
213
- * Envia os findings selecionados como comentários na PR. Fail-open: sem PR/gh
214
- * devolve `{ available:false, reason }` (a UI só informa). Persiste o vínculo
215
- * (`finding.github`) no `state.json` e atualiza o status da run.
216
- */
217
- async function submitRun(
218
- dir: string,
219
- slug: string,
220
- ghRunner: GhRunner,
221
- request: Request,
222
- ): Promise<Response> {
223
- const state = loadState(dir, slug);
224
- const body = (await request.json().catch(() => ({}))) as SubmitSelection;
225
- const selection: SubmitSelection = {
226
- severities: body.severities,
227
- execStates: body.execStates,
228
- includeSent: body.includeSent,
229
- };
230
-
231
- const target = resolvePrTarget(ghRunner, state.branch || slug);
232
- if (!target.ok) return json({ available: false, reason: target.reason });
233
-
234
- const outcome = submitFindings({
235
- state,
236
- target: target.target,
237
- runner: ghRunner,
238
- selection,
239
- });
240
- new FindingsStateStore(join(dir, STATE_FILE), slug).write(state);
241
- pingFindingsStatus(dir, state);
242
- return json({ available: true, target: target.target, outcome });
243
- }
244
-
245
- function serveArtifact(dir: string, name: string): Response {
246
- if (!/\.(json|md)$/i.test(name)) return notFound("tipo de artefato não servido");
247
- const content = readRunFile(dir, name);
248
- if (content === null) return notFound(`artefato '${name}' não encontrado`);
249
- const type = name.toLowerCase().endsWith(".json")
250
- ? "application/json; charset=utf-8"
251
- : "text/markdown; charset=utf-8";
252
- return text(content, type);
253
- }
254
-
255
65
  // ---------------------------------------------------------------------------
256
66
  // Rotas
257
67
  // ---------------------------------------------------------------------------
@@ -270,7 +80,7 @@ async function serveLogo(): Promise<Response> {
270
80
 
271
81
  async function route(
272
82
  request: Request,
273
- artifactDir: string,
83
+ runs: RunRepository,
274
84
  ghRunner: GhRunner,
275
85
  ): Promise<Response> {
276
86
  const url = new URL(request.url);
@@ -286,65 +96,83 @@ async function route(
286
96
  if (path === "/api/health") return json({ ok: true });
287
97
 
288
98
  if (path === "/api/runs" && request.method === "GET") {
289
- return json({ runs: listRuns(artifactDir) });
99
+ return json({ runs: runs.list() });
290
100
  }
291
101
 
292
102
  const match = path.match(API_RUN);
293
103
  if (!match) return notFound();
294
104
  const slug = decodeURIComponent(match[1]);
295
105
  const rest = match[2] ?? "";
296
- const dir = runDirForSlug(artifactDir, slug);
297
- if (!dir) return notFound(`run '${slug}' não encontrada`);
106
+ if (!runs.exists(slug)) return notFound(`run '${slug}' não encontrada`);
298
107
 
299
- if (rest === "" || rest === "/") return json(runDetail(dir, slug));
108
+ if (rest === "" || rest === "/") return json(runs.detail(slug));
300
109
 
301
110
  if (rest === "/findings" && request.method === "GET") {
302
- return json(findingsFor(dir, slug, url.searchParams));
111
+ return json(runs.findings(slug, url.searchParams));
303
112
  }
304
- if (rest === "/sync" && request.method === "POST") return syncRun(dir, slug);
113
+ if (rest === "/sync" && request.method === "POST") return json(runs.sync(slug));
305
114
  if (rest === "/submit-preview" && request.method === "GET") {
306
- return submitPreview(dir, slug, ghRunner);
115
+ return json(runs.submitPreview(slug, ghRunner));
307
116
  }
308
117
  if (rest === "/submit" && request.method === "POST") {
309
- return submitRun(dir, slug, ghRunner, request);
118
+ const body = (await request.json().catch(() => ({}))) as SubmitSelection;
119
+ const selection: SubmitSelection = {
120
+ severities: body.severities,
121
+ execStates: body.execStates,
122
+ includeSent: body.includeSent,
123
+ };
124
+ return json(runs.submit(slug, selection, ghRunner));
310
125
  }
311
126
 
312
127
  const findingMatch = rest.match(/^\/findings\/([^/]+)$/);
313
128
  if (findingMatch) {
314
129
  const id = decodeURIComponent(findingMatch[1]);
315
130
  if (request.method === "GET") {
316
- const state = loadState(dir, slug);
317
- const finding = state.findings[id];
131
+ const finding = runs.finding(slug, id);
318
132
  return finding
319
- ? json({ finding: toClientFinding(finding) })
133
+ ? json({ finding })
320
134
  : notFound(`finding '${id}' não encontrado`);
321
135
  }
322
136
  if (request.method === "PATCH" || request.method === "POST") {
323
- return updateFinding(dir, slug, id, request);
137
+ const body = (await request.json().catch(() => ({}))) as FindingUpdate;
138
+ const finding = runs.update(slug, id, body);
139
+ return finding
140
+ ? json({ finding })
141
+ : notFound(`finding '${id}' não encontrado`);
324
142
  }
325
143
  }
326
144
 
327
145
  const artifactMatch = rest.match(/^\/artifacts\/(.+)$/);
328
146
  if (artifactMatch && request.method === "GET") {
329
- return serveArtifact(dir, decodeURIComponent(artifactMatch[1]));
147
+ const name = decodeURIComponent(artifactMatch[1]);
148
+ const artifact = runs.artifact(slug, name);
149
+ if (!artifact) return notFound(`artefato '${name}' não encontrado`);
150
+ return text(artifact.content, artifact.type);
151
+ }
152
+ if (rest === "/artifacts" && request.method === "GET") {
153
+ return json({ artifacts: runs.artifacts(slug) ?? [] });
330
154
  }
331
155
 
332
156
  return notFound();
333
157
  }
334
158
 
335
- /** Dependências injetáveis do servidor (testes). */
336
- export type ServerDeps = { ghRunner?: GhRunner };
159
+ /** Servidor local do houndsman (rotas + shell). */
160
+ export class HoundsmanServer {
161
+ private readonly runs: RunRepository;
162
+ private readonly ghRunner: GhRunner;
163
+
164
+ constructor(config: HoundsmanConfig, deps: ServerDeps = {}) {
165
+ this.runs = new RunRepository(
166
+ deps.storage ?? createStorage(config),
167
+ deps.repo ?? resolveRepoIdentity(),
168
+ );
169
+ this.ghRunner = deps.ghRunner ?? defaultGhRunner;
170
+ }
337
171
 
338
- /** Cria o handler de requisições (testável sem abrir porta). */
339
- export function createRequestHandler(
340
- config: HoundsmanConfig,
341
- deps: ServerDeps = {},
342
- ): (request: Request) => Promise<Response> {
343
- const artifactDir = resolveArtifactDir(config);
344
- const ghRunner = deps.ghRunner ?? defaultGhRunner;
345
- return async (request) => {
172
+ /** Handler de requisições (testável sem abrir porta). */
173
+ readonly handler = async (request: Request): Promise<Response> => {
346
174
  try {
347
- return await route(request, artifactDir, ghRunner);
175
+ return await route(request, this.runs, this.ghRunner);
348
176
  } catch (error) {
349
177
  return json(
350
178
  { error: error instanceof Error ? error.message : String(error) },
@@ -352,6 +180,40 @@ export function createRequestHandler(
352
180
  );
353
181
  }
354
182
  };
183
+
184
+ /** Sobe o servidor e devolve a instância + URL. */
185
+ start(options: { port?: number; host?: string } = {}): {
186
+ server: BunServer;
187
+ url: string;
188
+ } {
189
+ // Manutenção do banco no start: compacta o que sobrou de runs antigas.
190
+ try {
191
+ this.runs.maintain();
192
+ } catch (error) {
193
+ console.warn(
194
+ `[houndsman] VACUUM falhou (seguindo sem compactar): ${
195
+ error instanceof Error ? error.message : String(error)
196
+ }`,
197
+ );
198
+ }
199
+ const host = options.host ?? "127.0.0.1";
200
+ const port = options.port ?? 7331;
201
+ const server = Bun.serve({ hostname: host, port, fetch: this.handler });
202
+ return { server, url: `http://${host}:${server.port}` };
203
+ }
204
+
205
+ /** Fecha o provider (após `server.stop()`). */
206
+ dispose(): void {
207
+ this.runs.dispose();
208
+ }
209
+ }
210
+
211
+ /** Cria o handler de requisições (testável sem abrir porta). */
212
+ export function createRequestHandler(
213
+ config: HoundsmanConfig,
214
+ deps: ServerDeps = {},
215
+ ): (request: Request) => Promise<Response> {
216
+ return new HoundsmanServer(config, deps).handler;
355
217
  }
356
218
 
357
219
  export type ServerOptions = {
@@ -360,18 +222,21 @@ export type ServerOptions = {
360
222
  host?: string;
361
223
  /** Runner do `gh` (injetável nos testes). */
362
224
  ghRunner?: GhRunner;
225
+ /** Provider/storage injetável (testes). */
226
+ storage?: StorageProvider;
227
+ repo?: RepoIdentity;
363
228
  };
364
229
 
365
230
  /** Sobe o servidor e devolve a instância + URL. */
366
231
  export function startServer(options: ServerOptions): { server: BunServer; url: string } {
367
- const host = options.host ?? "127.0.0.1";
368
- const port = options.port ?? 7331;
369
- const server = Bun.serve({
370
- hostname: host,
371
- port,
372
- fetch: createRequestHandler(options.config, { ghRunner: options.ghRunner }),
232
+ return new HoundsmanServer(options.config, {
233
+ ghRunner: options.ghRunner,
234
+ storage: options.storage,
235
+ repo: options.repo,
236
+ }).start({
237
+ port: options.port,
238
+ host: options.host,
373
239
  });
374
- return { server, url: `http://${host}:${server.port}` };
375
240
  }
376
241
 
377
242
  // ---------------------------------------------------------------------------
@@ -397,7 +262,7 @@ html, body { height:100%; }
397
262
  body { margin:0; font-size:14px; background:var(--bg); color:var(--fg); font-family:var(--font-sans); -webkit-font-smoothing:antialiased; }
398
263
  body::before { content:""; position:fixed; inset:0; pointer-events:none; z-index:0;
399
264
  background:radial-gradient(820px 420px at 8% -12%, rgba(240,165,44,.08), transparent 62%); }
400
- code, pre, .mono { font-family:var(--font-mono); }
265
+ code, pre, .mono { font-family:var(--font-mono); tab-size:4; }
401
266
  ::-webkit-scrollbar { width:10px; height:10px; }
402
267
  ::-webkit-scrollbar-thumb { background:rgba(255,255,255,.1); border:2px solid transparent; border-radius:999px; background-clip:padding-box; }
403
268
  ::-webkit-scrollbar-thumb:hover { background:rgba(255,255,255,.18); background-clip:padding-box; }
@@ -539,13 +404,35 @@ code, pre, .mono { font-family:var(--font-mono); }
539
404
  .meta { display:inline-flex; align-items:center; gap:7px; }
540
405
  .meta-k { font-size:10px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--muted-2); }
541
406
  .meta code { padding:2px 6px; font-size:11.5px; color:var(--fg); background:rgba(255,255,255,.04); border:1px solid var(--border); border-radius:6px; }
542
- .action-block { display:flex; gap:11px; padding:16px; background:var(--accent-soft); border:1px solid var(--accent-line); border-radius:var(--radius); }
543
- .action-icon { flex:none; display:inline-block; margin-right:5px; margin-bottom:-5px; color:var(--accent); }
544
- .action-label { display:inline-block; font-size:10px; font-weight:600; letter-spacing:.12em; text-transform:uppercase; color:var(--accent); margin-right:5px; }
545
- .action-block p { margin:0; font-size:13px; color:#f0ece6; }
407
+ .action-block { display:flex; gap:11px; padding:0; background:transparent; border:0; border-radius:0; }
408
+ .action-icon { flex:none; display:inline-block; margin-top:3px; margin-right:2px; color:var(--accent); }
409
+ .action-text { flex:1; min-width:0; }
410
+ .action-label { display:block; margin-bottom:4px; font-size:10px; font-weight:600; letter-spacing:.12em; text-transform:uppercase; color:var(--accent); }
411
+ .action-text p, .md-p { margin:0 0 6px; font-size:13px; line-height:1.65; color:#f0ece6; white-space:pre-wrap; }
412
+ .action-text p:last-child { margin-bottom:0; }
413
+ .md-list { margin:0 0 6px; padding-left:20px; font-size:13px; line-height:1.65; color:#f0ece6; }
414
+ .md-list li { margin:0 0 2px; }
415
+ .md-quote { margin:0 0 6px; padding:2px 0 2px 10px; border-left:2px solid var(--accent-line); color:var(--muted); font-size:13px; line-height:1.65; }
416
+ .action-text code, .md-code { font-family:var(--font-mono); font-size:11.5px; padding:1px 5px; color:#f0ece6; background:rgba(255,255,255,.06); border:1px solid var(--border); border-radius:5px; }
417
+ .action-text a { color:var(--accent-strong); text-decoration:underline; text-underline-offset:2px; }
418
+ .action-text strong { color:#fff; font-weight:600; }
419
+ .action-text em { color:#e9e4dc; }
420
+ .md-code-block { margin:6px 0; padding:10px 12px; max-height:320px; overflow:auto; white-space:pre; font-size:11.5px; line-height:1.6; color:#cdc7bf; background:#0b0b0e; border-radius:10px; }
546
421
  .decision-tags-block { display:flex; align-items:center; gap:8px; }
547
422
  .evidence { margin:0; padding: 16px; white-space:break-spaces; font-size:12px; line-height:1.6; color:#cdc7bf;
548
423
  background:#0b0b0e; border:1px solid var(--border); border-radius:var(--radius); }
424
+ .evidence-block { display:flex; flex-direction:column; gap:6px; }
425
+ .evidence-head { display:flex; justify-content:flex-end; }
426
+ .code-lang { font-family:var(--font-mono); font-size:9.5px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--muted-2); }
427
+ .code-evidence { white-space:pre; overflow:auto; }
428
+ .tok-keyword { color:#c792ea; }
429
+ .tok-string { color:#9ddc8a; }
430
+ .tok-comment { color:#6d6862; font-style:italic; }
431
+ .tok-number { color:#8fc7ff; }
432
+ .tok-type { color:var(--accent-strong); }
433
+ .tok-function { color:#82aaff; }
434
+ .tok-property { color:#e6c07b; }
435
+ .tok-punct { color:#8d8a85; }
549
436
  .tags { display:flex; flex-wrap:wrap; align-items:center; gap:7px; margin-left: auto; }
550
437
  .tags-label { display:inline-flex; align-items:center; gap:6px; font-size:10px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--muted-2); }
551
438
  .tag { display:inline-flex; align-items:center; gap:6px; padding:3px 9px; font-family:var(--font-mono); font-size:11px; color:var(--muted);
@@ -568,11 +455,34 @@ code, pre, .mono { font-family:var(--font-mono); }
568
455
  font-family:var(--font-mono); font-size:11px; color:var(--muted); }
569
456
  .comment-status.saved { color:var(--success); }
570
457
  .artifacts { display:flex; flex-direction:column; }
571
- .artifact-tabs { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:12px; }
572
- .artifact-tabs .chip { border:1px solid var(--border); background:var(--surface-2); }
573
- .artifact-desc { margin:0 0 12px; color:var(--muted); font-size:12.5px; line-height:1.55; }
574
- .artifact-view { margin:0; padding:14px; max-height:70vh; overflow:auto; font-size:12px; line-height:1.6; color:#cdc7bf;
458
+ .artifact-layout { display:grid; grid-template-columns:minmax(190px,250px) minmax(0,1fr); gap:16px; align-items:start; }
459
+ .artifact-list { display:flex; flex-direction:column; gap:14px; max-height:72vh; overflow:auto; padding:2px 4px 2px 0; }
460
+ .artifact-group { display:flex; flex-direction:column; gap:4px; }
461
+ .artifact-group-label { margin:0 0 2px; font-size:10px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--muted); }
462
+ .artifact-item { display:flex; flex-direction:column; gap:2px; text-align:left; cursor:pointer; font:inherit; color:var(--muted);
463
+ padding:8px 10px; background:transparent; border:1px solid transparent; border-radius:10px;
464
+ transition:background .15s ease, border-color .15s ease, color .15s ease; }
465
+ .artifact-item:hover { color:var(--fg); background:var(--surface-2); border-color:var(--border); }
466
+ .artifact-item.active { color:var(--fg); background:var(--accent-soft); border-color:var(--accent-line); }
467
+ .artifact-item-name { font-family:var(--font-mono); font-size:11.5px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
468
+ .artifact-item-kind { font-size:9.5px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--muted-2); }
469
+ .artifact-item.active .artifact-item-kind { color:var(--accent); }
470
+ .artifact-pane { min-width:0; }
471
+ .artifact-head { display:flex; align-items:flex-start; justify-content:space-between; gap:14px; margin-bottom:12px; }
472
+ .artifact-title { margin:0 0 5px; font-family:var(--font-mono); font-size:13px; font-weight:600; word-break:break-word; }
473
+ .artifact-desc { margin:0; max-width:78ch; color:var(--muted); font-size:12.5px; line-height:1.55; }
474
+ .artifact-kind-badge { flex:none; display:inline-flex; align-items:center; padding:3px 9px; font-size:10px; font-weight:700;
475
+ letter-spacing:.08em; text-transform:uppercase; color:var(--accent); background:var(--accent-soft);
476
+ border:1px solid var(--accent-line); border-radius:999px; }
477
+ .artifact-view { margin:0; padding:14px 16px; max-height:70vh; overflow:auto; font-size:12px; line-height:1.6; color:#cdc7bf;
575
478
  background:#0b0b0e; border:1px solid var(--border); border-radius:var(--radius); }
479
+ .json-view { white-space:pre; }
480
+ .json-key { color:var(--accent-strong); }
481
+ .json-string { color:#9ddc8a; }
482
+ .json-number { color:#8fc7ff; }
483
+ .json-literal { color:#d7a6ff; }
484
+ .json-punct { color:#8d8a85; }
485
+ @media (max-width:900px) { .artifact-layout { grid-template-columns:1fr; } .artifact-list { max-height:240px; } }
576
486
  .sent-tag { display:inline-flex; align-items:center; gap:5px; padding:2px 8px; font-size:10px; font-weight:700; letter-spacing:.06em; text-transform:uppercase;
577
487
  color:var(--success); background:rgba(69,192,122,.12); border:1px solid rgba(69,192,122,.32); border-radius:999px; text-decoration:none; }
578
488
  .sent-tag:hover { color:#fff; }