wicked-crew 0.4.0 → 0.6.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 (132) hide show
  1. package/dist/api/api-prefix.d.ts +13 -0
  2. package/dist/api/api-prefix.d.ts.map +1 -0
  3. package/dist/api/api-prefix.js +13 -0
  4. package/dist/api/api-prefix.js.map +1 -0
  5. package/dist/api/audit.d.ts +57 -0
  6. package/dist/api/audit.d.ts.map +1 -0
  7. package/dist/api/audit.js +115 -0
  8. package/dist/api/audit.js.map +1 -0
  9. package/dist/api/auth.d.ts +192 -0
  10. package/dist/api/auth.d.ts.map +1 -0
  11. package/dist/api/auth.js +515 -0
  12. package/dist/api/auth.js.map +1 -0
  13. package/dist/api/evidence.d.ts +11 -0
  14. package/dist/api/evidence.d.ts.map +1 -1
  15. package/dist/api/evidence.js +8 -3
  16. package/dist/api/evidence.js.map +1 -1
  17. package/dist/api/gate-cache.d.ts +8 -0
  18. package/dist/api/gate-cache.d.ts.map +1 -1
  19. package/dist/api/gate-cache.js +10 -0
  20. package/dist/api/gate-cache.js.map +1 -1
  21. package/dist/api/open-path.d.ts +18 -0
  22. package/dist/api/open-path.d.ts.map +1 -0
  23. package/dist/api/open-path.js +79 -0
  24. package/dist/api/open-path.js.map +1 -0
  25. package/dist/api/routes.d.ts +113 -1
  26. package/dist/api/routes.d.ts.map +1 -1
  27. package/dist/api/routes.js +516 -27
  28. package/dist/api/routes.js.map +1 -1
  29. package/dist/api/seat-health.d.ts +55 -0
  30. package/dist/api/seat-health.d.ts.map +1 -0
  31. package/dist/api/seat-health.js +273 -0
  32. package/dist/api/seat-health.js.map +1 -0
  33. package/dist/api/seat-signin.d.ts +27 -0
  34. package/dist/api/seat-signin.d.ts.map +1 -0
  35. package/dist/api/seat-signin.js +143 -0
  36. package/dist/api/seat-signin.js.map +1 -0
  37. package/dist/api/server.d.ts +111 -3
  38. package/dist/api/server.d.ts.map +1 -1
  39. package/dist/api/server.js +236 -11
  40. package/dist/api/server.js.map +1 -1
  41. package/dist/api/unit-output.d.ts +62 -0
  42. package/dist/api/unit-output.d.ts.map +1 -0
  43. package/dist/api/unit-output.js +108 -0
  44. package/dist/api/unit-output.js.map +1 -0
  45. package/dist/cli/index.js +97 -3
  46. package/dist/cli/index.js.map +1 -1
  47. package/dist/cli/mcp.d.ts +14 -0
  48. package/dist/cli/mcp.d.ts.map +1 -0
  49. package/dist/cli/mcp.js +119 -0
  50. package/dist/cli/mcp.js.map +1 -0
  51. package/dist/core/adapter.d.ts +106 -1
  52. package/dist/core/adapter.d.ts.map +1 -1
  53. package/dist/core/adapter.js +293 -12
  54. package/dist/core/adapter.js.map +1 -1
  55. package/dist/core/bridge-path.d.ts +17 -6
  56. package/dist/core/bridge-path.d.ts.map +1 -1
  57. package/dist/core/bridge-path.js +17 -6
  58. package/dist/core/bridge-path.js.map +1 -1
  59. package/dist/core/types.d.ts +23 -352
  60. package/dist/core/types.d.ts.map +1 -1
  61. package/dist/core/types.js +11 -4
  62. package/dist/core/types.js.map +1 -1
  63. package/dist/interactive/bridge-pool.d.ts +71 -0
  64. package/dist/interactive/bridge-pool.d.ts.map +1 -0
  65. package/dist/interactive/bridge-pool.js +187 -0
  66. package/dist/interactive/bridge-pool.js.map +1 -0
  67. package/dist/interactive/bridge-root.d.ts +36 -0
  68. package/dist/interactive/bridge-root.d.ts.map +1 -0
  69. package/dist/interactive/bridge-root.js +48 -0
  70. package/dist/interactive/bridge-root.js.map +1 -0
  71. package/dist/interactive/draft-events.d.ts +144 -0
  72. package/dist/interactive/draft-events.d.ts.map +1 -0
  73. package/dist/interactive/draft-events.js +490 -0
  74. package/dist/interactive/draft-events.js.map +1 -0
  75. package/dist/interactive/edit-events.d.ts +172 -0
  76. package/dist/interactive/edit-events.d.ts.map +1 -0
  77. package/dist/interactive/edit-events.js +530 -0
  78. package/dist/interactive/edit-events.js.map +1 -0
  79. package/dist/interactive/ledger.d.ts +39 -0
  80. package/dist/interactive/ledger.d.ts.map +1 -0
  81. package/dist/interactive/ledger.js +93 -0
  82. package/dist/interactive/ledger.js.map +1 -0
  83. package/dist/interactive/proxy-routes.d.ts +39 -0
  84. package/dist/interactive/proxy-routes.d.ts.map +1 -0
  85. package/dist/interactive/proxy-routes.js +189 -0
  86. package/dist/interactive/proxy-routes.js.map +1 -0
  87. package/dist/interactive/ws-relay.d.ts +85 -0
  88. package/dist/interactive/ws-relay.d.ts.map +1 -0
  89. package/dist/interactive/ws-relay.js +191 -0
  90. package/dist/interactive/ws-relay.js.map +1 -0
  91. package/dist/projects/activity.d.ts +29 -0
  92. package/dist/projects/activity.d.ts.map +1 -0
  93. package/dist/projects/activity.js +172 -0
  94. package/dist/projects/activity.js.map +1 -0
  95. package/dist/projects/charter.d.ts +28 -0
  96. package/dist/projects/charter.d.ts.map +1 -0
  97. package/dist/projects/charter.js +53 -0
  98. package/dist/projects/charter.js.map +1 -0
  99. package/dist/projects/events.d.ts +55 -0
  100. package/dist/projects/events.d.ts.map +1 -0
  101. package/dist/projects/events.js +141 -0
  102. package/dist/projects/events.js.map +1 -0
  103. package/dist/projects/membership-index.d.ts +25 -0
  104. package/dist/projects/membership-index.d.ts.map +1 -0
  105. package/dist/projects/membership-index.js +46 -0
  106. package/dist/projects/membership-index.js.map +1 -0
  107. package/dist/projects/routes.d.ts +90 -0
  108. package/dist/projects/routes.d.ts.map +1 -0
  109. package/dist/projects/routes.js +388 -0
  110. package/dist/projects/routes.js.map +1 -0
  111. package/dist/projects/settings.d.ts +32 -0
  112. package/dist/projects/settings.d.ts.map +1 -0
  113. package/dist/projects/settings.js +64 -0
  114. package/dist/projects/settings.js.map +1 -0
  115. package/dist/qe/acceptance.d.ts +137 -0
  116. package/dist/qe/acceptance.d.ts.map +1 -0
  117. package/dist/qe/acceptance.js +249 -0
  118. package/dist/qe/acceptance.js.map +1 -0
  119. package/dist/qe/gate-events.d.ts +111 -0
  120. package/dist/qe/gate-events.d.ts.map +1 -0
  121. package/dist/qe/gate-events.js +168 -0
  122. package/dist/qe/gate-events.js.map +1 -0
  123. package/dist/qe/ledger.d.ts +100 -0
  124. package/dist/qe/ledger.d.ts.map +1 -0
  125. package/dist/qe/ledger.js +154 -0
  126. package/dist/qe/ledger.js.map +1 -0
  127. package/dist/studio/assets/index-CCwXa1cn.js +428 -0
  128. package/dist/studio/assets/index-HWxo0h41.css +32 -0
  129. package/dist/studio/index.html +2 -2
  130. package/package.json +10 -4
  131. package/dist/studio/assets/index-Ci_R6ARr.js +0 -423
  132. package/dist/studio/assets/index-Dio_c1Q3.css +0 -32
