wicked-crew 0.3.2 → 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 (44) hide show
  1. package/dist/api/elicitation-cache.d.ts +93 -0
  2. package/dist/api/elicitation-cache.d.ts.map +1 -0
  3. package/dist/api/elicitation-cache.js +128 -0
  4. package/dist/api/elicitation-cache.js.map +1 -0
  5. package/dist/api/evidence.d.ts +37 -38
  6. package/dist/api/evidence.d.ts.map +1 -1
  7. package/dist/api/evidence.js +40 -36
  8. package/dist/api/evidence.js.map +1 -1
  9. package/dist/api/gate-cache.d.ts +29 -13
  10. package/dist/api/gate-cache.d.ts.map +1 -1
  11. package/dist/api/gate-cache.js +82 -36
  12. package/dist/api/gate-cache.js.map +1 -1
  13. package/dist/api/requirements.d.ts +12 -3
  14. package/dist/api/requirements.d.ts.map +1 -1
  15. package/dist/api/requirements.js +34 -37
  16. package/dist/api/requirements.js.map +1 -1
  17. package/dist/api/routes.d.ts +2 -1
  18. package/dist/api/routes.d.ts.map +1 -1
  19. package/dist/api/routes.js +388 -41
  20. package/dist/api/routes.js.map +1 -1
  21. package/dist/api/server.d.ts.map +1 -1
  22. package/dist/api/server.js +13 -6
  23. package/dist/api/server.js.map +1 -1
  24. package/dist/core/adapter.d.ts +94 -6
  25. package/dist/core/adapter.d.ts.map +1 -1
  26. package/dist/core/adapter.js +386 -86
  27. package/dist/core/adapter.js.map +1 -1
  28. package/dist/core/exec.d.ts +37 -0
  29. package/dist/core/exec.d.ts.map +1 -0
  30. package/dist/core/exec.js +97 -0
  31. package/dist/core/exec.js.map +1 -0
  32. package/dist/core/repoPaths.d.ts +37 -0
  33. package/dist/core/repoPaths.d.ts.map +1 -0
  34. package/dist/core/repoPaths.js +48 -0
  35. package/dist/core/repoPaths.js.map +1 -0
  36. package/dist/core/types.d.ts +81 -2
  37. package/dist/core/types.d.ts.map +1 -1
  38. package/dist/core/types.js.map +1 -1
  39. package/dist/studio/assets/index-Ci_R6ARr.js +423 -0
  40. package/dist/studio/assets/index-Dio_c1Q3.css +32 -0
  41. package/dist/studio/index.html +2 -2
  42. package/package.json +14 -13
  43. package/dist/studio/assets/index-CjUiA3ex.css +0 -32
  44. package/dist/studio/assets/index-DlHYzFvv.js +0 -420
@@ -5,11 +5,10 @@ import { readFileSync, existsSync } from 'node:fs';
5
5
  import { promises as fsp } from 'node:fs';
6
6
  import { join } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
- import { execFile } from 'node:child_process';
9
- import { promisify } from 'node:util';
10
- import { CoreAdapter } from '../core/adapter.js';
8
+ import { ChatUnsupportedError, CoreAdapter, ElicitationUnsupportedError } from '../core/adapter.js';
9
+ import { codeGraphDb, requirementsGraph } from '../core/repoPaths.js';
11
10
  import { buildEvidenceBundle, evidenceFilename } from './evidence.js';
12
- const execFileAsync = promisify(execFile);
11
+ import { execCapped, ExecOutputTooLarge } from '../core/exec.js';
13
12
  const V = '/api/v1';
14
13
  // Daemon version reported by /health — read from package.json so it never drifts
15
14
  // from the shipped version across releases. Resolves the package root from the
