wicked-crew 0.3.1 → 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 +66 -0
  14. package/dist/api/requirements.d.ts.map +1 -0
  15. package/dist/api/requirements.js +328 -0
  16. package/dist/api/requirements.js.map +1 -0
  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 +494 -34
  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-BCcaLGxk.js +0 -420
  44. package/dist/studio/assets/index-vsyAOtPq.css +0 -32
@@ -1,14 +1,14 @@
1
1
  import { z } from 'zod';
2
+ import { listRequirements, getRequirement, patchRequirement } from './requirements.js';
2
3
  import { randomUUID } from 'node:crypto';
3
4
  import { readFileSync, existsSync } from 'node:fs';
4
5
  import { promises as fsp } from 'node:fs';
5
6
  import { join } from 'node:path';
6
7
  import { fileURLToPath } from 'node:url';
7
- import { execFile } from 'node:child_process';
8
- import { promisify } from 'node:util';
9
- import { CoreAdapter } from '../core/adapter.js';
8
+ import { ChatUnsupportedError, CoreAdapter, ElicitationUnsupportedError } from '../core/adapter.js';
9
+ import { codeGraphDb, requirementsGraph } from '../core/repoPaths.js';
10
10
  import { buildEvidenceBundle, evidenceFilename } from './evidence.js';
11
- const execFileAsync = promisify(execFile);
11
+ import { execCapped, ExecOutputTooLarge } from '../core/exec.js';
12
12
  const V = '/api/v1';
13
13
  // Daemon version reported by /health — read from package.json so it never drifts
14
14
  // from the shipped version across releases. Resolves the package root from the
@@ -32,6 +32,25 @@ function sortActionableFirst(views) {
32
32
  function message(err) {
33
33
  return err instanceof Error ? err.message : String(err);
34
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
+ }
35
54
  // Repo names become directory components under ~/.wicked/repos/ — reject anything
36
55
  // that would allow path traversal (slashes, dots-only segments, control chars).
37
56
  const SAFE_REPO_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
@@ -48,6 +67,7 @@ const RegisterRepoSchema = z
48
67
  rootPath: z.string().optional(),
49
68
  gitUrl: z.string().optional(),
50
69
  })
70
+ .strict()
51
71
  .refine((d) => {
52
72
  const hasRemote = typeof d.gitUrl === 'string' && d.gitUrl.length > 0;
53
73
  const hasLocal = typeof d.rootPath === 'string' && d.rootPath.length > 0;
@@ -55,6 +75,19 @@ const RegisterRepoSchema = z
55
75
  // or rootPath alone (register existing local repo) — all valid.
56
76
  return hasRemote || hasLocal;
57
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
+ */
58
91
  const LaunchSchema = z.object({
59
92
  problem: z.string().min(1),
60
93
  sessionId: z.string().min(1).optional(),
@@ -63,16 +96,16 @@ const LaunchSchema = z.object({
63
96
  humanConfirm: z.string().min(1).optional(),
64
97
  repoRef: z.string().min(1).optional(),
65
98
  workflow: z.string().min(1).optional(),
66
- });
99
+ }).strict();
67
100
  const GateSchema = z.object({
68
101
  approve: z.boolean(),
69
102
  amend: z.string().optional(),
70
- });
103
+ }).strict();
71
104
  const InjectSchema = z.object({
72
105
  message: z.string().min(1),
73
106
  /** `"all"` broadcasts to every active worker; any other value is a CLI key. */
74
107
  target: z.string().min(1).default('all'),
75
- });
108
+ }).strict();
76
109
  const OpenTerminalSchema = z.object({
77
110
  cwd: z.string().min(1),
78
111
  cmd: z.array(z.string().min(1)).min(1).optional(),
@@ -81,16 +114,16 @@ const OpenTerminalSchema = z.object({
81
114
  // Optional so omission is the SAFE governed default (§7 — `false` is never a
82
115
  // default; the ungoverned operator shell must opt in explicitly).
83
116
  governed: z.boolean().optional(),
84
- });
117
+ }).strict();
85
118
  const ResizeTerminalSchema = z.object({
86
119
  cols: z.number().int().positive(),
87
120
  rows: z.number().int().positive(),
88
- });
121
+ }).strict();
89
122
  /**
90
123
  * The daemon REST surface. Every endpoint is a thin wrapper over one adapter /
91
124
  * core-ts call (DES-STUDIO-001 §2). `session`/`phase` nouns are now `run`/`unit`.
92
125
  */