@@ -3,13 +3,31 @@ import { listRequirements, getRequirement, patchRequirement } from './requiremen
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { readFileSync, existsSync } from 'node:fs';
5
5
  import { promises as fsp } from 'node:fs';
6
- import { join } from 'node:path';
6
+ import { isAbsolute, join, resolve } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
- import { ChatUnsupportedError, CoreAdapter, ElicitationUnsupportedError } from '../core/adapter.js';
8
+ import { ChatUnsupportedError, CoreAdapter, ElicitationUnsupportedError, humanGatePhaseIds } from '../core/adapter.js';
9
9
  import { codeGraphDb, requirementsGraph } from '../core/repoPaths.js';
10
- import { buildEvidenceBundle, evidenceFilename } from './evidence.js';
10
+ import { QeGateCache } from '../qe/gate-events.js';
11
+ import { buildAcceptanceView, resolveRunWorkflow } from '../qe/acceptance.js';
12
+ import { buildEvidenceBundle, coreUnitId, evidenceFilename } from './evidence.js';
13
+ import { outputUnavailableReason, resolveUnit, unitKeysFor } from './unit-output.js';
11
14
  import { execCapped, ExecOutputTooLarge } from '../core/exec.js';
12
- const V = '/api/v1';
15
+ import { SeatHealthTracker } from './seat-health.js';
16
+ import { applyWorkerConfigRoot, signedInHeuristic } from './seat-signin.js';
17
+ import { isInsideRoot, openWithSystemDefault } from './open-path.js';
18
+ import { registerProjectRoutes } from '../projects/routes.js';
19
+ import { ProjectSettingsStore } from '../projects/settings.js';
20
+ import { InteractiveBridgePool } from '../interactive/bridge-pool.js';
21
+ import { registerInteractiveProxy } from '../interactive/proxy-routes.js';
22
+ import { MembershipIndex } from '../projects/membership-index.js';
23
+ import { MEMBERSHIP_ATTACHED, membershipAttachedKey } from '../projects/events.js';
24
+ import { AuditLog } from './audit.js';
25
+ import { LOCAL_ACTOR } from './auth.js';
26
+ // Re-exported so existing `import { API_PREFIX } from './routes.js'` callers keep working; the
27
+ // value lives in the leaf module api-prefix.ts to keep unit-output.ts out of this file's cycle.
28
+ export { API_PREFIX } from './api-prefix.js';
29
+ import { API_PREFIX } from './api-prefix.js';
30
+ const V = API_PREFIX;
13
31
  // Daemon version reported by /health — read from package.json so it never drifts
14
32
  // from the shipped version across releases. Resolves the package root from the
15
33
  // compiled module location (dist/api/routes.js → ../../package.json) and works the
@@ -88,7 +106,10 @@ const RegisterRepoSchema = z
88
106
  * schema does not accept — so `.strict()` is what turns that trip into a 400 instead of an
89
107
  * unworkflowed run reported as `201`.
90
108
  */