@@ -33,6 +32,25 @@ function sortActionableFirst(views) {
33
32
  function message(err) {
34
33
  return err instanceof Error ? err.message : String(err);
35
34
  }
35
+ /**
36
+ * Turns a failed parse into a 400 body, naming any field the schema does not know.
37
+ *
38
+ * Every schema here is `.strict()`, because zod's default is to STRIP unknown keys — which made a
39
+ * misspelled optional field a silent behaviour change (FINDING-031). `POST /runs {"clis":[...],
40
+ * "workflowId":"feature"}` — core's field names, not the HTTP layer's — answered `201` and ran the
41
+ * full roster with no workflow. Rejecting is only half the fix: a bare "Invalid request body" leaves
42
+ * the caller comparing their JSON against the source, so the unknown keys are named in `error`
43
+ * itself, where a human and a `curl | jq .error` both see it without reading `details`.
44
+ */
45
+ function invalidBody(err, what) {
46
+ const unknown = err.issues.flatMap((i) => (i.code === 'unrecognized_keys' ? i.keys : []));
47
+ const error = unknown.length > 0
48
+ ? `${what}: unknown field${unknown.length > 1 ? 's' : ''} ${unknown
49
+ .map((k) => `\`${k}\``)
50
+ .join(', ')} — this endpoint does not accept ${unknown.length > 1 ? 'them' : 'it'}, and ignoring ${unknown.length > 1 ? 'them' : 'it'} would run a different request than you sent`
51
+ : what;
52
+ return { error, details: err.issues };
53
+ }
36
54
  // Repo names become directory components under ~/.wicked/repos/ — reject anything
37
55
  // that would allow path traversal (slashes, dots-only segments, control chars).
38
56
  const SAFE_REPO_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
@@ -49,6 +67,7 @@ const RegisterRepoSchema = z
49
67
  rootPath: z.string().optional(),
50
68
  gitUrl: z.string().optional(),
51
69
  })
70
+ .strict()
52
71
  .refine((d) => {
53
72
  const hasRemote = typeof d.gitUrl === 'string' && d.gitUrl.length > 0;
54
73
  const hasLocal = typeof d.rootPath === 'string' && d.rootPath.length > 0;
@@ -56,6 +75,19 @@ const RegisterRepoSchema = z
56
75
  // or rootPath alone (register existing local repo) — all valid.
57
76
  return hasRemote || hasLocal;
58
77
  }, { message: 'Provide gitUrl (remote clone) or rootPath (local registration), or both.' });
78
+ /**
79
+ * The launch body. Every field but `problem` is optional, which is what made stripping dangerous:
80
+ * omitting one is a legitimate request that gets an engine default, so a MISSPELLED one was
81
+ * indistinguishable from an omitted one and the run went ahead on a configuration the caller never
82
+ * asked for (FINDING-031).
83
+ *
84
+ * The names differ from core's on purpose and this is the trap worth knowing about: core's
85
+ * `LaunchSpec` takes `clis` (an array) and `workflow`, this takes `clisJson` (a JSON *string*) and
86
+ * `workflow`, and the `/ws` `sessionStarted` frame reports the chosen workflow as `workflowId`. A
87
+ * caller who reads the event stream to learn the field names arrives at `workflowId`, which this
88
+ * schema does not accept — so `.strict()` is what turns that trip into a 400 instead of an
89
+ * unworkflowed run reported as `201`.
90
+ */
59
91
  const LaunchSchema = z.object({
60
92
  problem: z.string().min(1),
61
93
  sessionId: z.string().min(1).optional(),
@@ -64,16 +96,16 @@ const LaunchSchema = z.object({
64
96
  humanConfirm: z.string().min(1).optional(),
65
97
  repoRef: z.string().min(1).optional(),
66
98
  workflow: z.string().min(1).optional(),
67
- });
99
+ }).strict();
68
100
  const GateSchema = z.object({
69
101
  approve: z.boolean(),
70
102
  amend: z.string().optional(),
71
- });
103
+ }).strict();
72
104
  const InjectSchema = z.object({
73
105
  message: z.string().min(1),
74
106
  /** `"all"` broadcasts to every active worker; any other value is a CLI key. */
75
107
  target: z.string().min(1).default('all'),
76
- });
108
+ }).strict();
77
109
  const OpenTerminalSchema = z.object({
78
110
  cwd: z.string().min(1),
79
111
  cmd: z.array(z.string().min(1)).min(1).optional(),
@@ -82,16 +114,16 @@ const OpenTerminalSchema = z.object({
82
114
  // Optional so omission is the SAFE governed default (§7 — `false` is never a
83
115
  // default; the ungoverned operator shell must opt in explicitly).
84
116
  governed: z.boolean().optional(),
85
- });
117
+ }).strict();
86
118
  const ResizeTerminalSchema = z.object({
87
119
  cols: z.number().int().positive(),
88
120
  rows: z.number().int().positive(),
89
- });
121
+ }).strict();
90
122
  /**
91
123
  * The daemon REST surface. Every endpoint is a thin wrapper over one adapter /
92
124
  * core-ts call (DES-STUDIO-001 §2). `session`/`phase` nouns are now `run`/`unit`.
93
125
  */
94
- export function registerRoutes(app, adapter, gateCache) {
126
+ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
95
127
  // Liveness — also proves the actor + event pump are up.
96
128
  app.get(`${V}/health`, async () => {
97
129
  const ping = await adapter.ping();
@@ -111,7 +143,7 @@ export function registerRoutes(app, adapter, gateCache) {
111
143
  app.post(`${V}/repos`, async (req, reply) => {
112
144
  const parsed = RegisterRepoSchema.safeParse(req.body);
113
145
  if (!parsed.success) {
114
- return reply.code(400).send({ error: 'Invalid request body', details: parsed.error.issues });
146
+ return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
115
147
  }
116
148
  const { name, rootPath, gitUrl } = parsed.data;
117
149
  try {
@@ -162,7 +194,7 @@ export function registerRoutes(app, adapter, gateCache) {
162
194
  app.post(`${V}/runs`, async (req, reply) => {
163
195
  const parsed = LaunchSchema.safeParse(req.body);
164
196
  if (!parsed.success) {
165
- return reply.code(400).send({ error: 'Invalid request body', details: parsed.error.issues });
197
+ return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
166
198
  }
167
199
  const b = parsed.data;
168
200
  const input = {
@@ -188,10 +220,12 @@ export function registerRoutes(app, adapter, gateCache) {
188
220
  return reply.code(busy ? 409 : 400).send({ error: msg });
189
221
  }
190
222
  });
191
- // Run list (replaces GET /sessions). Actionable-first; reconciles the gate cache.
223
+ // Run list (replaces GET /sessions). Actionable-first; reconciles the gate and elicitation caches
224
+ // so that terminal-run entries are pruned even when their terminal CoreEvent was missed.
192
225
  app.get(`${V}/runs`, async () => {
193
226
  const views = await adapter.sessionsDetail();
194
227
  gateCache.reconcile(views);
228
+ elicitationCache.reconcile(views);
195
229
  return { runs: sortActionableFirst(views) };
196
230
  });
197
231
  // One run's detail.
@@ -206,6 +240,99 @@ export function registerRoutes(app, adapter, gateCache) {
206
240
  // A unit's captured transcript. unitKey is the suffix after `<run>:` — `u<ord>` for free-text
207
241
  // runs, `<phase_id>` for workflow runs (e.g. "survey", "coverage"). Strip any accidental
208
242
  // `<id>:` prefix so both `survey` and `run-1:survey` resolve to the same key.
243
+ // ── Chat sessions (crew#165): warm ACP seat pool + group fan-out (core#134) ──
244
+ // A chat is NOT a run: no council, no gates, no units. Seats warm on open;
245
+ // messages fan out to warm seats; replies stream on /ws as chatDelta/chatReply.
246
+ const ChatOpenSchema = z.object({
247
+ chatId: z.string().min(1).max(128).regex(/^[A-Za-z0-9._-]+$/).optional(),
248
+ clis: z.array(z.string().min(1)).min(1).max(8).optional(),
249
+ repoRef: z.string().optional(),
250
+ }).strict();
251
+ app.post(`${V}/chats`, async (req, reply) => {
252
+ const parsed = ChatOpenSchema.safeParse(req.body ?? {});
253
+ if (!parsed.success) {
254
+ return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
255
+ }
256
+ const b = parsed.data;
257
+ const chatId = b.chatId ?? randomUUID();
258
+ let cwd;
259
+ if (b.repoRef !== undefined) {
260
+ const repos = await adapter.listRepos();
261
+ const repo = repos.find((r) => r.id === b.repoRef);
262
+ if (!repo)
263
+ return reply.code(404).send({ error: `Repo ${b.repoRef} not found` });
264
+ cwd = repo.root_path;
265
+ }
266
+ const clis = b.clis ??
267
+ CoreAdapter.roster()
268
+ .map((s) => s.key)
269
+ .filter((k) => typeof k === 'string');
270
+ try {
271
+ const seats = await adapter.chatOpen(chatId, clis, cwd);
272
+ return reply.code(201).send({ chatId, seats });
273
+ }
274
+ catch (err) {
275
+ return reply.code(400).send({ error: message(err) });
276
+ }
277
+ });
278
+ const ChatMessageSchema = z.object({
279
+ text: z.string().min(1).max(65536),
280
+ targets: z.array(z.string().min(1)).min(1).max(8).optional(),
281
+ }).strict();
282
+ app.post(`${V}/chats/:id/messages`, async (req, reply) => {
283
+ const { id } = req.params;
284
+ const parsed = ChatMessageSchema.safeParse(req.body);
285
+ if (!parsed.success) {
286
+ return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
287
+ }
288
+ try {
289
+ const seats = await adapter.chatSend(id, parsed.data.text, parsed.data.targets);
290
+ return reply.code(202).send({ seats });
291
+ }
292
+ catch (err) {
293
+ const msg = message(err);
294
+ return reply.code(/no warm seats/.test(msg) ? 409 : 400).send({ error: msg });
295
+ }
296
+ });
297
+ // Enumerate live chats (FINDING-027 gap 4). Chat sessions deliberately outlive the page, and
298
+ // their ids are minted client-side — so before this route the only record of an orphaned seat
299
+ // lived in the tab that abandoned it, and an operator could not reclaim one without restarting
300
+ // the daemon. Registered BEFORE `/chats/:id` is irrelevant to fastify (it routes on the literal
301
+ // segment first), but the order reads the way the routes nest.
302
+ app.get(`${V}/chats`, async (_req, reply) => {
303
+ try {
304
+ return { chats: await adapter.chatList() };
305
+ }
306
+ catch (err) {
307
+ // A build that cannot do chat is a capability gap, not a bad request: 501 tells an operator to
308
+ // upgrade rather than to fix a call that was already correct. Branching on the type, not on
309
+ // the message — regexing the text here caught the missing-binding phrasing and missed the
310
+ // engine's own ("chat unsupported: engine spawned without the ACP runner"), so half of one
311
+ // condition answered 400.
312
+ return reply
313
+ .code(err instanceof ChatUnsupportedError ? 501 : 400)
314
+ .send({ error: message(err) });
315
+ }
316
+ });
317
+ app.get(`${V}/chats/:id`, async (req, reply) => {
318
+ const { id } = req.params;
319
+ try {
320
+ return { chatId: id, seats: await adapter.chatSeats(id) };
321
+ }
322
+ catch (err) {
323
+ return reply.code(400).send({ error: message(err) });
324
+ }
325
+ });
326
+ app.delete(`${V}/chats/:id`, async (req, reply) => {
327
+ const { id } = req.params;
328
+ try {
329
+ await adapter.chatClose(id);
330
+ return { ok: true };
331
+ }
332
+ catch (err) {
333
+ return reply.code(400).send({ error: message(err) });
334
+ }
335
+ });
209
336
  app.get(`${V}/runs/:id/units/:unitKey/output`, async (req, reply) => {
210
337
  const { id, unitKey } = req.params;
211
338
  const suffix = unitKey.startsWith(`${id}:`) ? unitKey.slice(id.length + 1) : unitKey;
@@ -213,15 +340,15 @@ export function registerRoutes(app, adapter, gateCache) {
213
340
  return reply.send({ output });
214
341
  });
215
342
  // The whole run as one auditable JSON attachment: the run, its units (each with
216
- // the captured transcript), and the gate/routing decision trail re-derived from
217
- // the run DTO — the daemon keeps no event log of its own.
343
+ // the captured transcript), and the decision trail read back from core's durable
344
+ // per-run event log — what actually happened, not a re-derivation of it.
218
345
  app.get(`${V}/runs/:id/evidence`, async (req, reply) => {
219
346
  const { id } = req.params;
220
347
  const views = await adapter.sessionsDetail();
221
348
  const run = views.find((v) => v.session.id === id);
222
349
  if (!run)
223
350
  return reply.code(404).send({ error: 'Run not found' });
224
- const bundle = await buildEvidenceBundle(run, (unitId) => adapter.workOutput(unitId));
351
+ const bundle = await buildEvidenceBundle(run, (unitId) => adapter.workOutput(unitId), (runId) => adapter.runEvents(runId));
225
352
  return reply
226
353
  .header('Content-Disposition', `attachment; filename="${evidenceFilename(id)}"`)
227
354
  .send(bundle);
@@ -231,7 +358,7 @@ export function registerRoutes(app, adapter, gateCache) {
231
358
  const { id } = req.params;
232
359
  const parsed = GateSchema.safeParse(req.body);
233
360
  if (!parsed.success) {
234
- return reply.code(400).send({ error: 'Invalid request body', details: parsed.error.issues });
361
+ return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
235
362
  }
236
363
  const views = await adapter.sessionsDetail();
237
364
  const run = views.find((v) => v.session.id === id);
@@ -290,7 +417,7 @@ export function registerRoutes(app, adapter, gateCache) {
290
417
  const { id } = req.params;
291
418
  const parsed = InjectSchema.safeParse(req.body);
292
419
  if (!parsed.success) {
293
- return reply.code(400).send({ error: 'Invalid request body', details: parsed.error.issues });
420
+ return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
294
421
  }
295
422
  const ids = await adapter.sessions();
296
423
  if (!ids.includes(id))
@@ -303,14 +430,188 @@ export function registerRoutes(app, adapter, gateCache) {
303
430
  return reply.code(409).send({ error: message(err) });
304
431
  }
305
432
  });
306
- // The daemon-cached gate prompt for a paused run (not a core call) so a fresh
307
- // browser can render the gate after a late join.
433
+ // The gate prompt for a paused run, so a fresh browser can render the gate after a late join.
434
+ //
435
+ // Cache first, durable event log second (FINDING-051). The cache is process-lifetime, so before
436
+ // the fallback existed a daemon restart left every parked run unable to say what it was asking —
437
+ // not because the prompt was gone (core records `awaitingHuman` to the log) but because nothing
438
+ // read it. A restart is routine: deploy, crash, laptop sleep.
308
439
  app.get(`${V}/runs/:id/gate`, async (req, reply) => {
309
440
  const { id } = req.params;
310
- const entry = gateCache.get(id);
311
- if (!entry)
441
+ const cached = gateCache.get(id);
442
+ if (cached)
443
+ return { runId: id, ...cached };
444
+ // A miss is not evidence of anything on its own, so ask the run what it is doing before paying
445
+ // to replay it. Only `awaiting_human` can have an open gate, and that answer is definitive:
446
+ // every other status is a 404 that needs no log at all — which is also the overwhelmingly
447
+ // common case here, since studio polls this route for runs that are merely finished.
448
+ //
449
+ // Reading status first is therefore CHEAPER than not reading it, the opposite of what the first
450
+ // cut of this assumed: it trades one `sessionsDetail()` for replaying an entire event history,
451
+ // and it was that skipped check which made a completed run answer 503 on any build without the
452
+ // binding (CI caught it) — an error where the honest, knowable answer was "no gate".
453
+ const views = await adapter.sessionsDetail();
454
+ const run = views.find((v) => v.session.id === id);
455
+ if (!run)
456
+ return reply.code(404).send({ error: 'Run not found' });
457
+ if (run.session.status !== 'awaiting_human') {
458
+ return reply.code(404).send({ error: 'No open gate for this run' });
459
+ }
460
+ const events = await adapter.runEvents(id);
461
+ if (events === null) {
462
+ // Now — and only now — 503 is the honest answer: this run really is holding for a human, and
463
+ // this build cannot say what it is asking. Distinct cause, distinct message; answering "no
464
+ // gate" here would report a capability gap as a fact about the run (the FINDING-050 shape).
465
+ return reply.code(503).send({
466
+ error: 'Gate history is unavailable: this wicked-core build has no event-log read binding',
467
+ });
468
+ }
469
+ const replayed = gateCache.rebuild(id, events);
470
+ if (!replayed) {
471
+ // Parked, with a history that records no open gate. Pre-log runs land here (their prompt is
472
+ // genuinely lost), as does a gate whose `awaitingHuman` predates the log's retention.
312
473
  return reply.code(404).send({ error: 'No open gate for this run' });
313
- return { runId: id, ...entry };
474
+ }
475
+ return { runId: id, ...replayed };
476
+ });
477
+ // ── Elicitation (DES-002) ────────────────────────────────────────────────────
478
+ //
479
+ // GET returns the current pending elicitation prompt for a run (display-store read).
480
+ // POST resolves it: the body carries the elicitationId to guard against stale tabs,
481
+ // the action (accept|decline|cancel), and — for accept — the operator's response.
482
+ app.get(`${V}/runs/:id/elicitation`, async (req, reply) => {
483
+ const { id } = req.params;
484
+ const entry = elicitationCache.get(id);
485
+ // `entry` already carries `runId`; return it directly to avoid TS2783
486
+ // ("runId is specified more than once") from a redundant spread.
487
+ if (entry)
488
+ return entry;
489
+ // Cache miss: check existence before returning 404.
490
+ // Use sessions() (IDs only) — cheaper than sessionsDetail() on this read path.
491
+ const ids = await adapter.sessions();
492
+ if (!ids.includes(id))
493
+ return reply.code(404).send({ error: 'Run not found' });
494
+ return reply.code(404).send({ error: 'No pending elicitation for this run' });
495
+ });
496
+ const ElicitationRespondSchema = z
497
+ .object({
498
+ /** Guards against stale-tab submissions: must match the current elicitation's id. */
499
+ elicitationId: z.string().min(1),
500
+ action: z.enum(['accept', 'decline', 'cancel']),
501
+ /** Required when action is "accept" (response must be non-empty); must be absent for decline/cancel. */
502
+ content: z.object({ response: z.string().min(1) }).optional(),
503
+ })
504
+ .strict()
505
+ .superRefine((val, ctx) => {
506
+ if (val.action !== 'accept' && val.content !== undefined) {
507
+ ctx.addIssue({
508
+ code: z.ZodIssueCode.custom,
509
+ path: ['content'],
510
+ message: 'content must only be provided when action is "accept"',
511
+ });
512
+ }
513
+ });
514
+ app.post(`${V}/runs/:id/elicitation`, async (req, reply) => {
515
+ const { id } = req.params;
516
+ // 1. Validate body.
517
+ const parsed = ElicitationRespondSchema.safeParse(req.body);
518
+ if (!parsed.success) {
519
+ return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
520
+ }
521
+ const body = parsed.data;
522
+ // 1b. Accept requires content.response — validated here, before any state query, so
523
+ // the 400 is deterministic regardless of run-existence / cache state (P2).
524
+ if (body.action === 'accept' && !body.content?.response) {
525
+ return reply.code(400).send({ error: 'action:accept requires content.response' });
526
+ }
527
+ // 2. Existence check (IDs only — cheaper than sessionsDetail).
528
+ const ids = await adapter.sessions();
529
+ if (!ids.includes(id))
530
+ return reply.code(404).send({ error: 'Run not found' });
531
+ // 3. Atomically take the pending elicitation.
532
+ const taken = elicitationCache.take(id);
533
+ if (!taken)
534
+ return reply.code(409).send({ error: 'No pending elicitation for this run' });
535
+ // 4. Stale-tab check: submitted elicitationId must match the one we just took.
536
+ if (body.elicitationId !== taken.entry.elicitationId) {
537
+ elicitationCache.restoreIfUnchanged(id, taken.entry, taken.gen);
538
+ return reply
539
+ .code(409)
540
+ .send({ error: 'Elicitation superseded; fetch the current prompt and resubmit' });
541
+ }
542
+ // 5. Accept-specific validation.
543
+ if (body.action === 'accept') {
544
+ // 5a. content.response must be present.
545
+ if (typeof body.content?.response !== 'string') {
546
+ elicitationCache.restoreIfUnchanged(id, taken.entry, taken.gen);
547
+ return reply.code(400).send({ error: 'action:accept requires content.response' });
548
+ }
549
+ // 5b. Enum check: if the schema constrained the response, honour it.
550
+ if (taken.entry.options !== null &&
551
+ !taken.entry.options.includes(body.content.response)) {
552
+ elicitationCache.restoreIfUnchanged(id, taken.entry, taken.gen);
553
+ return reply.code(400).send({ error: 'response must be one of the allowed options' });
554
+ }
555
+ }
556
+ const response = body.action === 'accept' ? body.content.response : null;
557
+ // 6. Forward to the actor.
558
+ try {
559
+ await adapter.resolveElicitation(id, taken.entry.elicitationId, body.action, response);
560
+ }
561
+ catch (err) {
562
+ elicitationCache.restoreIfUnchanged(id, taken.entry, taken.gen);
563
+ const code = err instanceof ElicitationUnsupportedError ? 501 : 500;
564
+ return reply.code(code).send({ error: message(err) });
565
+ }
566
+ // 7. Done.
567
+ return { status: 'resolved' };
568
+ });
569
+ // The durable history of one run (FINDING-057).
570
+ //
571
+ // `/ws` is a live tap and explicitly replays nothing on late join, so until this route existed
572
+ // the event log had exactly one reader — the gate route above, which reads it for a single
573
+ // `awaitingHuman` frame and discards the rest. Everything else a run recorded (routing councils,
574
+ // gate verdicts, per-unit cost) was write-only: observable if you happened to be attached while
575
+ // it streamed, and unrecoverable afterwards. That makes an incident un-investigable and a run's
576
+ // audit trail unciteable, which is the opposite of what a durable log is for.
577
+ //
578
+ // `type` filters server-side because the alternative is shipping an entire run's history to
579
+ // answer "what did the gates decide" — the log already excludes high-volume frames
580
+ // (`is_high_volume` drops CLI/chat deltas and terminal output), so what remains is the
581
+ // lifecycle, but a long run is still thousands of frames.
582
+ app.get(`${V}/runs/:id/events`, async (req, reply) => {
583
+ const { id } = req.params;
584
+ // Fastify's default querystring parser yields a string for `?type=a` and an ARRAY for a
585
+ // repeated `?type=a&type=b`. Typing it as `string` and calling `.split` would have thrown a
586
+ // 500 on the repeated form — a caller asking for two types the obvious way.
587
+ const { type } = req.query;
588
+ // IDs only: this is an existence check, and `sessionsDetail()` would load and parse every
589
+ // run's full unit detail to answer it — the same reason `/runs/:id/inject` uses `sessions()`.
590
+ const ids = await adapter.sessions();
591
+ if (!ids.includes(id)) {
592
+ return reply.code(404).send({ error: 'Run not found' });
593
+ }
594
+ const events = await adapter.runEvents(id);
595
+ if (events === null) {
596
+ // Same shape as the gate route's 503, and for the same reason: "no events" would report a
597
+ // missing binding as a fact about the run. The run may well have a rich history.
598
+ return reply.code(503).send({
599
+ error: 'Run history is unavailable: this wicked-core build has no event-log read binding',
600
+ });
601
+ }
602
+ // An empty array here is a real answer, not a failure: runs that predate the log have no
603
+ // history, and saying so is the honest response.
604
+ // Both spellings of "several types" mean the same thing: `?type=a,b` and `?type=a&type=b`.
605
+ // An empty filter (`?type=`, or only separators) is treated as NO filter rather than as a
606
+ // filter matching nothing — "show me events of no type" is not a question anyone asks, and
607
+ // answering it with an empty list looks identical to a run that recorded nothing.
608
+ const names = (Array.isArray(type) ? type : type === undefined ? [] : [type])
609
+ .flatMap((t) => t.split(','))
610
+ .map((t) => t.trim())
611
+ .filter(Boolean);
612
+ const wanted = names.length > 0 ? new Set(names) : null;
613
+ const filtered = wanted ? events.filter((e) => wanted.has(e.type)) : events;
614
+ return { runId: id, total: events.length, returned: filtered.length, events: filtered };
314
615
  });
315
616
  // ── Governance reads (crew#40) ──────────────────────────────────────────────
316
617
  app.get(`${V}/governance/policies`, async () => {
@@ -348,6 +649,32 @@ export function registerRoutes(app, adapter, gateCache) {
348
649
  return reply.code(400).send({ error: message(err) });
349
650
  }
350
651
  });
652
+ // Retire, not delete. The record survives so past decisions citing it stay explicable; it just
653
+ // stops being enforced (FINDING-038 — a mis-authored policy otherwise denied forever).
654
+ app.delete(`${V}/governance/policies/:id`, async (req, reply) => {
655
+ const { id } = req.params;
656
+ try {
657
+ const existed = await adapter.retirePolicy(id);
658
+ if (!existed)
659
+ return reply.code(404).send({ error: `policy '${id}' not found` });
660
+ return { status: 'retired', id };
661
+ }
662
+ catch (err) {
663
+ return reply.code(400).send({ error: message(err) });
664
+ }
665
+ });
666
+ app.delete(`${V}/governance/rules/:id`, async (req, reply) => {
667
+ const { id } = req.params;
668
+ try {
669
+ const existed = await adapter.retireConformanceRule(id);
670
+ if (!existed)
671
+ return reply.code(404).send({ error: `conformance rule '${id}' not found` });
672
+ return { status: 'retired', id };
673
+ }
674
+ catch (err) {
675
+ return reply.code(400).send({ error: message(err) });
676
+ }
677
+ });
351
678
  app.get(`${V}/governance/rules/preview`, async (req, reply) => {
352
679
  const q = req.query;
353
680
  try {
@@ -435,16 +762,31 @@ export function registerRoutes(app, adapter, gateCache) {
435
762
  const repo = repos.find((r) => r.id === id);
436
763
  if (!repo)
437
764
  return reply.code(404).send({ error: `Repo ${id} not found` });
438
- const graphPath = join(repo.root_path, '.wicked-estate', 'requirements', 'requirements_graph.json');
439
- const dbPath = join(repo.root_path, '.codegraph', 'estate.db');
765
+ const graphPath = requirementsGraph(repo);
766
+ const dbPath = codeGraphDb(repo);
440
767
  // Coverage from the live estate store — computed by wicked-core governance layer.
441
768
  let coverage = null;
442
769
  if (existsSync(dbPath)) {
443
770
  try {
444
- const { stdout } = await execFileAsync(process.env['WICKED_CORE_EXE'] ?? 'wicked-core', ['coverage', '--db', dbPath, '--json'], { timeout: 20_000, cwd: repo.root_path });
771
+ const { stdout } = await execCapped(process.env['WICKED_CORE_EXE'] ?? 'wicked-core', ['coverage', '--db', dbPath, '--json'], { timeout: 20_000, cwd: repo.root_path });
445
772
  coverage = JSON.parse(stdout);
446
773
  }
447
- catch { /* store not yet indexed — coverage stays null */ }
774
+ catch (err) {
775
+ // The bare `catch {}` here claimed "store not yet indexed" for EVERY failure — a comment
776
+ // asserting a diagnosis the code never checked. An output overflow, a timeout and a missing
777
+ // binary all became a silent null, so the panel showed "no coverage" for reasons that are
778
+ // not the same problem and do not have the same fix (FINDING-016, and FINDING-050's shape:
779
+ // distinct causes collapsed into one outcome).
780
+ //
781
+ // Coverage stays optional — this endpoint must not 500 because the store is not indexed yet,
782
+ // which is a legitimate and common state. But a cause that is NOT that gets said out loud.
783
+ if (err instanceof ExecOutputTooLarge) {
784
+ req.log.warn({ err: err.message }, 'coverage output exceeded the buffer cap');
785
+ }
786
+ else if (!(err instanceof SyntaxError)) {
787
+ req.log.debug({ err: message(err) }, 'coverage unavailable');
788
+ }
789
+ }
448
790
  }
449
791
  try {
450
792
  const content = await fsp.readFile(graphPath, 'utf8');
@@ -468,7 +810,7 @@ export function registerRoutes(app, adapter, gateCache) {
468
810
  domain: z.string().optional(),
469
811
  offset: z.coerce.number().int().min(0).default(0),
470
812
  limit: z.coerce.number().int().min(1).max(200).default(50),
471
- });
813
+ }).strict();
472
814
  app.get(`${V}/repos/:id/requirements`, async (req, reply) => {
473
815
  const { id } = req.params;
474
816
  const repos = await adapter.listRepos();
@@ -477,9 +819,9 @@ export function registerRoutes(app, adapter, gateCache) {
477
819
  return reply.code(404).send({ error: `Repo ${id} not found` });
478
820
  const parsed = ReqQuerySchema.safeParse(req.query);
479
821
  if (!parsed.success) {
480
- return reply.code(400).send({ error: 'Invalid query', details: parsed.error.issues });
822
+ return reply.code(400).send(invalidBody(parsed.error, 'Invalid query'));
481
823
  }
482
- const page = await listRequirements(repo.root_path, parsed.data);
824
+ const page = await listRequirements(repo, parsed.data);
483
825
  if (page === null) {
484
826
  return reply.code(404).send({ error: 'requirements_graph.json not generated for this repo yet' });
485
827
  }
@@ -498,7 +840,7 @@ export function registerRoutes(app, adapter, gateCache) {
498
840
  catch {
499
841
  return reply.code(400).send({ error: 'Malformed requirement key encoding' });
500
842
  }
501
- const detail = await getRequirement(repo.root_path, decoded);
843
+ const detail = await getRequirement(repo, decoded);
502
844
  if (detail === null)
503
845
  return reply.code(404).send({ error: 'Requirement not found' });
504
846
  return { requirement: detail };
@@ -510,6 +852,11 @@ export function registerRoutes(app, adapter, gateCache) {
510
852
  status: z.enum(['active', 'deprecated', 'review']).optional(),
511
853
  risk: z.boolean().optional(),
512
854
  })
855
+ .strict()
856
+ // `.strict()` BEFORE `.refine()`: the refine runs on the parsed object, so with stripping still
857
+ // in effect `{"title":"x","note":"y"}` would pass the non-empty check having silently discarded
858
+ // the edit the caller cared about. Every field here is optional, which is exactly the shape
859
+ // FINDING-031 makes dangerous.
513
860
  .refine((b) => Object.keys(b).length > 0, { message: 'empty patch' });
514
861
  app.patch(`${V}/repos/:id/requirements/:key`, async (req, reply) => {
515
862
  const { id, key } = req.params;
@@ -519,7 +866,7 @@ export function registerRoutes(app, adapter, gateCache) {
519
866
  return reply.code(404).send({ error: `Repo ${id} not found` });
520
867
  const parsed = ReqPatchSchema.safeParse(req.body);
521
868
  if (!parsed.success) {
522
- return reply.code(400).send({ error: 'Invalid patch', details: parsed.error.issues });
869
+ return reply.code(400).send(invalidBody(parsed.error, 'Invalid patch'));
523
870
  }
524
871
  let decodedKey;
525
872
  try {
@@ -528,7 +875,7 @@ export function registerRoutes(app, adapter, gateCache) {
528
875
  catch {
529
876
  return reply.code(400).send({ error: 'Malformed requirement key encoding' });
530
877
  }
531
- const detail = await patchRequirement(repo.root_path, decodedKey, parsed.data);
878
+ const detail = await patchRequirement(repo, decodedKey, parsed.data);
532
879
  if (detail === null)
533
880
  return reply.code(404).send({ error: 'Requirement not found' });
534
881
  return { requirement: detail };
@@ -539,7 +886,7 @@ export function registerRoutes(app, adapter, gateCache) {
539
886
  const repo = repos.find((r) => r.id === id);
540
887
  if (!repo)
541
888
  return reply.code(404).send({ error: `Repo ${id} not found` });
542
- const dbPath = join(repo.root_path, '.codegraph', 'estate.db');
889
+ const dbPath = codeGraphDb(repo);
543
890
  if (!existsSync(dbPath)) {
544
891
  return reply.send({ graph: null });
545
892
  }
@@ -556,7 +903,7 @@ export function registerRoutes(app, adapter, gateCache) {
556
903
  if (q.focus !== undefined && q.focus.trim() !== '') {
557
904
  args.push('--focus', q.focus.trim());
558
905
  }
559
- const { stdout } = await execFileAsync('wicked-estate', args, {
906
+ const { stdout } = await execCapped('wicked-estate', args, {
560
907
  timeout: 30_000,
561
908
  cwd: repo.root_path,
562
909
  });
@@ -588,12 +935,12 @@ export function registerRoutes(app, adapter, gateCache) {
588
935
  const repo = repos.find((r) => r.id === id);
589
936
  if (!repo)
590
937
  return reply.code(404).send({ error: `Repo ${id} not found` });
591
- const dbPath = join(repo.root_path, '.codegraph', 'estate.db');
938
+ const dbPath = codeGraphDb(repo);
592
939
  if (!existsSync(dbPath)) {
593
940
  return reply.code(404).send({ error: 'Code graph not built for this repo yet' });
594
941
  }
595
942
  try {
596
- const { stdout } = await execFileAsync('wicked-estate', ['blast-radius', q.name.trim(), '--db', dbPath, '--json'], { timeout: 30_000, cwd: repo.root_path });
943
+ const { stdout } = await execCapped('wicked-estate', ['blast-radius', q.name.trim(), '--db', dbPath, '--json'], { timeout: 30_000, cwd: repo.root_path });
597
944
  return reply.send(JSON.parse(stdout));
598
945
  }
599
946
  catch (err) {
@@ -607,7 +954,7 @@ export function registerRoutes(app, adapter, gateCache) {
607
954
  if (!repo)
608
955
  return reply.code(404).send({ error: `Repo ${id} not found` });
609
956
  try {
610
- const { stdout } = await execFileAsync('git', ['log', '--pretty=format:%H\x1f%h\x1f%s\x1f%an\x1f%ar', '-n', '20'], { timeout: 10_000, cwd: repo.root_path });
957
+ const { stdout } = await execCapped('git', ['log', '--pretty=format:%H\x1f%h\x1f%s\x1f%an\x1f%ar', '-n', '20'], { timeout: 10_000, cwd: repo.root_path });
611
958
  const commits = stdout.trim().split('\n').filter(Boolean).map((line) => {
612
959
  const parts = line.split('\x1f');
613
960
  return {
@@ -639,7 +986,7 @@ export function registerRoutes(app, adapter, gateCache) {
639
986
  if (!repo)
640
987
  return reply.code(404).send({ error: `Repo ${id} not found` });
641
988
  try {
642
- const { stdout } = await execFileAsync('git', ['shortlog', '-sne', '-n', '--no-merges', 'HEAD'], { timeout: 10_000, cwd: repo.root_path });
989
+ const { stdout } = await execCapped('git', ['shortlog', '-sne', '-n', '--no-merges', 'HEAD'], { timeout: 10_000, cwd: repo.root_path });
643
990
  // Output: " 42\tFull Name <email@example.com>"
644
991
  const contributors = stdout.trim().split('\n').filter(Boolean).slice(0, 10).map((line) => {
645
992
  const m = line.match(/^\s*(\d+)\s+(.+?)\s+<([^>]+)>/);
@@ -671,7 +1018,7 @@ export function registerRoutes(app, adapter, gateCache) {
671
1018
  app.post(`${V}/terminals`, async (req, reply) => {
672
1019
  const parsed = OpenTerminalSchema.safeParse(req.body);
673
1020
  if (!parsed.success) {
674
- return reply.code(400).send({ error: 'Invalid request body', details: parsed.error.issues });
1021
+ return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
675
1022
  }
676
1023
  const b = parsed.data;
677
1024
  try {
@@ -688,7 +1035,7 @@ export function registerRoutes(app, adapter, gateCache) {
688
1035
  const { id } = req.params;
689
1036
  const parsed = ResizeTerminalSchema.safeParse(req.body);
690
1037
  if (!parsed.success) {
691
- return reply.code(400).send({ error: 'Invalid request body', details: parsed.error.issues });
1038
+ return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
692
1039
  }
693
1040
  try {
694
1041
  const status = await adapter.resizeTerminal(id, parsed.data.cols, parsed.data.rows);