93
- export function registerRoutes(app, adapter, gateCache) {
126
+ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
94
127
  // Liveness — also proves the actor + event pump are up.
95
128
  app.get(`${V}/health`, async () => {
96
129
  const ping = await adapter.ping();
@@ -110,7 +143,7 @@ export function registerRoutes(app, adapter, gateCache) {
110
143
  app.post(`${V}/repos`, async (req, reply) => {
111
144
  const parsed = RegisterRepoSchema.safeParse(req.body);
112
145
  if (!parsed.success) {
113
- 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'));
114
147
  }
115
148
  const { name, rootPath, gitUrl } = parsed.data;
116
149
  try {
@@ -161,7 +194,7 @@ export function registerRoutes(app, adapter, gateCache) {
161
194
  app.post(`${V}/runs`, async (req, reply) => {
162
195
  const parsed = LaunchSchema.safeParse(req.body);
163
196
  if (!parsed.success) {
164
- 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'));
165
198
  }
166
199
  const b = parsed.data;
167
200
  const input = {
@@ -187,10 +220,12 @@ export function registerRoutes(app, adapter, gateCache) {
187
220
  return reply.code(busy ? 409 : 400).send({ error: msg });
188
221
  }
189
222
  });
190
- // 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.
191
225
  app.get(`${V}/runs`, async () => {
192
226
  const views = await adapter.sessionsDetail();
193
227
  gateCache.reconcile(views);
228
+ elicitationCache.reconcile(views);
194
229
  return { runs: sortActionableFirst(views) };
195
230
  });
196
231
  // One run's detail.
@@ -205,6 +240,99 @@ export function registerRoutes(app, adapter, gateCache) {
205
240
  // A unit's captured transcript. unitKey is the suffix after `<run>:` — `u<ord>` for free-text
206
241
  // runs, `<phase_id>` for workflow runs (e.g. "survey", "coverage"). Strip any accidental
207
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
+ });
208
336
  app.get(`${V}/runs/:id/units/:unitKey/output`, async (req, reply) => {
209
337
  const { id, unitKey } = req.params;
210
338
  const suffix = unitKey.startsWith(`${id}:`) ? unitKey.slice(id.length + 1) : unitKey;
@@ -212,15 +340,15 @@ export function registerRoutes(app, adapter, gateCache) {
212
340
  return reply.send({ output });
213
341
  });
214
342
  // The whole run as one auditable JSON attachment: the run, its units (each with
215
- // the captured transcript), and the gate/routing decision trail re-derived from
216
- // 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.
217
345
  app.get(`${V}/runs/:id/evidence`, async (req, reply) => {
218
346
  const { id } = req.params;
219
347
  const views = await adapter.sessionsDetail();
220
348
  const run = views.find((v) => v.session.id === id);
221
349
  if (!run)
222
350
  return reply.code(404).send({ error: 'Run not found' });
223
- const bundle = await buildEvidenceBundle(run, (unitId) => adapter.workOutput(unitId));
351
+ const bundle = await buildEvidenceBundle(run, (unitId) => adapter.workOutput(unitId), (runId) => adapter.runEvents(runId));
224
352
  return reply
225
353
  .header('Content-Disposition', `attachment; filename="${evidenceFilename(id)}"`)
226
354
  .send(bundle);
@@ -230,7 +358,7 @@ export function registerRoutes(app, adapter, gateCache) {
230
358
  const { id } = req.params;
231
359
  const parsed = GateSchema.safeParse(req.body);
232
360
  if (!parsed.success) {
233
- 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'));
234
362
  }
235
363
  const views = await adapter.sessionsDetail();
236
364
  const run = views.find((v) => v.session.id === id);
@@ -289,7 +417,7 @@ export function registerRoutes(app, adapter, gateCache) {
289
417
  const { id } = req.params;
290
418
  const parsed = InjectSchema.safeParse(req.body);
291
419
  if (!parsed.success) {
292
- 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'));
293
421
  }
294
422
  const ids = await adapter.sessions();
295
423
  if (!ids.includes(id))
@@ -302,14 +430,188 @@ export function registerRoutes(app, adapter, gateCache) {
302
430
  return reply.code(409).send({ error: message(err) });
303
431
  }
304
432
  });
305
- // The daemon-cached gate prompt for a paused run (not a core call) so a fresh
306
- // 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.
307
439
  app.get(`${V}/runs/:id/gate`, async (req, reply) => {
308
440
  const { id } = req.params;
309
- const entry = gateCache.get(id);
310
- 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') {
311
458
  return reply.code(404).send({ error: 'No open gate for this run' });
312
- return { runId: id, ...entry };
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.
473
+ return reply.code(404).send({ error: 'No open gate for this run' });
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 };
313
615
  });
314
616
  // ── Governance reads (crew#40) ──────────────────────────────────────────────
315
617
  app.get(`${V}/governance/policies`, async () => {
@@ -347,6 +649,32 @@ export function registerRoutes(app, adapter, gateCache) {
347
649
  return reply.code(400).send({ error: message(err) });
348
650
  }
349
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
+ });
350
678
  app.get(`${V}/governance/rules/preview`, async (req, reply) => {
351
679
  const q = req.query;
352
680
  try {
@@ -434,16 +762,31 @@ export function registerRoutes(app, adapter, gateCache) {
434
762
  const repo = repos.find((r) => r.id === id);
435
763
  if (!repo)
436
764
  return reply.code(404).send({ error: `Repo ${id} not found` });
437
- const graphPath = join(repo.root_path, '.wicked-estate', 'requirements', 'requirements_graph.json');
438
- const dbPath = join(repo.root_path, '.codegraph', 'estate.db');
765
+ const graphPath = requirementsGraph(repo);
766
+ const dbPath = codeGraphDb(repo);
439
767
  // Coverage from the live estate store — computed by wicked-core governance layer.
440
768
  let coverage = null;
441
769
  if (existsSync(dbPath)) {
442
770
  try {
443
- 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 });
444
772
  coverage = JSON.parse(stdout);
445
773
  }
446
- 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
+ }
447
790
  }
448
791
  try {
449
792
  const content = await fsp.readFile(graphPath, 'utf8');
@@ -458,20 +801,112 @@ export function registerRoutes(app, adapter, gateCache) {
458
801
  // ── Repo code graph (via wicked-estate graph-view) ──────────────────────────
459
802
  // Delegates to the estate CLI so the query goes through the proper service
460
803
  // layer (store-seam aware, overlay edges included). Postgres-safe.
804
+ // ── Requirements management (server-side search over requirements_graph.json +
805
+ // operator overrides sidecar; see api/requirements.ts) ─────────────────────
806
+ const ReqQuerySchema = z.object({
807
+ q: z.string().optional(),
808
+ risk: z.enum(['risk', 'no-risk']).optional(),
809
+ category: z.enum(['functional', 'config-data']).optional(),
810
+ domain: z.string().optional(),
811
+ offset: z.coerce.number().int().min(0).default(0),
812
+ limit: z.coerce.number().int().min(1).max(200).default(50),
813
+ }).strict();
814
+ app.get(`${V}/repos/:id/requirements`, async (req, reply) => {
815
+ const { id } = req.params;
816
+ const repos = await adapter.listRepos();
817
+ const repo = repos.find((r) => r.id === id);
818
+ if (!repo)
819
+ return reply.code(404).send({ error: `Repo ${id} not found` });
820
+ const parsed = ReqQuerySchema.safeParse(req.query);
821
+ if (!parsed.success) {
822
+ return reply.code(400).send(invalidBody(parsed.error, 'Invalid query'));
823
+ }
824
+ const page = await listRequirements(repo, parsed.data);
825
+ if (page === null) {
826
+ return reply.code(404).send({ error: 'requirements_graph.json not generated for this repo yet' });
827
+ }
828
+ return page;
829
+ });
830
+ app.get(`${V}/repos/:id/requirements/:key`, async (req, reply) => {
831
+ const { id, key } = req.params;
832
+ const repos = await adapter.listRepos();
833
+ const repo = repos.find((r) => r.id === id);
834
+ if (!repo)
835
+ return reply.code(404).send({ error: `Repo ${id} not found` });
836
+ let decoded;
837
+ try {
838
+ decoded = decodeURIComponent(key);
839
+ }
840
+ catch {
841
+ return reply.code(400).send({ error: 'Malformed requirement key encoding' });
842
+ }
843
+ const detail = await getRequirement(repo, decoded);
844
+ if (detail === null)
845
+ return reply.code(404).send({ error: 'Requirement not found' });
846
+ return { requirement: detail };
847
+ });
848
+ const ReqPatchSchema = z
849
+ .object({
850
+ title: z.string().min(1).max(500).optional(),
851
+ notes: z.string().max(5000).optional(),
852
+ status: z.enum(['active', 'deprecated', 'review']).optional(),
853
+ risk: z.boolean().optional(),
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.
860
+ .refine((b) => Object.keys(b).length > 0, { message: 'empty patch' });
861
+ app.patch(`${V}/repos/:id/requirements/:key`, async (req, reply) => {
862
+ const { id, key } = req.params;
863
+ const repos = await adapter.listRepos();
864
+ const repo = repos.find((r) => r.id === id);
865
+ if (!repo)
866
+ return reply.code(404).send({ error: `Repo ${id} not found` });
867
+ const parsed = ReqPatchSchema.safeParse(req.body);
868
+ if (!parsed.success) {
869
+ return reply.code(400).send(invalidBody(parsed.error, 'Invalid patch'));
870
+ }
871
+ let decodedKey;
872
+ try {
873
+ decodedKey = decodeURIComponent(key);
874
+ }
875
+ catch {
876
+ return reply.code(400).send({ error: 'Malformed requirement key encoding' });
877
+ }
878
+ const detail = await patchRequirement(repo, decodedKey, parsed.data);
879
+ if (detail === null)
880
+ return reply.code(404).send({ error: 'Requirement not found' });
881
+ return { requirement: detail };
882
+ });
461
883
  app.get(`${V}/repos/:id/graph`, async (req, reply) => {
462
884
  const { id } = req.params;
463
885
  const repos = await adapter.listRepos();
464
886
  const repo = repos.find((r) => r.id === id);
465
887
  if (!repo)
466
888
  return reply.code(404).send({ error: `Repo ${id} not found` });
467
- const dbPath = join(repo.root_path, '.codegraph', 'estate.db');
889
+ const dbPath = codeGraphDb(repo);
468
890
  if (!existsSync(dbPath)) {
469
891
  return reply.send({ graph: null });
470
892
  }
471
893
  try {
472
894
  const settings = await adapter.getSettings();
473
- const nodeLimit = String(settings.graphNodeLimit);
474
- const { stdout } = await execFileAsync('wicked-estate', ['graph-view', '--limit', nodeLimit, '--db', dbPath], { timeout: 30_000, cwd: repo.root_path });
895
+ const q = req.query;
896
+ const parsedLimit = q.limit !== undefined ? Number.parseInt(q.limit, 10) : NaN;
897
+ const nodeLimit = String(Number.isFinite(parsedLimit) && parsedLimit > 0 && parsedLimit <= 1000
898
+ ? parsedLimit
899
+ : settings.graphNodeLimit);
900
+ const args = ['graph-view', '--limit', nodeLimit, '--db', dbPath];
901
+ // FOCUS (ego-graph) mode: seed the slice from one symbol and expand its
902
+ // neighbourhood — the navigation primitive (estate graph-view --focus).
903
+ if (q.focus !== undefined && q.focus.trim() !== '') {
904
+ args.push('--focus', q.focus.trim());
905
+ }
906
+ const { stdout } = await execCapped('wicked-estate', args, {
907
+ timeout: 30_000,
908
+ cwd: repo.root_path,
909
+ });
475
910
  const raw = JSON.parse(stdout);
476
911
  const fileCount = new Set(raw.nodes.map((n) => n.file)).size;
477
912
  return reply.send({
@@ -487,6 +922,31 @@ export function registerRoutes(app, adapter, gateCache) {
487
922
  }
488
923
  });
489
924
  // ── Git history (last 20 commits via git log) ─────────────────────────────
925
+ // ── Blast radius for a symbol (via wicked-estate blast-radius --json).
926
+ // Carries the honesty contract through: dependents PLUS the unresolved-call
927
+ // count — an empty dependents list must never read as "safe to change".
928
+ app.get(`${V}/repos/:id/graph/blast-radius`, async (req, reply) => {
929
+ const { id } = req.params;
930
+ const q = req.query;
931
+ if (q.name === undefined || q.name.trim() === '') {
932
+ return reply.code(400).send({ error: 'name query parameter required' });
933
+ }
934
+ const repos = await adapter.listRepos();
935
+ const repo = repos.find((r) => r.id === id);
936
+ if (!repo)
937
+ return reply.code(404).send({ error: `Repo ${id} not found` });
938
+ const dbPath = codeGraphDb(repo);
939
+ if (!existsSync(dbPath)) {
940
+ return reply.code(404).send({ error: 'Code graph not built for this repo yet' });
941
+ }
942
+ try {
943
+ const { stdout } = await execCapped('wicked-estate', ['blast-radius', q.name.trim(), '--db', dbPath, '--json'], { timeout: 30_000, cwd: repo.root_path });
944
+ return reply.send(JSON.parse(stdout));
945
+ }
946
+ catch (err) {
947
+ return reply.code(500).send({ error: message(err) });
948
+ }
949
+ });
490
950
  app.get(`${V}/repos/:id/git-history`, async (req, reply) => {
491
951
  const { id } = req.params;
492
952
  const repos = await adapter.listRepos();
@@ -494,7 +954,7 @@ export function registerRoutes(app, adapter, gateCache) {
494
954
  if (!repo)
495
955
  return reply.code(404).send({ error: `Repo ${id} not found` });
496
956
  try {
497
- 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 });
498
958
  const commits = stdout.trim().split('\n').filter(Boolean).map((line) => {
499
959
  const parts = line.split('\x1f');
500
960
  return {
@@ -526,7 +986,7 @@ export function registerRoutes(app, adapter, gateCache) {
526
986
  if (!repo)
527
987
  return reply.code(404).send({ error: `Repo ${id} not found` });
528
988
  try {
529
- 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 });
530
990
  // Output: " 42\tFull Name <email@example.com>"
531
991
  const contributors = stdout.trim().split('\n').filter(Boolean).slice(0, 10).map((line) => {
532
992
  const m = line.match(/^\s*(\d+)\s+(.+?)\s+<([^>]+)>/);
@@ -558,7 +1018,7 @@ export function registerRoutes(app, adapter, gateCache) {
558
1018
  app.post(`${V}/terminals`, async (req, reply) => {
559
1019
  const parsed = OpenTerminalSchema.safeParse(req.body);
560
1020
  if (!parsed.success) {
561
- 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'));
562
1022
  }
563
1023
  const b = parsed.data;
564
1024
  try {
@@ -575,7 +1035,7 @@ export function registerRoutes(app, adapter, gateCache) {
575
1035
  const { id } = req.params;
576
1036
  const parsed = ResizeTerminalSchema.safeParse(req.body);
577
1037
  if (!parsed.success) {
578
- 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'));
579
1039
  }
580
1040
  try {
581
1041
  const status = await adapter.resizeTerminal(id, parsed.data.cols, parsed.data.rows);