91
- const LaunchSchema = z.object({
109
+ // The request-body schemas below are exported so `tests/wire-contract.test.ts` can prove, at
110
+ // compile time, that every body the published contract (`wicked-crew-api-types`) lets a client
111
+ // send is a body these schemas accept — the request-direction half of the drift guard (task #84).
112
+ export const LaunchSchema = z.object({
92
113
  problem: z.string().min(1),
93
114
  sessionId: z.string().min(1).optional(),
94
115
  clisJson: z.string().min(1).optional(),
@@ -96,8 +117,11 @@ const LaunchSchema = z.object({
96
117
  humanConfirm: z.string().min(1).optional(),
97
118
  repoRef: z.string().min(1).optional(),
98
119
  workflow: z.string().min(1).optional(),
120
+ /** DES-PROJECT-001 §2.2 — file the run into a project; membership attaches atomically with
121
+ * the launch record. Unknown/archived ⇒ the launch fails (never a silent unfiled run). */
122
+ projectId: z.string().min(1).optional(),
99
123
  }).strict();
100
- const GateSchema = z.object({
124
+ export const GateSchema = z.object({
101
125
  approve: z.boolean(),
102
126
  amend: z.string().optional(),
103
127
  }).strict();
@@ -106,7 +130,7 @@ const InjectSchema = z.object({
106
130
  /** `"all"` broadcasts to every active worker; any other value is a CLI key. */
107
131
  target: z.string().min(1).default('all'),
108
132
  }).strict();
109
- const OpenTerminalSchema = z.object({
133
+ export const OpenTerminalSchema = z.object({
110
134
  cwd: z.string().min(1),
111
135
  cmd: z.array(z.string().min(1)).min(1).optional(),
112
136
  cols: z.number().int().positive(),
@@ -119,16 +143,81 @@ const ResizeTerminalSchema = z.object({
119
143
  cols: z.number().int().positive(),
120
144
  rows: z.number().int().positive(),
121
145
  }).strict();
146
+ /** `POST /open` (crew#273) — the studio Files tab's "open with the OS default app" body. */
147
+ export const OpenPathSchema = z.object({
148
+ path: z.string().min(1),
149
+ runId: z.string().min(1).optional(),
150
+ }).strict();
122
151
  /**
123
152
  * The daemon REST surface. Every endpoint is a thin wrapper over one adapter /
124
153
  * core-ts call (DES-STUDIO-001 §2). `session`/`phase` nouns are now `run`/`unit`.
125
154
  */
126
- export function registerRoutes(app, adapter, gateCache, elicitationCache) {
155
+ export function registerRoutes(app, adapter, gateCache, elicitationCache,
156
+ // Defaulted so a caller that never arms the bus seam (tests drive this
157
+ // function directly) gets the same behavior as an unarmed daemon: an empty
158
+ // cache, `busEvent: null`, and the lazy ledger read doing all the work.
159
+ qeGateEvents = new QeGateCache(),
160
+ // Defaulted for the same reason: tests that drive this function directly get
161
+ // the projects surface with no bus (events skipped) and a fresh index.
162
+ projects = { bus: null, index: new MembershipIndex(), log: () => undefined },
163
+ // Defaulted likewise — to a NOOP trail, deliberately: a directly-driven
164
+ // route set (unit tests) must never write the operator's real
165
+ // ~/.wicked-crew/audit.log or leave appends pending after close. The real
166
+ // trail always arrives from `createServer`.
167
+ security = { audit: AuditLog.noop(), authMode: 'off' },
168
+ // Defaulted likewise: a directly-driven route set gets a fresh (all-active) health map and the
169
+ // real OS opener — tests inject both through this seam.
170
+ runtime = {}) {
171
+ const { audit } = security;
172
+ const seatHealth = runtime.seatHealth ?? new SeatHealthTracker();
173
+ const openWithOs = runtime.openWithOs ?? openWithSystemDefault;
174
+ const signedIn = runtime.signedIn ?? signedInHeuristic;
175
+ // Resolved ONCE and shared by the project routes (which read/write `interactiveRoot`) and the
176
+ // interactive proxy (which resolves a root from it) — two stores would let a PATCH land in one
177
+ // and the proxy keep reading the other.
178
+ const projectSettings = projects.settings ?? new ProjectSettingsStore();
179
+ // `req.actor` is pinned by the auth hooks `createServer` installs; a caller
180
+ // driving this function directly (tests) has no hooks, so downstream code
181
+ // still gets the ONE actor shape via this accessor.
182
+ const actorOf = (req) => req.actor ?? LOCAL_ACTOR;
127
183
  // Liveness — also proves the actor + event pump are up.
128
184
  app.get(`${V}/health`, async () => {
129
185
  const ping = await adapter.ping();
130
186
  return { status: 'ok', version: PKG_VERSION, ping };
131
187
  });
188
+ // Who am I talking to the daemon as? (task #88). In local mode this is
189
+ // always the implicit full-trust local actor; in required mode it names the
190
+ // token's actor — the cheap probe a skin uses to decide what to render.
191
+ app.get(`${V}/whoami`, async (req) => ({
192
+ actor: actorOf(req),
193
+ authMode: security.authMode,
194
+ }));
195
+ // The actor audit trail (task #88): who launched/steered/governed what.
196
+ // Read-only (observer trust); newest first; `?runId=` / `?action=` / `?limit=`.
197
+ app.get(`${V}/audit`, async (req, reply) => {
198
+ const q = req.query;
199
+ const first = (v) => (Array.isArray(v) ? v[0] : v)?.trim() || undefined;
200
+ const runId = first(q.runId);
201
+ const action = first(q.action);
202
+ const limitRaw = first(q.limit);
203
+ // Reject partial-numeric strings like "10abc" — parseInt accepts those,
204
+ // Number() is strict and returns NaN for them (Copilot, #250).
205
+ const limit = limitRaw !== undefined ? Number(limitRaw) : undefined;
206
+ if (limitRaw !== undefined && (!Number.isFinite(limit) || limit < 1 || !Number.isInteger(limit))) {
207
+ return reply.code(400).send({ error: '`limit` must be a positive integer' });
208
+ }
209
+ try {
210
+ const entries = await audit.read({
211
+ ...(runId !== undefined ? { runId } : {}),
212
+ ...(action !== undefined ? { action } : {}),
213
+ ...(limit !== undefined ? { limit } : {}),
214
+ });
215
+ return { entries };
216
+ }
217
+ catch (err) {
218
+ return reply.code(500).send({ error: message(err) });
219
+ }
220
+ });
132
221
  // Report the actually-bound port/host (honours --port / CREW_PORT / port 0).
133
222
  app.get(`${V}/config`, async () => {
134
223
  const addr = app.server.address();
@@ -136,8 +225,77 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
136
225
  const host = typeof addr === 'object' && addr ? addr.address : '127.0.0.1';
137
226
  return { port, host };
138
227
  });
139
- // The council seats for the launch form (static production roster).
140
- app.get(`${V}/roster`, async () => ({ roster: CoreAdapter.roster() }));
228
+ // The council seats for the launch form (static production roster), each carrying its RUNTIME
229
+ // health (crew#274). The roster is declarative — every configured seat is listed — and `health`
230
+ // is what the platform has observed: default active with no message; inactive + the error
231
+ // excerpt after a seat-level failure, until an ok output or the recovery probe flips it back.
232
+ // Existing fields ride through verbatim (the seat still round-trips into `clisJson` on launch)
233
+ // — the spread is deliberately NOT a field whitelist, which is what lets the engine's
234
+ // `login_invocation` (seat sign-in, wicked-core PR#278) pass through untouched. Each seat also
235
+ // gains `signed_in`: the cheap file/env presence heuristic, computed against the LIVE
236
+ // `WICKED_WORKER_HOME` env — the same value the engine reads at the next worker spawn, kept
237
+ // current by `applyWorkerConfigRoot` at boot and on every settings change.
238
+ app.get(`${V}/roster`, async () => {
239
+ const workerRoot = process.env['WICKED_WORKER_HOME'];
240
+ return {
241
+ roster: CoreAdapter.roster().map((seat) => ({
242
+ ...seat,
243
+ health: seatHealth.healthFor(String(seat.key)),
244
+ signed_in: signedIn(String(seat.key), workerRoot === '' ? undefined : workerRoot),
245
+ })),
246
+ };
247
+ });
248
+ // Open a file/folder with the OS default application (crew#273) — the studio Files tab's
249
+ // click-to-open. The open MUST happen daemon-side (the SPA cannot spawn a process), which is
250
+ // why the path is validated first: absolute, and inside one of the caller-visible roots — the
251
+ // run's workdir + extra write roots (when `runId` is given) or a registered repo root. The
252
+ // run's QE evidence/decisions dirs (`.wicked-qe`/`.wicked-testing`) live under the repo root,
253
+ // so the repo-root rule covers them. Never an arbitrary path.
254
+ app.post(`${V}/open`, async (req, reply) => {
255
+ const parsed = OpenPathSchema.safeParse(req.body);
256
+ if (!parsed.success) {
257
+ return reply.code(400).send(invalidBody(parsed.error, 'Invalid open request'));
258
+ }
259
+ const { path: rawPath, runId } = parsed.data;
260
+ if (!isAbsolute(rawPath)) {
261
+ return reply.code(400).send({ error: '`path` must be an absolute path' });
262
+ }
263
+ const target = resolve(rawPath);
264
+ const roots = [];
265
+ try {
266
+ if (runId !== undefined) {
267
+ const views = await adapter.sessionsDetail();
268
+ const view = views.find((v) => v.session.id === runId);
269
+ if (view === undefined) {
270
+ return reply.code(404).send({ error: `unknown run: ${runId}` });
271
+ }
272
+ const workdir = view.session.workdir;
273
+ if (typeof workdir === 'string' && workdir.length > 0)
274
+ roots.push(workdir);
275
+ for (const r of view.session.extra_write_roots ?? []) {
276
+ if (typeof r === 'string' && r.length > 0)
277
+ roots.push(r);
278
+ }
279
+ }
280
+ for (const repo of await adapter.listRepos())
281
+ roots.push(repo.root_path);
282
+ }
283
+ catch (err) {
284
+ return reply.code(500).send({ error: message(err) });
285
+ }
286
+ if (!roots.some((root) => isInsideRoot(root, target))) {
287
+ return reply.code(403).send({
288
+ error: "path is outside every allowed root (the run's workdir/write roots and the registered repos)",
289
+ });
290
+ }
291
+ try {
292
+ await openWithOs(target);
293
+ }
294
+ catch (err) {
295
+ return reply.code(502).send({ error: `could not open ${target}: ${message(err)}` });
296
+ }
297
+ return { status: 'opened' };
298
+ });
141
299
  // Registered repos → target-repo picker.
142
300
  app.get(`${V}/repos`, async () => ({ repos: await adapter.listRepos() }));
143
301
  app.post(`${V}/repos`, async (req, reply) => {
@@ -210,23 +368,116 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
210
368
  input.repoRef = b.repoRef;
211
369
  if (b.workflow !== undefined)
212
370
  input.workflow = b.workflow;
371
+ if (b.projectId !== undefined)
372
+ input.projectId = b.projectId;
213
373
  try {
214
374
  const runId = await adapter.launchRun(input);
375
+ // Who launched it — the engine's LaunchOptions carries no actor field
376
+ // (checked, wicked-core-ts 0.6.0), so the crew-side trail is the system
377
+ // of record for run provenance (task #88).
378
+ audit.record('run.launched', actorOf(req), {
379
+ runId,
380
+ detail: {
381
+ ...(b.workflow !== undefined ? { workflow: b.workflow } : {}),
382
+ ...(b.repoRef !== undefined ? { repoRef: b.repoRef } : {}),
383
+ ...(b.projectId !== undefined ? { projectId: b.projectId } : {}),
384
+ },
385
+ });
386
+ if (b.projectId !== undefined) {
387
+ // The engine attached the crew.run membership ATOMICALLY with the launch record
388
+ // (DES-PROJECT-001 §2.2) — this is the post-commit half: tag future /ws frames and
389
+ // emit the membership event (auto-attach at launch is an attach, §4).
390
+ projects.index.set(runId, b.projectId);
391
+ projects.bus?.emit(MEMBERSHIP_ATTACHED,
392
+ // The AUTHENTICATED actor id, not a caller-supplied string — locked
393
+ // decision #6 replaces spoofable actor strings on the event surface.
394
+ { project_id: b.projectId, member: { kind: 'crew.run', ref: runId }, actor: actorOf(req).id }, membershipAttachedKey(b.projectId, 'crew.run', runId, Date.now()));
395
+ }
215
396
  return reply.code(201).send({ runId });
216
397
  }
217
398
  catch (err) {
218
399
  const msg = message(err);
400
+ // An unknown/archived project is a state conflict on a real resource, not a malformed
401
+ // request: 404/409 per the projects error mapping; anything else keeps the launch 400/409.
402
+ if (b.projectId !== undefined && /project.*not registered/i.test(msg)) {
403
+ return reply.code(404).send({ error: msg });
404
+ }
405
+ if (b.projectId !== undefined && /archived|'default'|synthesized/i.test(msg)) {
406
+ return reply.code(409).send({ error: msg });
407
+ }
219
408
  const busy = /busy|in flight|already/i.test(msg);
220
409
  return reply.code(busy ? 409 : 400).send({ error: msg });
221
410
  }
222
411
  });
223
412
  // Run list (replaces GET /sessions). Actionable-first; reconciles the gate and elicitation caches
224
413
  // so that terminal-run entries are pruned even when their terminal CoreEvent was missed.
225
- app.get(`${V}/runs`, async () => {
414
+ app.get(`${V}/runs`, async (req) => {
226
415
  const views = await adapter.sessionsDetail();
227
416
  gateCache.reconcile(views);
228
417
  elicitationCache.reconcile(views);
229
- return { runs: sortActionableFirst(views) };
418
+ // Archived runs are WRITTEN OFF (crew#265): excluded from the default view so finished
419
+ // history doesn't drown live signal, returned in full with `?include=archived`. The caches
420
+ // above reconcile over the COMPLETE set either way — a gate on an archived run must still
421
+ // resolve, not leak.
422
+ // Fastify parses a REPEATED query param as string[] — normalize so `?include=archived`
423
+ // and `?include=archived&include=archived` behave identically (Copilot).
424
+ const { include } = req.query;
425
+ const includeArchived = (Array.isArray(include) ? include : [include]).includes('archived');
426
+ const visible = includeArchived
427
+ ? views
428
+ : views.filter((v) => v.session.archived_at == null);
429
+ return { runs: sortActionableFirst(visible) };
430
+ });
431
+ // ── Run archival (crew#265) — write-off, not delete ────────────────────────
432
+ const ArchiveSchema = z.object({
433
+ archived: z.boolean(),
434
+ note: z.string().max(500).optional(),
435
+ }).strict();
436
+ app.post(`${V}/runs/:id/archive`, async (req, reply) => {
437
+ const { id } = req.params;
438
+ const parsed = ArchiveSchema.safeParse(req.body ?? {});
439
+ if (!parsed.success) {
440
+ return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
441
+ }
442
+ try {
443
+ const found = await adapter.archiveRun(id, parsed.data.archived, parsed.data.note);
444
+ if (!found)
445
+ return reply.code(404).send({ error: 'Run not found' });
446
+ return { runId: id, archived: parsed.data.archived };
447
+ }
448
+ catch (e) {
449
+ const msg = e instanceof Error ? e.message : String(e);
450
+ // The engine names a NON-terminal status ("run X is Executing — only a terminal run…"):
451
+ // a state conflict on a real resource, not a bad request (write-off must never hide
452
+ // live work). Anything else — including the old-addon guard — is a real 500.
453
+ if (/only a terminal run/i.test(msg))
454
+ return reply.code(409).send({ error: msg });
455
+ return reply.code(500).send({ error: msg });
456
+ }
457
+ });
458
+ // Bulk write-off for campaign backlogs — explicit ids only, never implicit age selection.
459
+ const BulkArchiveSchema = z.object({
460
+ ids: z.array(z.string().min(1)).min(1).max(200),
461
+ note: z.string().max(500).optional(),
462
+ }).strict();
463
+ app.post(`${V}/runs/archive`, async (req, reply) => {
464
+ const parsed = BulkArchiveSchema.safeParse(req.body ?? {});
465
+ if (!parsed.success) {
466
+ return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
467
+ }
468
+ // Per-id outcomes rather than all-or-nothing: a batch of 45 with one live run in it
469
+ // should archive 44 and NAME the refusal, not roll back the write-off.
470
+ const results = [];
471
+ for (const id of parsed.data.ids) {
472
+ try {
473
+ const found = await adapter.archiveRun(id, true, parsed.data.note);
474
+ results.push(found ? { id, ok: true } : { id, ok: false, error: 'not found' });
475
+ }
476
+ catch (e) {
477
+ results.push({ id, ok: false, error: e instanceof Error ? e.message : String(e) });
478
+ }
479
+ }
480
+ return { results, archived: results.filter((r) => r.ok).length };
230
481
  });
231
482
  // One run's detail.
232
483
  app.get(`${V}/runs/:id`, async (req, reply) => {
@@ -237,9 +488,6 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
237
488
  return reply.code(404).send({ error: 'Run not found' });
238
489
  return { run };
239
490
  });
240
- // A unit's captured transcript. unitKey is the suffix after `<run>:` — `u<ord>` for free-text
241
- // runs, `<phase_id>` for workflow runs (e.g. "survey", "coverage"). Strip any accidental
242
- // `<id>:` prefix so both `survey` and `run-1:survey` resolve to the same key.
243
491
  // ── Chat sessions (crew#165): warm ACP seat pool + group fan-out (core#134) ──
244
492
  // A chat is NOT a run: no council, no gates, no units. Seats warm on open;
245
493
  // messages fan out to warm seats; replies stream on /ws as chatDelta/chatReply.
@@ -247,6 +495,8 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
247
495
  chatId: z.string().min(1).max(128).regex(/^[A-Za-z0-9._-]+$/).optional(),
248
496
  clis: z.array(z.string().min(1)).min(1).max(8).optional(),
249
497
  repoRef: z.string().optional(),
498
+ /** DES-PROJECT-001 §2.2 — file the chat into a project (`crew.chat` membership). */
499
+ projectId: z.string().min(1).optional(),
250
500
  }).strict();
251
501
  app.post(`${V}/chats`, async (req, reply) => {
252
502
  const parsed = ChatOpenSchema.safeParse(req.body ?? {});
@@ -263,12 +513,48 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
263
513
  return reply.code(404).send({ error: `Repo ${b.repoRef} not found` });
264
514
  cwd = repo.root_path;
265
515
  }
516
+ // Validate the project BEFORE opening seats: a chat has no launch record for the engine to
517
+ // attach against atomically (chats are an in-memory seat pool), so the route validates
518
+ // up-front and attaches right after open — the one non-atomic attach, documented in the ADR
519
+ // changelog. Fail here and no seats were warmed for a filing that could never happen.
520
+ if (b.projectId !== undefined) {
521
+ try {
522
+ const project = await adapter.projectGet(b.projectId);
523
+ if (project === null) {
524
+ return reply.code(404).send({ error: `Project ${b.projectId} not found` });
525
+ }
526
+ if (project.status === 'archived') {
527
+ return reply
528
+ .code(409)
529
+ .send({ error: `project ${b.projectId} is archived and blocks new attachments` });
530
+ }
531
+ }
532
+ catch (err) {
533
+ return reply.code(501).send({ error: message(err) });
534
+ }
535
+ }
266
536
  const clis = b.clis ??
267
537
  CoreAdapter.roster()
268
538
  .map((s) => s.key)
269
539
  .filter((k) => typeof k === 'string');
270
540
  try {
271
541
  const seats = await adapter.chatOpen(chatId, clis, cwd);
542
+ if (b.projectId !== undefined) {
543
+ try {
544
+ const { member, created } = await adapter.projectMemberAttach(b.projectId, 'crew.chat', chatId);
545
+ if (created) {
546
+ projects.index.set(chatId, b.projectId);
547
+ projects.bus?.emit(MEMBERSHIP_ATTACHED, { project_id: b.projectId, member: { kind: 'crew.chat', ref: chatId }, actor: actorOf(req).id }, membershipAttachedKey(b.projectId, 'crew.chat', chatId, member.attached_at));
548
+ }
549
+ }
550
+ catch (err) {
551
+ // The chat is open and usable; the filing failed. Say so instead of failing the open —
552
+ // the caller can re-attach via POST /projects/:id/members.
553
+ return reply
554
+ .code(201)
555
+ .send({ chatId, seats, projectAttachError: message(err) });
556
+ }
557
+ }
272
558
  return reply.code(201).send({ chatId, seats });
273
559
  }
274
560
  catch (err) {
@@ -333,11 +619,33 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
333
619
  return reply.code(400).send({ error: message(err) });
334
620
  }
335
621
  });
622
+ // A unit's captured transcript. `unitKey` accepts the fully-qualified id (`run-1:survey`),
623
+ // the id suffix (`survey`, `u3`), or the ordinal (`u3`, `3`) — see `resolveUnit`.
624
+ //
625
+ // A `null` body is never bare here. An unknown run or an unknown key is a 404 that names the
626
+ // keys this run does have; a unit that exists but has no stored transcript answers 200 with
627
+ // `outputUnavailable` saying WHICH cause applies — denied, not finished, or a store that
628
+ // disagrees with itself. It previously answered `200 {"output": null}` to all four, so the
629
+ // failed unit an operator opens during triage was the one that told them nothing (FINDING-006).
336
630
  app.get(`${V}/runs/:id/units/:unitKey/output`, async (req, reply) => {
337
631
  const { id, unitKey } = req.params;
338
- const suffix = unitKey.startsWith(`${id}:`) ? unitKey.slice(id.length + 1) : unitKey;
339
- const output = await adapter.workOutput(`${id}:${suffix}`);
340
- return reply.send({ output });
632
+ const views = await adapter.sessionsDetail();
633
+ const run = views.find((v) => v.session.id === id);
634
+ if (!run)
635
+ return reply.code(404).send({ error: 'Run not found' });
636
+ const unit = resolveUnit(run, unitKey);
637
+ if (!unit) {
638
+ return reply.code(404).send({
639
+ error: `Unit '${unitKey}' not found in run ${id}`,
640
+ units: unitKeysFor(run),
641
+ });
642
+ }
643
+ // Keyed off the unit RECORD (the same derivation the evidence bundle uses), not off the
644
+ // caller's path segment — the two disagreeing is what made a resolvable unit unreadable.
645
+ const output = await adapter.workOutput(coreUnitId(id, unit));
646
+ if (output !== null)
647
+ return reply.send({ output });
648
+ return reply.send({ output: null, outputUnavailable: outputUnavailableReason(unit) });
341
649
  });
342
650
  // The whole run as one auditable JSON attachment: the run, its units (each with
343
651
  // the captured transcript), and the decision trail read back from core's durable
@@ -353,6 +661,44 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
353
661
  .header('Content-Disposition', `attachment; filename="${evidenceFilename(id)}"`)
354
662
  .send(bundle);
355
663
  });
664
+ // ── Acceptance gate read (Phase 6a) ─────────────────────────────────────────
665
+ // The QE evidence ledger's verdict + manifest for THIS run's repo, plus the
666
+ // gate's deny-dominates resolution of the workflow's acceptance requirement.
667
+ // Sits beside `/runs/:id/evidence` deliberately: evidence is what the RUN
668
+ // recorded about itself; acceptance is what the QE pipeline recorded about
669
+ // the repo the run worked on — two different systems of record, two routes.
670
+ //
671
+ // Always a 200 for a known run: "no ledger", "no verdict" and "FAIL" are
672
+ // real answers about the gate (each a deny with its own reason), not errors
673
+ // in the request. Only an unknown run 404s. `?qeRun=<id>` pins the read to
674
+ // one QE run's newest verdict instead of the repo's newest overall.
675
+ app.get(`${V}/runs/:id/acceptance`, async (req, reply) => {
676
+ const { id } = req.params;
677
+ const q = req.query;
678
+ const qeRunRaw = Array.isArray(q.qeRun) ? q.qeRun[0] : q.qeRun;
679
+ const qeRunId = qeRunRaw?.trim();
680
+ const views = await adapter.sessionsDetail();
681
+ const run = views.find((v) => v.session.id === id);
682
+ if (!run)
683
+ return reply.code(404).send({ error: 'Run not found' });
684
+ // `sessionsDetail()` patches workflow_id back to the definition name for
685
+ // BUILT-INS; runs of user-registered workflows still carry the instance id,
686
+ // so resolve by phase sequence over the full registry. A free-text run
687
+ // resolves to no workflow, which reads as "declares no requirement".
688
+ const workflow = resolveRunWorkflow(run, adapter.listWorkflows());
689
+ let repo = null;
690
+ if (run.session.repo_ref !== null) {
691
+ const repos = await adapter.listRepos();
692
+ repo = repos.find((r) => r.id === run.session.repo_ref) ?? null;
693
+ }
694
+ return buildAcceptanceView({
695
+ runId: id,
696
+ repo,
697
+ workflow,
698
+ gateEvents: qeGateEvents,
699
+ ...(qeRunId !== undefined && qeRunId !== '' ? { qeRunId } : {}),
700
+ });
701
+ });
356
702
  // The steering gate (§11.1). approve+amend = approve-with-steer; approve:false = reject (cancels).
357
703
  app.post(`${V}/runs/:id/gate`, async (req, reply) => {
358
704
  const { id } = req.params;
@@ -371,6 +717,17 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
371
717
  }
372
718
  try {
373
719
  const status = await adapter.confirmGate(id, parsed.data.approve, parsed.data.amend);
720
+ // WHO approved/rejected — the gate-decision audit (task #88). The engine
721
+ // records THAT the gate resolved (interaction_requests / gateDecided);
722
+ // only this HTTP layer knows the authenticated principal behind it.
723
+ audit.record('gate.decided', actorOf(req), {
724
+ runId: id,
725
+ detail: {
726
+ approve: parsed.data.approve,
727
+ ...(parsed.data.amend !== undefined ? { amend: parsed.data.amend } : {}),
728
+ status,
729
+ },
730
+ });
374
731
  return reply.send({ status });
375
732
  }
376
733
  catch (err) {
@@ -386,6 +743,7 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
386
743
  return reply.code(404).send({ error: 'Run not found' });
387
744
  try {
388
745
  const status = await adapter.cancelRun(id);
746
+ audit.record('run.cancelled', actorOf(req), { runId: id, detail: { status } });
389
747
  return reply.send({ status });
390
748
  }
391
749
  catch (err) {
@@ -401,9 +759,14 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
401
759
  if (!run)
402
760
  return reply.code(404).send({ error: 'Run not found' });
403
761
  try {
404
- const status = run.session.status === 'awaiting_human'
405
- ? await adapter.confirmGate(id, true)
406
- : await adapter.resumeRun(id);
762
+ const gated = run.session.status === 'awaiting_human';
763
+ const status = gated ? await adapter.confirmGate(id, true) : await adapter.resumeRun(id);
764
+ // A resume of a gated run IS a gate approval — audit it as one, so the
765
+ // "who approved" trail has no side door (task #88).
766
+ audit.record(gated ? 'gate.decided' : 'run.resumed', actorOf(req), {
767
+ runId: id,
768
+ detail: gated ? { approve: true, via: 'resume', status } : { status },
769
+ });
407
770
  return reply.send({ status });
408
771
  }
409
772
  catch (err) {
@@ -424,6 +787,7 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
424
787
  return reply.code(404).send({ error: 'Run not found' });
425
788
  try {
426
789
  await adapter.injectWorkerMessage(id, parsed.data.message, parsed.data.target);
790
+ audit.record('run.injected', actorOf(req), { runId: id, detail: { target: parsed.data.target } });
427
791
  return reply.send({ status: 'ok' });
428
792
  }
429
793
  catch (err) {
@@ -457,6 +821,28 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
457
821
  if (run.session.status !== 'awaiting_human') {
458
822
  return reply.code(404).send({ error: 'No open gate for this run' });
459
823
  }
824
+ // DURABLE TRUTH FIRST (DES-PROJECT-001 §5.3): the engine persists the open prompt in
825
+ // `interaction_requests`, written in the same transaction as the `awaiting_human` pause —
826
+ // so a daemon restart reads it back directly instead of replaying the event log. The cache
827
+ // adopts the row (latency layer over durable truth — its comments finally true). The replay
828
+ // below stays as the FALLBACK, not just for engines predating the binding: a run parked
829
+ // BEFORE the engine grew the table is awaiting_human with no row, and answering 404 there
830
+ // would re-open FINDING-051 for exactly the runs mid-upgrade. Row → serve it; no row →
831
+ // fall through and let the log speak.
832
+ const durableRows = typeof adapter.interactionRequests === 'function'
833
+ ? await adapter.interactionRequests(id, 'open')
834
+ : null;
835
+ const durableGate = durableRows?.find((r) => r.kind === 'gate');
836
+ if (durableGate !== undefined) {
837
+ const entry = {
838
+ ord: durableGate.ord ?? 0,
839
+ prompt: durableGate.prompt,
840
+ lifecycle: 'open',
841
+ receivedAt: new Date(durableGate.created_at).toISOString(),
842
+ };
843
+ gateCache.adopt(id, entry);
844
+ return { runId: id, ...entry };
845
+ }
460
846
  const events = await adapter.runEvents(id);
461
847
  if (events === null) {
462
848
  // Now — and only now — 503 is the honest answer: this run really is holding for a human, and
@@ -491,6 +877,25 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
491
877
  const ids = await adapter.sessions();
492
878
  if (!ids.includes(id))
493
879
  return reply.code(404).send({ error: 'Run not found' });
880
+ // Durable probe (DES-PROJECT-001 §5.3): `interaction_requests` reserves kind `elicitation`.
881
+ // The engine writes no elicitation rows yet (its elicitation surface is future work — the
882
+ // resolve path still answers 501), so this read is empty today; it exists so the cache is
883
+ // STRUCTURALLY a latency layer, and the day the engine writes the rows, restart survival
884
+ // holds here exactly as it does for gates, with no route change. Guarded like `runEvents`:
885
+ // a partial-stub adapter (tests) or a pre-0.6.0 addon simply has no durable half.
886
+ const durable = typeof adapter.interactionRequests === 'function'
887
+ ? await adapter.interactionRequests(id, 'open')
888
+ : null;
889
+ const pending = durable?.find((r) => r.kind === 'elicitation');
890
+ if (pending !== undefined) {
891
+ return {
892
+ runId: id,
893
+ elicitationId: pending.id,
894
+ message: pending.prompt,
895
+ options: null,
896
+ receivedAt: new Date(pending.created_at).toISOString(),
897
+ };
898
+ }
494
899
  return reply.code(404).send({ error: 'No pending elicitation for this run' });
495
900
  });
496
901
  const ElicitationRespondSchema = z
@@ -564,6 +969,10 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
564
969
  return reply.code(code).send({ error: message(err) });
565
970
  }
566
971
  // 7. Done.
972
+ audit.record('elicitation.resolved', actorOf(req), {
973
+ runId: id,
974
+ detail: { elicitationId: taken.entry.elicitationId, action: body.action },
975
+ });
567
976
  return { status: 'resolved' };
568
977
  });
569
978
  // The durable history of one run (FINDING-057).
@@ -626,14 +1035,60 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
626
1035
  const claims = await adapter.listConformanceClaims();
627
1036
  return { claims };
628
1037
  });
629
- app.get(`${V}/governance/coverage`, async () => {
1038
+ // Normalize a `?repo=` query: Fastify parses a repeated `?repo=a&repo=b` into a `string[]` (take
1039
+ // the first), and the value is TRIMMED so it's both validated and PASSED trimmed — a `?repo=%20x%20`
1040
+ // must not pass the guard then miss the registry lookup on whitespace (Copilot #227).
1041
+ const repoParam = (req) => {
1042
+ const raw = req.query?.repo;
1043
+ const first = Array.isArray(raw) ? raw[0] : raw;
1044
+ const trimmed = first?.trim();
1045
+ return trimmed ? trimmed : undefined;
1046
+ };
1047
+ // An unknown repo is the CALLER's error (404); a binding/parse failure is OURS (500). Don't collapse
1048
+ // them (Copilot #227). Core spells the not-found case `no registered repo '<ref>'`.
1049
+ const repoReadStatus = (msg) => (/no registered repo/i.test(msg) ? 404 : 500);
1050
+ app.get(`${V}/governance/coverage`, async (req, reply) => {
1051
+ // FINDING-009: with `?repo=<ref>`, report coverage over THAT repo's own code graph. The bare
1052
+ // endpoint reads the daemon store and reports a vacuous `coverage: 1.0` that names no repo — the
1053
+ // real gate lives per-repo. An unknown repo is an ERROR in core (never a silent vacuous report).
1054
+ const repo = repoParam(req);
1055
+ if (repo) {
1056
+ try {
1057
+ const report = await adapter.getCoverageReportForRepo(repo);
1058
+ return { report };
1059
+ }
1060
+ catch (err) {
1061
+ const msg = message(err);
1062
+ return reply.code(repoReadStatus(msg)).send({ error: msg });
1063
+ }
1064
+ }
630
1065
  const report = await adapter.getCoverageReport();
631
1066
  return { report };
632
1067
  });
1068
+ app.get(`${V}/governance/graph`, async (req, reply) => {
1069
+ // #122: node-count-by-kind summary of a repo's OWN code graph. Repo-scoped ONLY — there is no
1070
+ // meaningful daemon-wide graph view (the daemon store holds run/governance nodes, not a repo's
1071
+ // code graph), so a missing `?repo=` is a 400, an unknown repo a 404, and an engine/binding
1072
+ // failure a 500 (not masked as 404).
1073
+ const repo = repoParam(req);
1074
+ if (!repo) {
1075
+ return reply.code(400).send({ error: 'governance/graph requires a ?repo=<ref>' });
1076
+ }
1077
+ try {
1078
+ const kinds = await adapter.getGraphKindsForRepo(repo);
1079
+ return { kinds };
1080
+ }
1081
+ catch (err) {
1082
+ const msg = message(err);
1083
+ return reply.code(repoReadStatus(msg)).send({ error: msg });
1084
+ }
1085
+ });
633
1086
  // ── Governance writes (crew#42) ────────────────────────────────────────────
634
1087
  app.post(`${V}/governance/policies`, async (req, reply) => {
635
1088
  try {
636
- await adapter.upsertPolicy(req.body);
1089
+ const policy = req.body;
1090
+ await adapter.upsertPolicy(policy);
1091
+ audit.record('governance.policy.upserted', actorOf(req), { detail: { id: policy?.id } });
637
1092
  return { status: 'ok' };
638
1093
  }
639
1094
  catch (err) {
@@ -642,7 +1097,9 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
642
1097
  });
643
1098
  app.post(`${V}/governance/rules`, async (req, reply) => {
644
1099
  try {
645
- await adapter.upsertConformanceRule(req.body);
1100
+ const rule = req.body;
1101
+ await adapter.upsertConformanceRule(rule);
1102
+ audit.record('governance.rule.upserted', actorOf(req), { detail: { id: rule?.id } });
646
1103
  return { status: 'ok' };
647
1104
  }
648
1105
  catch (err) {
@@ -657,6 +1114,7 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
657
1114
  const existed = await adapter.retirePolicy(id);
658
1115
  if (!existed)
659
1116
  return reply.code(404).send({ error: `policy '${id}' not found` });
1117
+ audit.record('governance.policy.retired', actorOf(req), { detail: { id } });
660
1118
  return { status: 'retired', id };
661
1119
  }
662
1120
  catch (err) {
@@ -669,6 +1127,7 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
669
1127
  const existed = await adapter.retireConformanceRule(id);
670
1128
  if (!existed)
671
1129
  return reply.code(404).send({ error: `conformance rule '${id}' not found` });
1130
+ audit.record('governance.rule.retired', actorOf(req), { detail: { id } });
672
1131
  return { status: 'retired', id };
673
1132
  }
674
1133
  catch (err) {
@@ -695,7 +1154,9 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
695
1154
  const workflow = adapter.getWorkflow(id);
696
1155
  if (!workflow)
697
1156
  return reply.code(404).send({ error: `workflow '${id}' not found` });
698
- return { workflow };
1157
+ // humanGates: the phases that will PAUSE for a person even under humanConfirm:none (core#208).
1158
+ // Surfaced so an operator can see a workflow's gates BEFORE launching it (FINDING-023).
1159
+ return { workflow, humanGates: humanGatePhaseIds(workflow) };
699
1160
  });
700
1161
  // Register (or replace) a user-authored workflow definition.
701
1162
  // Validates, persists to ~/.wicked/workflows/<id>.json, hot-registers in the
@@ -710,6 +1171,7 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
710
1171
  }
711
1172
  try {
712
1173
  const id = await adapter.registerWorkflow(body);
1174
+ audit.record('workflow.registered', actorOf(req), { detail: { id } });
713
1175
  return reply.code(201).send({ id, status: 'registered' });
714
1176
  }
715
1177
  catch (err) {
@@ -1071,14 +1533,41 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
1071
1533
  return reply.code(400).send({ error: 'graphNodeLimit must be an integer between 20 and 500' });
1072
1534
  }
1073
1535
  }
1536
+ if ('worker_config_root' in patch) {
1537
+ const root = patch.worker_config_root;
1538
+ if (typeof root !== 'string' || (root !== '' && !isAbsolute(root))) {
1539
+ return reply.code(400).send({
1540
+ error: 'worker_config_root must be an absolute path, or "" for the default (~/.wicked-worker)',
1541
+ });
1542
+ }
1543
+ }
1074
1544
  // Only allow known keys through.
1075
- const allowed = ['graphNodeLimit'];
1545
+ const allowed = [
1546
+ 'graphNodeLimit',
1547
+ 'worker_config_root',
1548
+ ];
1076
1549
  const safe = {};
1077
1550
  for (const key of allowed) {
1078
1551
  if (key in patch)
1079
1552
  safe[key] = patch[key];
1080
1553
  }
1081
- return { settings: await adapter.updateSettings(safe) };
1554
+ const settings = await adapter.updateSettings(safe);
1555
+ // Re-apply the worker-config root to this process's env (seat sign-in). The engine reads
1556
+ // WICKED_WORKER_HOME per worker spawn — never cached — so this alone makes the change live
1557
+ // at the next spawn: no daemon restart, no engine restart.
1558
+ applyWorkerConfigRoot(settings.worker_config_root);
1559
+ audit.record('settings.updated', actorOf(req), { detail: { changed: Object.keys(safe) } });
1560
+ return { settings };
1561
+ });
1562
+ // ── Projects (DES-PROJECT-001) — the 9-route experience-plane surface ────────
1563
+ registerProjectRoutes(app, adapter, { ...projects, settings: projectSettings }, security);
1564
+ // ── The wicked-interactive bridge, reverse-proxied (DES-MERGE-001 §5.3/§7.2) ──
1565
+ // Mounted BESIDE the routes above and under the same `${V}` prefix, so it inherits one
1566
+ // origin, one auth hook, and one CORS posture — the whole point of slice 1.
1567
+ registerInteractiveProxy(app, adapter, {
1568
+ settings: projectSettings,
1569
+ pool: runtime.interactiveBridges ?? new InteractiveBridgePool({ log: (m) => app.log.warn(m) }),
1570
+ log: (m) => app.log.warn(m),
1082
1571
  });
1083
1572
  }
1084
1573
  //# sourceMappingURL=routes.js.map