wicked-crew 0.5.0 → 0.7.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 (167) hide show
  1. package/dist/api/audit.d.ts +70 -0
  2. package/dist/api/audit.d.ts.map +1 -0
  3. package/dist/api/audit.js +131 -0
  4. package/dist/api/audit.js.map +1 -0
  5. package/dist/api/auth.d.ts +192 -0
  6. package/dist/api/auth.d.ts.map +1 -0
  7. package/dist/api/auth.js +515 -0
  8. package/dist/api/auth.js.map +1 -0
  9. package/dist/api/gate-cache.d.ts +8 -0
  10. package/dist/api/gate-cache.d.ts.map +1 -1
  11. package/dist/api/gate-cache.js +10 -0
  12. package/dist/api/gate-cache.js.map +1 -1
  13. package/dist/api/guidance-index.d.ts +39 -0
  14. package/dist/api/guidance-index.d.ts.map +1 -0
  15. package/dist/api/guidance-index.js +67 -0
  16. package/dist/api/guidance-index.js.map +1 -0
  17. package/dist/api/open-path.d.ts +34 -0
  18. package/dist/api/open-path.d.ts.map +1 -0
  19. package/dist/api/open-path.js +101 -0
  20. package/dist/api/open-path.js.map +1 -0
  21. package/dist/api/retry-index.d.ts +30 -0
  22. package/dist/api/retry-index.d.ts.map +1 -0
  23. package/dist/api/retry-index.js +45 -0
  24. package/dist/api/retry-index.js.map +1 -0
  25. package/dist/api/routes.d.ts +164 -1
  26. package/dist/api/routes.d.ts.map +1 -1
  27. package/dist/api/routes.js +769 -21
  28. package/dist/api/routes.js.map +1 -1
  29. package/dist/api/run-files.d.ts +63 -0
  30. package/dist/api/run-files.d.ts.map +1 -0
  31. package/dist/api/run-files.js +271 -0
  32. package/dist/api/run-files.js.map +1 -0
  33. package/dist/api/seat-health.d.ts +55 -0
  34. package/dist/api/seat-health.d.ts.map +1 -0
  35. package/dist/api/seat-health.js +273 -0
  36. package/dist/api/seat-health.js.map +1 -0
  37. package/dist/api/seat-signin.d.ts +27 -0
  38. package/dist/api/seat-signin.d.ts.map +1 -0
  39. package/dist/api/seat-signin.js +143 -0
  40. package/dist/api/seat-signin.js.map +1 -0
  41. package/dist/api/server.d.ts +190 -3
  42. package/dist/api/server.d.ts.map +1 -1
  43. package/dist/api/server.js +365 -11
  44. package/dist/api/server.js.map +1 -1
  45. package/dist/api/stall-watchdog.d.ts +62 -0
  46. package/dist/api/stall-watchdog.d.ts.map +1 -0
  47. package/dist/api/stall-watchdog.js +138 -0
  48. package/dist/api/stall-watchdog.js.map +1 -0
  49. package/dist/cli/index.js +171 -12
  50. package/dist/cli/index.js.map +1 -1
  51. package/dist/cli/mcp.d.ts +14 -0
  52. package/dist/cli/mcp.d.ts.map +1 -0
  53. package/dist/cli/mcp.js +119 -0
  54. package/dist/cli/mcp.js.map +1 -0
  55. package/dist/core/adapter.d.ts +87 -10
  56. package/dist/core/adapter.d.ts.map +1 -1
  57. package/dist/core/adapter.js +347 -28
  58. package/dist/core/adapter.js.map +1 -1
  59. package/dist/core/bridge-reaper.d.ts +134 -0
  60. package/dist/core/bridge-reaper.d.ts.map +1 -0
  61. package/dist/core/bridge-reaper.js +286 -0
  62. package/dist/core/bridge-reaper.js.map +1 -0
  63. package/dist/core/deliver.d.ts +118 -0
  64. package/dist/core/deliver.d.ts.map +1 -0
  65. package/dist/core/deliver.js +241 -0
  66. package/dist/core/deliver.js.map +1 -0
  67. package/dist/core/deliverable-floor.d.ts +103 -0
  68. package/dist/core/deliverable-floor.d.ts.map +1 -0
  69. package/dist/core/deliverable-floor.js +173 -0
  70. package/dist/core/deliverable-floor.js.map +1 -0
  71. package/dist/core/exec.d.ts +2 -0
  72. package/dist/core/exec.d.ts.map +1 -1
  73. package/dist/core/exec.js.map +1 -1
  74. package/dist/core/types.d.ts +92 -352
  75. package/dist/core/types.d.ts.map +1 -1
  76. package/dist/core/types.js +14 -4
  77. package/dist/core/types.js.map +1 -1
  78. package/dist/interactive/bridge-pool.d.ts +99 -0
  79. package/dist/interactive/bridge-pool.d.ts.map +1 -0
  80. package/dist/interactive/bridge-pool.js +244 -0
  81. package/dist/interactive/bridge-pool.js.map +1 -0
  82. package/dist/interactive/bridge-root.d.ts +36 -0
  83. package/dist/interactive/bridge-root.d.ts.map +1 -0
  84. package/dist/interactive/bridge-root.js +48 -0
  85. package/dist/interactive/bridge-root.js.map +1 -0
  86. package/dist/interactive/chat-events.d.ts +207 -0
  87. package/dist/interactive/chat-events.d.ts.map +1 -0
  88. package/dist/interactive/chat-events.js +769 -0
  89. package/dist/interactive/chat-events.js.map +1 -0
  90. package/dist/interactive/demo-events.d.ts +283 -0
  91. package/dist/interactive/demo-events.d.ts.map +1 -0
  92. package/dist/interactive/demo-events.js +889 -0
  93. package/dist/interactive/demo-events.js.map +1 -0
  94. package/dist/interactive/draft-events.d.ts +224 -0
  95. package/dist/interactive/draft-events.d.ts.map +1 -0
  96. package/dist/interactive/draft-events.js +793 -0
  97. package/dist/interactive/draft-events.js.map +1 -0
  98. package/dist/interactive/edit-events.d.ts +194 -0
  99. package/dist/interactive/edit-events.d.ts.map +1 -0
  100. package/dist/interactive/edit-events.js +601 -0
  101. package/dist/interactive/edit-events.js.map +1 -0
  102. package/dist/interactive/ledger.d.ts +39 -0
  103. package/dist/interactive/ledger.d.ts.map +1 -0
  104. package/dist/interactive/ledger.js +93 -0
  105. package/dist/interactive/ledger.js.map +1 -0
  106. package/dist/interactive/proxy-routes.d.ts +39 -0
  107. package/dist/interactive/proxy-routes.d.ts.map +1 -0
  108. package/dist/interactive/proxy-routes.js +189 -0
  109. package/dist/interactive/proxy-routes.js.map +1 -0
  110. package/dist/interactive/repo-snapshot.d.ts +100 -0
  111. package/dist/interactive/repo-snapshot.d.ts.map +1 -0
  112. package/dist/interactive/repo-snapshot.js +289 -0
  113. package/dist/interactive/repo-snapshot.js.map +1 -0
  114. package/dist/interactive/ws-relay.d.ts +85 -0
  115. package/dist/interactive/ws-relay.d.ts.map +1 -0
  116. package/dist/interactive/ws-relay.js +191 -0
  117. package/dist/interactive/ws-relay.js.map +1 -0
  118. package/dist/projects/activity.d.ts +29 -0
  119. package/dist/projects/activity.d.ts.map +1 -0
  120. package/dist/projects/activity.js +172 -0
  121. package/dist/projects/activity.js.map +1 -0
  122. package/dist/projects/charter.d.ts +28 -0
  123. package/dist/projects/charter.d.ts.map +1 -0
  124. package/dist/projects/charter.js +53 -0
  125. package/dist/projects/charter.js.map +1 -0
  126. package/dist/projects/events.d.ts +55 -0
  127. package/dist/projects/events.d.ts.map +1 -0
  128. package/dist/projects/events.js +141 -0
  129. package/dist/projects/events.js.map +1 -0
  130. package/dist/projects/graph-paths.d.ts +92 -0
  131. package/dist/projects/graph-paths.d.ts.map +1 -0
  132. package/dist/projects/graph-paths.js +130 -0
  133. package/dist/projects/graph-paths.js.map +1 -0
  134. package/dist/projects/graph.d.ts +179 -0
  135. package/dist/projects/graph.d.ts.map +1 -0
  136. package/dist/projects/graph.js +775 -0
  137. package/dist/projects/graph.js.map +1 -0
  138. package/dist/projects/membership-index.d.ts +25 -0
  139. package/dist/projects/membership-index.d.ts.map +1 -0
  140. package/dist/projects/membership-index.js +46 -0
  141. package/dist/projects/membership-index.js.map +1 -0
  142. package/dist/projects/routes.d.ts +97 -0
  143. package/dist/projects/routes.d.ts.map +1 -0
  144. package/dist/projects/routes.js +510 -0
  145. package/dist/projects/routes.js.map +1 -0
  146. package/dist/projects/settings.d.ts +32 -0
  147. package/dist/projects/settings.d.ts.map +1 -0
  148. package/dist/projects/settings.js +64 -0
  149. package/dist/projects/settings.js.map +1 -0
  150. package/dist/qe/acceptance.d.ts +137 -0
  151. package/dist/qe/acceptance.d.ts.map +1 -0
  152. package/dist/qe/acceptance.js +249 -0
  153. package/dist/qe/acceptance.js.map +1 -0
  154. package/dist/qe/gate-events.d.ts +111 -0
  155. package/dist/qe/gate-events.d.ts.map +1 -0
  156. package/dist/qe/gate-events.js +168 -0
  157. package/dist/qe/gate-events.js.map +1 -0
  158. package/dist/qe/ledger.d.ts +100 -0
  159. package/dist/qe/ledger.d.ts.map +1 -0
  160. package/dist/qe/ledger.js +154 -0
  161. package/dist/qe/ledger.js.map +1 -0
  162. package/dist/studio/assets/index-8p8uwCxG.js +530 -0
  163. package/dist/studio/assets/index-D6S9zUtO.css +32 -0
  164. package/dist/studio/index.html +5 -3
  165. package/package.json +10 -4
  166. package/dist/studio/assets/index-DaaUU8Ep.css +0 -32
  167. package/dist/studio/assets/index-Fu5DRC00.js +0 -423
@@ -3,13 +3,30 @@ 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, relative, resolve } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { ChatUnsupportedError, CoreAdapter, ElicitationUnsupportedError, humanGatePhaseIds } from '../core/adapter.js';
9
9
  import { codeGraphDb, requirementsGraph } from '../core/repoPaths.js';
10
+ import { QeGateCache } from '../qe/gate-events.js';
11
+ import { buildAcceptanceView, resolveRunWorkflow } from '../qe/acceptance.js';
10
12
  import { buildEvidenceBundle, coreUnitId, evidenceFilename } from './evidence.js';
11
13
  import { outputUnavailableReason, resolveUnit, unitKeysFor } from './unit-output.js';
12
14
  import { execCapped, ExecOutputTooLarge } from '../core/exec.js';
15
+ import { SeatHealthTracker } from './seat-health.js';
16
+ import { applyWorkerConfigRoot, signedInHeuristic } from './seat-signin.js';
17
+ import { allowedRootsFor, isInsideRoot, openWithSystemDefault } from './open-path.js';
18
+ import { InvalidDiffBaseError, NotARegularFileError, UnresolvableDiffBaseError, readFileCapped, worktreeDiff, } from './run-files.js';
19
+ import { resolveProjectGraphBinding } from '../projects/graph.js';
20
+ import { registerProjectRoutes } from '../projects/routes.js';
21
+ import { ProjectSettingsStore } from '../projects/settings.js';
22
+ import { boundOrigin, InteractiveBridgePool } from '../interactive/bridge-pool.js';
23
+ import { registerInteractiveProxy } from '../interactive/proxy-routes.js';
24
+ import { MembershipIndex } from '../projects/membership-index.js';
25
+ import { MEMBERSHIP_ATTACHED, membershipAttachedKey } from '../projects/events.js';
26
+ import { AuditLog } from './audit.js';
27
+ import { RetryIndex } from './retry-index.js';
28
+ import { GuidanceIndex } from './guidance-index.js';
29
+ import { LOCAL_ACTOR } from './auth.js';
13
30
  // Re-exported so existing `import { API_PREFIX } from './routes.js'` callers keep working; the
14
31
  // value lives in the leaf module api-prefix.ts to keep unit-output.ts out of this file's cycle.
15
32
  export { API_PREFIX } from './api-prefix.js';
@@ -93,7 +110,10 @@ const RegisterRepoSchema = z
93
110
  * schema does not accept — so `.strict()` is what turns that trip into a 400 instead of an
94
111
  * unworkflowed run reported as `201`.
95
112
  */
96
- const LaunchSchema = z.object({
113
+ // The request-body schemas below are exported so `tests/wire-contract.test.ts` can prove, at
114
+ // compile time, that every body the published contract (`wicked-crew-api-types`) lets a client
115
+ // send is a body these schemas accept — the request-direction half of the drift guard (task #84).
116
+ export const LaunchSchema = z.object({
97
117
  problem: z.string().min(1),
98
118
  sessionId: z.string().min(1).optional(),
99
119
  clisJson: z.string().min(1).optional(),
@@ -101,17 +121,39 @@ const LaunchSchema = z.object({
101
121
  humanConfirm: z.string().min(1).optional(),
102
122
  repoRef: z.string().min(1).optional(),
103
123
  workflow: z.string().min(1).optional(),
104
- }).strict();
105
- const GateSchema = z.object({
124
+ /** DES-PROJECT-001 §2.2 — file the run into a project; membership attaches atomically with
125
+ * the launch record. Unknown/archived ⇒ the launch fails (never a silent unfiled run). */
126
+ projectId: z.string().min(1).optional(),
127
+ /** crew#293 — `"pr"` appends the hardened deliver Tool phase (push run branch + `gh pr create`)
128
+ * to a PER-RUN copy of the selected workflow. Requires `workflow` (enforced by the refine
129
+ * below, so the 400 happens at parse time, not after the adapter is consulted). */
130
+ deliver: z.literal('pr').optional(),
131
+ /** DES-UX-001 §8.3 (CREW-UX-3) — the run this launch retries. Must name an EXISTING run id
132
+ * (the route checks the store and 400s with a named error otherwise); persisted via the
133
+ * `run.launched` audit entry + retry index and echoed as `AgentSession.retry_of`. */
134
+ retryOf: z.string().min(1).optional(),
135
+ }).strict().refine((b) => b.deliver === undefined || b.workflow !== undefined, {
136
+ message: 'deliver: "pr" requires a workflow — a free-text run has no def to append the deliver phase to',
137
+ path: ['deliver'],
138
+ });
139
+ export const GateSchema = z.object({
106
140
  approve: z.boolean(),
107
141
  amend: z.string().optional(),
108
142
  }).strict();
143
+ /** `PUT /runs/:id/guidance` (DES-UX-002 §7.2, CREW-UX-7) — the durable pre-gate note body.
144
+ * The empty string is a legal body: it CLEARS the note. The byte cap is checked in the route
145
+ * (not zod's char-counting `max`) so the 400 names the actual limit. */
146
+ export const GuidanceSchema = z.object({
147
+ text: z.string(),
148
+ }).strict();
149
+ /** ~8KB — a guidance note is operator prose, not a document store. */
150
+ const GUIDANCE_MAX_BYTES = 8192;
109
151
  const InjectSchema = z.object({
110
152
  message: z.string().min(1),
111
153
  /** `"all"` broadcasts to every active worker; any other value is a CLI key. */
112
154
  target: z.string().min(1).default('all'),
113
155
  }).strict();
114
- const OpenTerminalSchema = z.object({
156
+ export const OpenTerminalSchema = z.object({
115
157
  cwd: z.string().min(1),
116
158
  cmd: z.array(z.string().min(1)).min(1).optional(),
117
159
  cols: z.number().int().positive(),
@@ -124,16 +166,119 @@ const ResizeTerminalSchema = z.object({
124
166
  cols: z.number().int().positive(),
125
167
  rows: z.number().int().positive(),
126
168
  }).strict();
169
+ /** `POST /open` (crew#273) — the studio Files tab's "open with the OS default app" body. */
170
+ export const OpenPathSchema = z.object({
171
+ path: z.string().min(1),
172
+ runId: z.string().min(1).optional(),
173
+ }).strict();
174
+ /**
175
+ * A SKIN-OWNED settings key (crew#323): one lowercase segment under the `studio.` namespace,
176
+ * e.g. `studio.appearance`, `studio.notifications`. The daemon never interprets these values —
177
+ * the settings store is shared with the skin, and the namespace is what makes that legible.
178
+ */
179
+ const STUDIO_SETTINGS_KEY = /^studio\.[a-z][a-z0-9-]*$/;
180
+ /**
181
+ * Per-key ceiling on a `studio.*` value, as the UTF-8 byte length of its JSON form.
182
+ *
183
+ * 512KB, not "a few KB": `studio.appearance` carries the operator's logo as a data URI, and
184
+ * base64 costs ~33%, so this admits a ~380KB image — a real logo, not a favicon — while a
185
+ * preferences blob like `studio.notifications` spends a few dozen bytes of it. One generous
186
+ * cap covers both because the daemon cannot tell them apart; what it stops is settings.json
187
+ * quietly becoming an asset store. It sits at half of Fastify's 1MiB default body limit, so a
188
+ * realistic multi-key patch (one blob near the cap plus small preference blobs) is still parsed
189
+ * before it is judged — but note the two limits COMPOSE: a body whose keys total more than 1MiB
190
+ * is refused by Fastify with a 413 that this route never sees, so the per-key 400 below is the
191
+ * ceiling on ONE key, not on the patch.
192
+ */
193
+ const STUDIO_SETTINGS_MAX_BYTES = 512 * 1024;
127
194
  /**
128
195
  * The daemon REST surface. Every endpoint is a thin wrapper over one adapter /
129
196
  * core-ts call (DES-STUDIO-001 §2). `session`/`phase` nouns are now `run`/`unit`.
130
197
  */
131
- export function registerRoutes(app, adapter, gateCache, elicitationCache) {
198
+ export function registerRoutes(app, adapter, gateCache, elicitationCache,
199
+ // Defaulted so a caller that never arms the bus seam (tests drive this
200
+ // function directly) gets the same behavior as an unarmed daemon: an empty
201
+ // cache, `busEvent: null`, and the lazy ledger read doing all the work.
202
+ qeGateEvents = new QeGateCache(),
203
+ // Defaulted for the same reason: tests that drive this function directly get
204
+ // the projects surface with no bus (events skipped) and a fresh index.
205
+ projects = { bus: null, index: new MembershipIndex(), log: () => undefined },
206
+ // Defaulted likewise — to a NOOP trail, deliberately: a directly-driven
207
+ // route set (unit tests) must never write the operator's real
208
+ // ~/.wicked-crew/audit.log or leave appends pending after close. The real
209
+ // trail always arrives from `createServer`.
210
+ security = { audit: AuditLog.noop(), authMode: 'off' },
211
+ // Defaulted likewise: a directly-driven route set gets a fresh (all-active) health map and the
212
+ // real OS opener — tests inject both through this seam.
213
+ runtime = {}) {
214
+ const { audit } = security;
215
+ const seatHealth = runtime.seatHealth ?? new SeatHealthTracker();
216
+ const openWithOs = runtime.openWithOs ?? openWithSystemDefault;
217
+ const signedIn = runtime.signedIn ?? signedInHeuristic;
218
+ const retryIndex = runtime.retryIndex ?? new RetryIndex();
219
+ const guidanceIndex = runtime.guidanceIndex ?? new GuidanceIndex();
220
+ // The run-DTO joins (DES-UX-001 §8.2/§8.3, DES-UX-002 §7.2): `project_id` from the membership
221
+ // record — `null` = genuinely unfiled, so the field is ALWAYS present on served runs —
222
+ // `retry_of` from the lineage index, and `guidance` from the guidance index, each set only
223
+ // when known (absent, never null, spells "not a retry" / "no note").
224
+ // Applied at DTO assembly on exactly the two endpoints that serve the run DTO
225
+ // (GET /runs + GET /runs/:id); the internal sessionsDetail() consumers are untouched.
226
+ const decorateRun = (view) => {
227
+ view.session.project_id = projects.index.projectOf(view.session.id) ?? null;
228
+ const retryOf = retryIndex.retryOfFor(view.session.id);
229
+ if (retryOf !== undefined)
230
+ view.session.retry_of = retryOf;
231
+ const guidance = guidanceIndex.guidanceFor(view.session.id);
232
+ if (guidance !== undefined)
233
+ view.session.guidance = guidance;
234
+ return view;
235
+ };
236
+ // Resolved ONCE and shared by the project routes (which read/write `interactiveRoot`) and the
237
+ // interactive proxy (which resolves a root from it) — two stores would let a PATCH land in one
238
+ // and the proxy keep reading the other.
239
+ const projectSettings = projects.settings ?? new ProjectSettingsStore();
240
+ // `req.actor` is pinned by the auth hooks `createServer` installs; a caller
241
+ // driving this function directly (tests) has no hooks, so downstream code
242
+ // still gets the ONE actor shape via this accessor.
243
+ const actorOf = (req) => req.actor ?? LOCAL_ACTOR;
132
244
  // Liveness — also proves the actor + event pump are up.
133
245
  app.get(`${V}/health`, async () => {
134
246
  const ping = await adapter.ping();
135
247
  return { status: 'ok', version: PKG_VERSION, ping };
136
248
  });
249
+ // Who am I talking to the daemon as? (task #88). In local mode this is
250
+ // always the implicit full-trust local actor; in required mode it names the
251
+ // token's actor — the cheap probe a skin uses to decide what to render.
252
+ app.get(`${V}/whoami`, async (req) => ({
253
+ actor: actorOf(req),
254
+ authMode: security.authMode,
255
+ }));
256
+ // The actor audit trail (task #88): who launched/steered/governed what.
257
+ // Read-only (observer trust); newest first; `?runId=` / `?action=` / `?limit=`.
258
+ app.get(`${V}/audit`, async (req, reply) => {
259
+ const q = req.query;
260
+ const first = (v) => (Array.isArray(v) ? v[0] : v)?.trim() || undefined;
261
+ const runId = first(q.runId);
262
+ const action = first(q.action);
263
+ const limitRaw = first(q.limit);
264
+ // Reject partial-numeric strings like "10abc" — parseInt accepts those,
265
+ // Number() is strict and returns NaN for them (Copilot, #250).
266
+ const limit = limitRaw !== undefined ? Number(limitRaw) : undefined;
267
+ if (limitRaw !== undefined && (!Number.isFinite(limit) || limit < 1 || !Number.isInteger(limit))) {
268
+ return reply.code(400).send({ error: '`limit` must be a positive integer' });
269
+ }
270
+ try {
271
+ const entries = await audit.read({
272
+ ...(runId !== undefined ? { runId } : {}),
273
+ ...(action !== undefined ? { action } : {}),
274
+ ...(limit !== undefined ? { limit } : {}),
275
+ });
276
+ return { entries };
277
+ }
278
+ catch (err) {
279
+ return reply.code(500).send({ error: message(err) });
280
+ }
281
+ });
137
282
  // Report the actually-bound port/host (honours --port / CREW_PORT / port 0).
138
283
  app.get(`${V}/config`, async () => {
139
284
  const addr = app.server.address();
@@ -141,8 +286,209 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
141
286
  const host = typeof addr === 'object' && addr ? addr.address : '127.0.0.1';
142
287
  return { port, host };
143
288
  });
144
- // The council seats for the launch form (static production roster).
145
- app.get(`${V}/roster`, async () => ({ roster: CoreAdapter.roster() }));
289
+ // The council seats for the launch form (static production roster), each carrying its RUNTIME
290
+ // health (crew#274). The roster is declarative — every configured seat is listed — and `health`
291
+ // is what the platform has observed: default active with no message; inactive + the error
292
+ // excerpt after a seat-level failure, until an ok output or the recovery probe flips it back.
293
+ // Existing fields ride through verbatim (the seat still round-trips into `clisJson` on launch)
294
+ // — the spread is deliberately NOT a field whitelist, which is what lets the engine's
295
+ // `login_invocation` (seat sign-in, wicked-core PR#278) pass through untouched. Each seat also
296
+ // gains `signed_in`: the cheap file/env presence heuristic, computed against the LIVE
297
+ // `WICKED_WORKER_HOME` env — the same value the engine reads at the next worker spawn, kept
298
+ // current by `applyWorkerConfigRoot` at boot and on every settings change.
299
+ app.get(`${V}/roster`, async () => {
300
+ const workerRoot = process.env['WICKED_WORKER_HOME'];
301
+ return {
302
+ roster: CoreAdapter.roster().map((seat) => ({
303
+ ...seat,
304
+ health: seatHealth.healthFor(String(seat.key)),
305
+ signed_in: signedIn(String(seat.key), workerRoot === '' ? undefined : workerRoot),
306
+ })),
307
+ };
308
+ });
309
+ // Open a file/folder with the OS default application (crew#273) — the studio Files tab's
310
+ // click-to-open. The open MUST happen daemon-side (the SPA cannot spawn a process), which is
311
+ // why the path is validated first: absolute, and inside one of the caller-visible roots — the
312
+ // run's workdir + extra write roots (when `runId` is given) or a registered repo root. The
313
+ // run's QE evidence/decisions dirs (`.wicked-qe`/`.wicked-testing`) live under the repo root,
314
+ // so the repo-root rule covers them. Never an arbitrary path.
315
+ app.post(`${V}/open`, async (req, reply) => {
316
+ const parsed = OpenPathSchema.safeParse(req.body);
317
+ if (!parsed.success) {
318
+ return reply.code(400).send(invalidBody(parsed.error, 'Invalid open request'));
319
+ }
320
+ const { path: rawPath, runId } = parsed.data;
321
+ if (!isAbsolute(rawPath)) {
322
+ return reply.code(400).send({ error: '`path` must be an absolute path' });
323
+ }
324
+ const target = resolve(rawPath);
325
+ let roots;
326
+ try {
327
+ let session;
328
+ if (runId !== undefined) {
329
+ const views = await adapter.sessionsDetail();
330
+ const view = views.find((v) => v.session.id === runId);
331
+ if (view === undefined) {
332
+ return reply.code(404).send({ error: `unknown run: ${runId}` });
333
+ }
334
+ session = view.session;
335
+ }
336
+ roots = allowedRootsFor(session, await adapter.listRepos());
337
+ }
338
+ catch (err) {
339
+ return reply.code(500).send({ error: message(err) });
340
+ }
341
+ if (!roots.some((root) => isInsideRoot(root, target))) {
342
+ return reply.code(403).send({
343
+ error: "path is outside every allowed root (the run's workdir/write roots and the registered repos)",
344
+ });
345
+ }
346
+ try {
347
+ await openWithOs(target);
348
+ }
349
+ catch (err) {
350
+ return reply.code(502).send({ error: `could not open ${target}: ${message(err)}` });
351
+ }
352
+ return { status: 'opened' };
353
+ });
354
+ // ── Run file & diff reads (DES-FEEDBACK-002 CREW-1) ────────────────────────
355
+ // The studio's in-app viewer (P0-3). Both routes are GET-only assembly of reviewed machinery:
356
+ // the SAME containment `POST /open` runs (`allowedRootsFor` + fail-closed `isInsideRoot`) over
357
+ // the SAME root set (run workdir + extra write roots + registered repo roots), capped payloads,
358
+ // and `execCapped` git with argv arrays. Threat delta over /open is strictly smaller: these only
359
+ // return bytes the daemon can already read inside the same containment — no OS opener, no write.
360
+ /** Resolve `:id` → the run's session, and the contained target from `?path=` when present.
361
+ * Shared by both routes so their validation ladders (404 unknown run → 400 non-absolute →
362
+ * 403 outside every root) cannot drift. Returns `null` after replying. */
363
+ const resolveRunPath = async (reply, id, rawPath) => {
364
+ let session;
365
+ let roots;
366
+ try {
367
+ const views = await adapter.sessionsDetail();
368
+ const view = views.find((v) => v.session.id === id);
369
+ if (view === undefined) {
370
+ await reply.code(404).send({ error: `unknown run: ${id}` });
371
+ return null;
372
+ }
373
+ session = view.session;
374
+ if (rawPath === undefined)
375
+ return { session };
376
+ roots = allowedRootsFor(session, await adapter.listRepos());
377
+ }
378
+ catch (err) {
379
+ await reply.code(500).send({ error: message(err) });
380
+ return null;
381
+ }
382
+ if (!isAbsolute(rawPath)) {
383
+ await reply.code(400).send({ error: '`path` must be an absolute path' });
384
+ return null;
385
+ }
386
+ const target = resolve(rawPath);
387
+ if (!roots.some((root) => isInsideRoot(root, target))) {
388
+ await reply.code(403).send({
389
+ error: "path is outside every allowed root (the run's workdir/write roots and the registered repos)",
390
+ });
391
+ return null;
392
+ }
393
+ return { session, target };
394
+ };
395
+ // Fastify parses a repeated param as string[] (Copilot, #250/#266). These are FILE-READ
396
+ // routes: `?path=a&path=b` is rejected outright (400) rather than silently reading as
397
+ // either (Copilot, #305).
398
+ const REPEATED_PATH = Symbol('repeated path param');
399
+ const singlePathQ = (v) => Array.isArray(v) ? REPEATED_PATH : v?.trim() || undefined;
400
+ // File content from the run's contained roots: 512 KB cap (`truncated: true` past it, first
401
+ // 512 KB served), NUL-in-first-8KB binary sniff (`binary: true`, `content: ""`). Read-only by
402
+ // construction (`fs` read); no directory listing — the studio already has the file list.
403
+ app.get(`${V}/runs/:id/files`, async (req, reply) => {
404
+ const { id } = req.params;
405
+ const rawPath = singlePathQ(req.query.path);
406
+ if (rawPath === REPEATED_PATH) {
407
+ return reply.code(400).send({ error: '`path` may be given at most once' });
408
+ }
409
+ if (rawPath === undefined) {
410
+ return reply.code(400).send({ error: '`path` query parameter is required' });
411
+ }
412
+ const resolved = await resolveRunPath(reply, id, rawPath);
413
+ if (resolved === null)
414
+ return reply;
415
+ const target = resolved.target;
416
+ try {
417
+ const read = await readFileCapped(target);
418
+ return { path: target, ...read };
419
+ }
420
+ catch (err) {
421
+ if (err.code === 'ENOENT') {
422
+ return reply.code(404).send({ error: `no such file: ${target}` });
423
+ }
424
+ if (err instanceof NotARegularFileError) {
425
+ return reply.code(400).send({ error: `\`path\` is not a regular file: ${target}` });
426
+ }
427
+ return reply.code(500).send({ error: message(err) });
428
+ }
429
+ });
430
+ // The run's worktree diff against HEAD — or, with `?base=` (CREW-UX-1, DES-UX-001 §8.1),
431
+ // against the run branch's fork point (`base=merge-base`) or a plain in-repo ref, so committed
432
+ // run work is visible. Staged + unstaged; untracked appended as all-addition `--no-index`
433
+ // hunks; whole-tree or `?path=` narrowed. 1 MB output cap. `diff: ""` is a real answer (clean
434
+ // tree), not an error. 409 — not 404 — when the run has no workdir or the workdir has been
435
+ // reaped: the RUN exists; what is gone is the thing to diff against. `base` is a baseline,
436
+ // NEVER a command surface: anything that is not the merge-base literal or a plain resolvable
437
+ // ref (flags, paths, ranges, separators) is a named 400 before any git process sees it.
438
+ app.get(`${V}/runs/:id/diff`, async (req, reply) => {
439
+ const { id } = req.params;
440
+ const q = req.query;
441
+ const rawPath = singlePathQ(q.path);
442
+ if (rawPath === REPEATED_PATH) {
443
+ return reply.code(400).send({ error: '`path` may be given at most once' });
444
+ }
445
+ const rawBase = singlePathQ(q.base);
446
+ if (rawBase === REPEATED_PATH) {
447
+ return reply.code(400).send({ error: '`base` may be given at most once' });
448
+ }
449
+ const resolved = await resolveRunPath(reply, id, rawPath);
450
+ if (resolved === null)
451
+ return reply;
452
+ const workdir = resolved.session.workdir;
453
+ if (typeof workdir !== 'string' || workdir.length === 0) {
454
+ return reply.code(409).send({ error: `run ${id} has no workdir — nothing to diff` });
455
+ }
456
+ if (!existsSync(workdir)) {
457
+ return reply.code(409).send({ error: `run ${id}'s workdir no longer exists: ${workdir}` });
458
+ }
459
+ // Narrowing is WORKTREE-scoped: a contained-but-outside-the-worktree path (extra write
460
+ // root / repo root) is a valid FILE read but has no meaning as a diff pathspec — rejected
461
+ // explicitly here rather than handing git a `../`-prefixed pathspec and surfacing its
462
+ // "outside repository" error as a 500 (Copilot, #305).
463
+ if (resolved.target !== undefined && !isInsideRoot(workdir, resolved.target)) {
464
+ return reply.code(400).send({
465
+ error: `\`path\` must be inside the run's worktree to diff: ${workdir}`,
466
+ });
467
+ }
468
+ const rel = resolved.target === undefined ? undefined : relative(workdir, resolved.target);
469
+ try {
470
+ return await worktreeDiff(workdir, rel, rawBase);
471
+ }
472
+ catch (err) {
473
+ // Named 400s (§8.1): malformed base (not a plain ref) and well-formed-but-unresolvable
474
+ // base are both client errors, each with its error name in the body — never a git 500.
475
+ if (err instanceof InvalidDiffBaseError || err instanceof UnresolvableDiffBaseError) {
476
+ return reply.code(400).send({ error: `${err.name}: ${message(err)}` });
477
+ }
478
+ if (err.code === 'ENOENT') {
479
+ return reply.code(500).send({ error: 'git executable not found on server' });
480
+ }
481
+ // execCapped throws (no partial output attached) past its 64 MiB daemon-wide buffer —
482
+ // beyond graceful truncation, so the answer is an explicit, actionable refusal
483
+ // rather than a generic 500 (Copilot, #305).
484
+ if (err instanceof ExecOutputTooLarge) {
485
+ return reply.code(507).send({
486
+ error: "diff output exceeds the server's execution buffer — narrow the request with ?path=",
487
+ });
488
+ }
489
+ return reply.code(500).send({ error: message(err) });
490
+ }
491
+ });
146
492
  // Registered repos → target-repo picker.
147
493
  app.get(`${V}/repos`, async () => ({ repos: await adapter.listRepos() }));
148
494
  app.post(`${V}/repos`, async (req, reply) => {
@@ -215,23 +561,148 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
215
561
  input.repoRef = b.repoRef;
216
562
  if (b.workflow !== undefined)
217
563
  input.workflow = b.workflow;
564
+ if (b.projectId !== undefined) {
565
+ input.projectId = b.projectId;
566
+ // A project is a CONTEXT (crew#326): a run filed into one should see the project's whole
567
+ // co-located graph, not just its own repo's. Resolved — never REFRESHED — at launch: a
568
+ // launch that silently indexed N repos would block this response for as long as the slowest
569
+ // of them takes, so a missing or stale graph degrades to the repo graph and says why.
570
+ //
571
+ // The decision is recorded either way. "This run sees the project" and "this run sees one
572
+ // repo, because X" are both facts about what the run could observe, and the second is the one
573
+ // an operator needs when a worker reports that a sibling repo does not exist.
574
+ const decision = await resolveProjectGraphBinding(adapter, b.projectId, b.repoRef);
575
+ if (decision.binding !== null)
576
+ input.projectGraph = decision.binding;
577
+ req.log.info({ runId: input.sessionId, projectId: b.projectId, repoRef: b.repoRef ?? null }, `run ${input.sessionId}: ${decision.reason}`);
578
+ }
579
+ if (b.deliver !== undefined)
580
+ input.deliver = b.deliver;
581
+ // Retry lineage (DES-UX-001 §8.3): `retryOf` must name an EXISTING run — recording lineage
582
+ // to a run that never existed would be provenance pointing at nothing, so the launch fails
583
+ // loudly (400, before anything is committed) rather than filing a dangling edge.
584
+ if (b.retryOf !== undefined) {
585
+ const known = await adapter.sessions();
586
+ if (!known.includes(b.retryOf)) {
587
+ return reply.code(400).send({
588
+ error: `retryOf names an unknown run: ${b.retryOf} — lineage must point at an existing run id`,
589
+ });
590
+ }
591
+ }
218
592
  try {
219
593
  const runId = await adapter.launchRun(input);
594
+ // Who launched it — the engine's LaunchOptions carries no actor field
595
+ // (checked, wicked-core-ts 0.6.0), so the crew-side trail is the system
596
+ // of record for run provenance (task #88).
597
+ audit.record('run.launched', actorOf(req), {
598
+ runId,
599
+ detail: {
600
+ ...(b.workflow !== undefined ? { workflow: b.workflow } : {}),
601
+ ...(b.repoRef !== undefined ? { repoRef: b.repoRef } : {}),
602
+ ...(b.projectId !== undefined ? { projectId: b.projectId } : {}),
603
+ ...(b.deliver !== undefined ? { deliver: b.deliver } : {}),
604
+ // CREW-UX-3: the trail is the durable record of lineage — the retry index (and a
605
+ // restarted daemon's hydrate) reads it back from exactly this entry.
606
+ ...(b.retryOf !== undefined ? { retryOf: b.retryOf } : {}),
607
+ },
608
+ });
609
+ if (b.retryOf !== undefined)
610
+ retryIndex.set(runId, b.retryOf);
611
+ if (b.projectId !== undefined) {
612
+ // The engine attached the crew.run membership ATOMICALLY with the launch record
613
+ // (DES-PROJECT-001 §2.2) — this is the post-commit half: tag future /ws frames and
614
+ // emit the membership event (auto-attach at launch is an attach, §4).
615
+ projects.index.set(runId, b.projectId);
616
+ projects.bus?.emit(MEMBERSHIP_ATTACHED,
617
+ // The AUTHENTICATED actor id, not a caller-supplied string — locked
618
+ // decision #6 replaces spoofable actor strings on the event surface.
619
+ { project_id: b.projectId, member: { kind: 'crew.run', ref: runId }, actor: actorOf(req).id }, membershipAttachedKey(b.projectId, 'crew.run', runId, Date.now()));
620
+ }
220
621
  return reply.code(201).send({ runId });
221
622
  }
222
623
  catch (err) {
223
624
  const msg = message(err);
625
+ // An unknown/archived project is a state conflict on a real resource, not a malformed
626
+ // request: 404/409 per the projects error mapping; anything else keeps the launch 400/409.
627
+ if (b.projectId !== undefined && /project.*not registered/i.test(msg)) {
628
+ return reply.code(404).send({ error: msg });
629
+ }
630
+ if (b.projectId !== undefined && /archived|'default'|synthesized/i.test(msg)) {
631
+ return reply.code(409).send({ error: msg });
632
+ }
224
633
  const busy = /busy|in flight|already/i.test(msg);
225
634
  return reply.code(busy ? 409 : 400).send({ error: msg });
226
635
  }
227
636
  });
228
637
  // Run list (replaces GET /sessions). Actionable-first; reconciles the gate and elicitation caches
229
638
  // so that terminal-run entries are pruned even when their terminal CoreEvent was missed.
230
- app.get(`${V}/runs`, async () => {
639
+ app.get(`${V}/runs`, async (req) => {
231
640
  const views = await adapter.sessionsDetail();
232
641
  gateCache.reconcile(views);
233
642
  elicitationCache.reconcile(views);
234
- return { runs: sortActionableFirst(views) };
643
+ // Archived runs are WRITTEN OFF (crew#265): excluded from the default view so finished
644
+ // history doesn't drown live signal, returned in full with `?include=archived`. The caches
645
+ // above reconcile over the COMPLETE set either way — a gate on an archived run must still
646
+ // resolve, not leak.
647
+ // Fastify parses a REPEATED query param as string[] — normalize so `?include=archived`
648
+ // and `?include=archived&include=archived` behave identically (Copilot).
649
+ const { include } = req.query;
650
+ const includeArchived = (Array.isArray(include) ? include : [include]).includes('archived');
651
+ const visible = includeArchived
652
+ ? views
653
+ : views.filter((v) => v.session.archived_at == null);
654
+ return { runs: sortActionableFirst(visible).map(decorateRun) };
655
+ });
656
+ // ── Run archival (crew#265) — write-off, not delete ────────────────────────
657
+ const ArchiveSchema = z.object({
658
+ archived: z.boolean(),
659
+ note: z.string().max(500).optional(),
660
+ }).strict();
661
+ app.post(`${V}/runs/:id/archive`, async (req, reply) => {
662
+ const { id } = req.params;
663
+ const parsed = ArchiveSchema.safeParse(req.body ?? {});
664
+ if (!parsed.success) {
665
+ return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
666
+ }
667
+ try {
668
+ const found = await adapter.archiveRun(id, parsed.data.archived, parsed.data.note);
669
+ if (!found)
670
+ return reply.code(404).send({ error: 'Run not found' });
671
+ return { runId: id, archived: parsed.data.archived };
672
+ }
673
+ catch (e) {
674
+ const msg = e instanceof Error ? e.message : String(e);
675
+ // The engine names a NON-terminal status ("run X is Executing — only a terminal run…"):
676
+ // a state conflict on a real resource, not a bad request (write-off must never hide
677
+ // live work). Anything else — including the old-addon guard — is a real 500.
678
+ if (/only a terminal run/i.test(msg))
679
+ return reply.code(409).send({ error: msg });
680
+ return reply.code(500).send({ error: msg });
681
+ }
682
+ });
683
+ // Bulk write-off for campaign backlogs — explicit ids only, never implicit age selection.
684
+ const BulkArchiveSchema = z.object({
685
+ ids: z.array(z.string().min(1)).min(1).max(200),
686
+ note: z.string().max(500).optional(),
687
+ }).strict();
688
+ app.post(`${V}/runs/archive`, async (req, reply) => {
689
+ const parsed = BulkArchiveSchema.safeParse(req.body ?? {});
690
+ if (!parsed.success) {
691
+ return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
692
+ }
693
+ // Per-id outcomes rather than all-or-nothing: a batch of 45 with one live run in it
694
+ // should archive 44 and NAME the refusal, not roll back the write-off.
695
+ const results = [];
696
+ for (const id of parsed.data.ids) {
697
+ try {
698
+ const found = await adapter.archiveRun(id, true, parsed.data.note);
699
+ results.push(found ? { id, ok: true } : { id, ok: false, error: 'not found' });
700
+ }
701
+ catch (e) {
702
+ results.push({ id, ok: false, error: e instanceof Error ? e.message : String(e) });
703
+ }
704
+ }
705
+ return { results, archived: results.filter((r) => r.ok).length };
235
706
  });
236
707
  // One run's detail.
237
708
  app.get(`${V}/runs/:id`, async (req, reply) => {
@@ -240,7 +711,39 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
240
711
  const run = views.find((v) => v.session.id === id);
241
712
  if (!run)
242
713
  return reply.code(404).send({ error: 'Run not found' });
243
- return { run };
714
+ return { run: decorateRun(run) };
715
+ });
716
+ // Durable pre-gate guidance (DES-UX-002 §7.2 — spec'd there as CREW-UX-4, implemented as
717
+ // CREW-UX-7 because crew#308 already spent that id; see guidance-index.ts). Upserts the ONE
718
+ // operator note on the run; the empty string clears it. The durable record is the
719
+ // `guidance.set` audit entry (actor + full text); the index is the read-side layer the run
720
+ // DTOs echo it from.
721
+ //
722
+ // GOVERNANCE ISOLATION (deliberate): the governance gate does NOT read this field — the
723
+ // engine's `LaunchOptions` never sees it, and no gate evaluation consults it. It is
724
+ // operator-visible context only; the amend text at gate decision (`POST /runs/:id/gate`)
725
+ // stays the ONE injection point. The studio pre-populates its steer textarea from this note,
726
+ // and injection still happens only through the governed amend.
727
+ app.put(`${V}/runs/:id/guidance`, async (req, reply) => {
728
+ const { id } = req.params;
729
+ const parsed = GuidanceSchema.safeParse(req.body);
730
+ if (!parsed.success) {
731
+ return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
732
+ }
733
+ const { text } = parsed.data;
734
+ if (Buffer.byteLength(text, 'utf8') > GUIDANCE_MAX_BYTES) {
735
+ return reply.code(400).send({
736
+ error: `guidance exceeds the ${GUIDANCE_MAX_BYTES}-byte cap — a note this size belongs in the problem statement or a linked doc`,
737
+ });
738
+ }
739
+ const known = await adapter.sessions();
740
+ if (!known.includes(id))
741
+ return reply.code(404).send({ error: 'Run not found' });
742
+ // The trail is the durable record (CREW-UX-3 posture): a restarted daemon's
743
+ // GuidanceIndex.hydrate reads the note back from exactly this entry.
744
+ audit.record('guidance.set', actorOf(req), { runId: id, detail: { text } });
745
+ guidanceIndex.set(id, text);
746
+ return { runId: id, guidance: text };
244
747
  });
245
748
  // ── Chat sessions (crew#165): warm ACP seat pool + group fan-out (core#134) ──
246
749
  // A chat is NOT a run: no council, no gates, no units. Seats warm on open;
@@ -249,6 +752,8 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
249
752
  chatId: z.string().min(1).max(128).regex(/^[A-Za-z0-9._-]+$/).optional(),
250
753
  clis: z.array(z.string().min(1)).min(1).max(8).optional(),
251
754
  repoRef: z.string().optional(),
755
+ /** DES-PROJECT-001 §2.2 — file the chat into a project (`crew.chat` membership). */
756
+ projectId: z.string().min(1).optional(),
252
757
  }).strict();
253
758
  app.post(`${V}/chats`, async (req, reply) => {
254
759
  const parsed = ChatOpenSchema.safeParse(req.body ?? {});
@@ -265,12 +770,48 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
265
770
  return reply.code(404).send({ error: `Repo ${b.repoRef} not found` });
266
771
  cwd = repo.root_path;
267
772
  }
773
+ // Validate the project BEFORE opening seats: a chat has no launch record for the engine to
774
+ // attach against atomically (chats are an in-memory seat pool), so the route validates
775
+ // up-front and attaches right after open — the one non-atomic attach, documented in the ADR
776
+ // changelog. Fail here and no seats were warmed for a filing that could never happen.
777
+ if (b.projectId !== undefined) {
778
+ try {
779
+ const project = await adapter.projectGet(b.projectId);
780
+ if (project === null) {
781
+ return reply.code(404).send({ error: `Project ${b.projectId} not found` });
782
+ }
783
+ if (project.status === 'archived') {
784
+ return reply
785
+ .code(409)
786
+ .send({ error: `project ${b.projectId} is archived and blocks new attachments` });
787
+ }
788
+ }
789
+ catch (err) {
790
+ return reply.code(501).send({ error: message(err) });
791
+ }
792
+ }
268
793
  const clis = b.clis ??
269
794
  CoreAdapter.roster()
270
795
  .map((s) => s.key)
271
796
  .filter((k) => typeof k === 'string');
272
797
  try {
273
798
  const seats = await adapter.chatOpen(chatId, clis, cwd);
799
+ if (b.projectId !== undefined) {
800
+ try {
801
+ const { member, created } = await adapter.projectMemberAttach(b.projectId, 'crew.chat', chatId);
802
+ if (created) {
803
+ projects.index.set(chatId, b.projectId);
804
+ 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));
805
+ }
806
+ }
807
+ catch (err) {
808
+ // The chat is open and usable; the filing failed. Say so instead of failing the open —
809
+ // the caller can re-attach via POST /projects/:id/members.
810
+ return reply
811
+ .code(201)
812
+ .send({ chatId, seats, projectAttachError: message(err) });
813
+ }
814
+ }
274
815
  return reply.code(201).send({ chatId, seats });
275
816
  }
276
817
  catch (err) {
@@ -377,6 +918,44 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
377
918
  .header('Content-Disposition', `attachment; filename="${evidenceFilename(id)}"`)
378
919
  .send(bundle);
379
920
  });
921
+ // ── Acceptance gate read (Phase 6a) ─────────────────────────────────────────
922
+ // The QE evidence ledger's verdict + manifest for THIS run's repo, plus the
923
+ // gate's deny-dominates resolution of the workflow's acceptance requirement.
924
+ // Sits beside `/runs/:id/evidence` deliberately: evidence is what the RUN
925
+ // recorded about itself; acceptance is what the QE pipeline recorded about
926
+ // the repo the run worked on — two different systems of record, two routes.
927
+ //
928
+ // Always a 200 for a known run: "no ledger", "no verdict" and "FAIL" are
929
+ // real answers about the gate (each a deny with its own reason), not errors
930
+ // in the request. Only an unknown run 404s. `?qeRun=<id>` pins the read to
931
+ // one QE run's newest verdict instead of the repo's newest overall.
932
+ app.get(`${V}/runs/:id/acceptance`, async (req, reply) => {
933
+ const { id } = req.params;
934
+ const q = req.query;
935
+ const qeRunRaw = Array.isArray(q.qeRun) ? q.qeRun[0] : q.qeRun;
936
+ const qeRunId = qeRunRaw?.trim();
937
+ const views = await adapter.sessionsDetail();
938
+ const run = views.find((v) => v.session.id === id);
939
+ if (!run)
940
+ return reply.code(404).send({ error: 'Run not found' });
941
+ // `sessionsDetail()` patches workflow_id back to the definition name for
942
+ // BUILT-INS; runs of user-registered workflows still carry the instance id,
943
+ // so resolve by phase sequence over the full registry. A free-text run
944
+ // resolves to no workflow, which reads as "declares no requirement".
945
+ const workflow = resolveRunWorkflow(run, adapter.listWorkflows());
946
+ let repo = null;
947
+ if (run.session.repo_ref !== null) {
948
+ const repos = await adapter.listRepos();
949
+ repo = repos.find((r) => r.id === run.session.repo_ref) ?? null;
950
+ }
951
+ return buildAcceptanceView({
952
+ runId: id,
953
+ repo,
954
+ workflow,
955
+ gateEvents: qeGateEvents,
956
+ ...(qeRunId !== undefined && qeRunId !== '' ? { qeRunId } : {}),
957
+ });
958
+ });
380
959
  // The steering gate (§11.1). approve+amend = approve-with-steer; approve:false = reject (cancels).
381
960
  app.post(`${V}/runs/:id/gate`, async (req, reply) => {
382
961
  const { id } = req.params;
@@ -395,6 +974,17 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
395
974
  }
396
975
  try {
397
976
  const status = await adapter.confirmGate(id, parsed.data.approve, parsed.data.amend);
977
+ // WHO approved/rejected — the gate-decision audit (task #88). The engine
978
+ // records THAT the gate resolved (interaction_requests / gateDecided);
979
+ // only this HTTP layer knows the authenticated principal behind it.
980
+ audit.record('gate.decided', actorOf(req), {
981
+ runId: id,
982
+ detail: {
983
+ approve: parsed.data.approve,
984
+ ...(parsed.data.amend !== undefined ? { amend: parsed.data.amend } : {}),
985
+ status,
986
+ },
987
+ });
398
988
  return reply.send({ status });
399
989
  }
400
990
  catch (err) {
@@ -410,6 +1000,7 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
410
1000
  return reply.code(404).send({ error: 'Run not found' });
411
1001
  try {
412
1002
  const status = await adapter.cancelRun(id);
1003
+ audit.record('run.cancelled', actorOf(req), { runId: id, detail: { status } });
413
1004
  return reply.send({ status });
414
1005
  }
415
1006
  catch (err) {
@@ -425,9 +1016,14 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
425
1016
  if (!run)
426
1017
  return reply.code(404).send({ error: 'Run not found' });
427
1018
  try {
428
- const status = run.session.status === 'awaiting_human'
429
- ? await adapter.confirmGate(id, true)
430
- : await adapter.resumeRun(id);
1019
+ const gated = run.session.status === 'awaiting_human';
1020
+ const status = gated ? await adapter.confirmGate(id, true) : await adapter.resumeRun(id);
1021
+ // A resume of a gated run IS a gate approval — audit it as one, so the
1022
+ // "who approved" trail has no side door (task #88).
1023
+ audit.record(gated ? 'gate.decided' : 'run.resumed', actorOf(req), {
1024
+ runId: id,
1025
+ detail: gated ? { approve: true, via: 'resume', status } : { status },
1026
+ });
431
1027
  return reply.send({ status });
432
1028
  }
433
1029
  catch (err) {
@@ -448,6 +1044,7 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
448
1044
  return reply.code(404).send({ error: 'Run not found' });
449
1045
  try {
450
1046
  await adapter.injectWorkerMessage(id, parsed.data.message, parsed.data.target);
1047
+ audit.record('run.injected', actorOf(req), { runId: id, detail: { target: parsed.data.target } });
451
1048
  return reply.send({ status: 'ok' });
452
1049
  }
453
1050
  catch (err) {
@@ -481,6 +1078,28 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
481
1078
  if (run.session.status !== 'awaiting_human') {
482
1079
  return reply.code(404).send({ error: 'No open gate for this run' });
483
1080
  }
1081
+ // DURABLE TRUTH FIRST (DES-PROJECT-001 §5.3): the engine persists the open prompt in
1082
+ // `interaction_requests`, written in the same transaction as the `awaiting_human` pause —
1083
+ // so a daemon restart reads it back directly instead of replaying the event log. The cache
1084
+ // adopts the row (latency layer over durable truth — its comments finally true). The replay
1085
+ // below stays as the FALLBACK, not just for engines predating the binding: a run parked
1086
+ // BEFORE the engine grew the table is awaiting_human with no row, and answering 404 there
1087
+ // would re-open FINDING-051 for exactly the runs mid-upgrade. Row → serve it; no row →
1088
+ // fall through and let the log speak.
1089
+ const durableRows = typeof adapter.interactionRequests === 'function'
1090
+ ? await adapter.interactionRequests(id, 'open')
1091
+ : null;
1092
+ const durableGate = durableRows?.find((r) => r.kind === 'gate');
1093
+ if (durableGate !== undefined) {
1094
+ const entry = {
1095
+ ord: durableGate.ord ?? 0,
1096
+ prompt: durableGate.prompt,
1097
+ lifecycle: 'open',
1098
+ receivedAt: new Date(durableGate.created_at).toISOString(),
1099
+ };
1100
+ gateCache.adopt(id, entry);
1101
+ return { runId: id, ...entry };
1102
+ }
484
1103
  const events = await adapter.runEvents(id);
485
1104
  if (events === null) {
486
1105
  // Now — and only now — 503 is the honest answer: this run really is holding for a human, and
@@ -515,6 +1134,25 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
515
1134
  const ids = await adapter.sessions();
516
1135
  if (!ids.includes(id))
517
1136
  return reply.code(404).send({ error: 'Run not found' });
1137
+ // Durable probe (DES-PROJECT-001 §5.3): `interaction_requests` reserves kind `elicitation`.
1138
+ // The engine writes no elicitation rows yet (its elicitation surface is future work — the
1139
+ // resolve path still answers 501), so this read is empty today; it exists so the cache is
1140
+ // STRUCTURALLY a latency layer, and the day the engine writes the rows, restart survival
1141
+ // holds here exactly as it does for gates, with no route change. Guarded like `runEvents`:
1142
+ // a partial-stub adapter (tests) or a pre-0.6.0 addon simply has no durable half.
1143
+ const durable = typeof adapter.interactionRequests === 'function'
1144
+ ? await adapter.interactionRequests(id, 'open')
1145
+ : null;
1146
+ const pending = durable?.find((r) => r.kind === 'elicitation');
1147
+ if (pending !== undefined) {
1148
+ return {
1149
+ runId: id,
1150
+ elicitationId: pending.id,
1151
+ message: pending.prompt,
1152
+ options: null,
1153
+ receivedAt: new Date(pending.created_at).toISOString(),
1154
+ };
1155
+ }
518
1156
  return reply.code(404).send({ error: 'No pending elicitation for this run' });
519
1157
  });
520
1158
  const ElicitationRespondSchema = z
@@ -588,6 +1226,10 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
588
1226
  return reply.code(code).send({ error: message(err) });
589
1227
  }
590
1228
  // 7. Done.
1229
+ audit.record('elicitation.resolved', actorOf(req), {
1230
+ runId: id,
1231
+ detail: { elicitationId: taken.entry.elicitationId, action: body.action },
1232
+ });
591
1233
  return { status: 'resolved' };
592
1234
  });
593
1235
  // The durable history of one run (FINDING-057).
@@ -701,7 +1343,9 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
701
1343
  // ── Governance writes (crew#42) ────────────────────────────────────────────
702
1344
  app.post(`${V}/governance/policies`, async (req, reply) => {
703
1345
  try {
704
- await adapter.upsertPolicy(req.body);
1346
+ const policy = req.body;
1347
+ await adapter.upsertPolicy(policy);
1348
+ audit.record('governance.policy.upserted', actorOf(req), { detail: { id: policy?.id } });
705
1349
  return { status: 'ok' };
706
1350
  }
707
1351
  catch (err) {
@@ -710,7 +1354,9 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
710
1354
  });
711
1355
  app.post(`${V}/governance/rules`, async (req, reply) => {
712
1356
  try {
713
- await adapter.upsertConformanceRule(req.body);
1357
+ const rule = req.body;
1358
+ await adapter.upsertConformanceRule(rule);
1359
+ audit.record('governance.rule.upserted', actorOf(req), { detail: { id: rule?.id } });
714
1360
  return { status: 'ok' };
715
1361
  }
716
1362
  catch (err) {
@@ -725,6 +1371,7 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
725
1371
  const existed = await adapter.retirePolicy(id);
726
1372
  if (!existed)
727
1373
  return reply.code(404).send({ error: `policy '${id}' not found` });
1374
+ audit.record('governance.policy.retired', actorOf(req), { detail: { id } });
728
1375
  return { status: 'retired', id };
729
1376
  }
730
1377
  catch (err) {
@@ -737,6 +1384,7 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
737
1384
  const existed = await adapter.retireConformanceRule(id);
738
1385
  if (!existed)
739
1386
  return reply.code(404).send({ error: `conformance rule '${id}' not found` });
1387
+ audit.record('governance.rule.retired', actorOf(req), { detail: { id } });
740
1388
  return { status: 'retired', id };
741
1389
  }
742
1390
  catch (err) {
@@ -780,6 +1428,7 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
780
1428
  }
781
1429
  try {
782
1430
  const id = await adapter.registerWorkflow(body);
1431
+ audit.record('workflow.registered', actorOf(req), { detail: { id } });
783
1432
  return reply.code(201).send({ id, status: 'registered' });
784
1433
  }
785
1434
  catch (err) {
@@ -1129,26 +1778,125 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
1129
1778
  }
1130
1779
  });
1131
1780
  // ── System settings ──────────────────────────────────────────────────────────
1781
+ // The store is SHARED with the skin (crew#323): beside the engine's own keys it round-trips
1782
+ // the studio's `studio.*` preference blobs verbatim — see `CrewSystemSettings`'s index
1783
+ // signature in core/types.ts, which states that rather than leaving it to a client comment.
1132
1784
  app.get(`${V}/settings`, async () => ({ settings: await adapter.getSettings() }));
1133
1785
  app.put(`${V}/settings`, async (req, reply) => {
1134
1786
  const patch = req.body;
1135
1787
  if (typeof patch !== 'object' || patch === null || Array.isArray(patch)) {
1136
1788
  return reply.code(400).send({ error: 'body must be a JSON object' });
1137
1789
  }
1138
- if ('graphNodeLimit' in patch) {
1790
+ if (Object.hasOwn(patch, 'graphNodeLimit')) {
1139
1791
  const limit = patch.graphNodeLimit;
1140
1792
  if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 20 || limit > 500) {
1141
1793
  return reply.code(400).send({ error: 'graphNodeLimit must be an integer between 20 and 500' });
1142
1794
  }
1143
1795
  }
1144
- // Only allow known keys through.
1145
- const allowed = ['graphNodeLimit'];
1796
+ if (Object.hasOwn(patch, 'worker_config_root')) {
1797
+ const root = patch.worker_config_root;
1798
+ if (typeof root !== 'string' || (root !== '' && !isAbsolute(root))) {
1799
+ return reply.code(400).send({
1800
+ error: 'worker_config_root must be an absolute path, or "" for the default (~/.wicked-worker)',
1801
+ });
1802
+ }
1803
+ }
1804
+ // workerStallMinutes (crew#287): the stall watchdog's silence threshold. Bounded to a day —
1805
+ // a huge value is "off in practice", which should be a deliberate choice, not a typo.
1806
+ if (Object.hasOwn(patch, 'workerStallMinutes')) {
1807
+ const mins = patch.workerStallMinutes;
1808
+ if (typeof mins !== 'number' || !Number.isInteger(mins) || mins < 1 || mins > 1440) {
1809
+ return reply
1810
+ .code(400)
1811
+ .send({ error: 'workerStallMinutes must be an integer between 1 and 1440' });
1812
+ }
1813
+ }
1814
+ // Skin-owned keys (crew#323): allowed through, but VALIDATED rather than trusted. The
1815
+ // daemon does not read these values, so the only two things it can check are the two that
1816
+ // can hurt it — a value it cannot persist, and a value big enough to bloat settings.json.
1817
+ // Both answer 400 naming the key: silence is exactly what made #323 invisible for a whole
1818
+ // campaign of appearance work.
1819
+ const studioKeys = Object.keys(patch).filter((k) => STUDIO_SETTINGS_KEY.test(k));
1820
+ for (const key of studioKeys) {
1821
+ const value = patch[key];
1822
+ // `undefined` from a throwing/circular value AND from a value JSON.stringify simply
1823
+ // drops (a function, a symbol) — both are unpersistable, both are refused.
1824
+ let encoded;
1825
+ try {
1826
+ encoded = JSON.stringify(value);
1827
+ }
1828
+ catch {
1829
+ encoded = undefined;
1830
+ }
1831
+ if (encoded === undefined) {
1832
+ return reply.code(400).send({ error: `${key} must be a JSON-serializable value` });
1833
+ }
1834
+ const bytes = Buffer.byteLength(encoded, 'utf8');
1835
+ if (bytes > STUDIO_SETTINGS_MAX_BYTES) {
1836
+ return reply.code(400).send({
1837
+ error: `${key} is ${bytes} bytes of JSON, over the ${STUDIO_SETTINGS_MAX_BYTES}-byte per-key cap on studio.* settings`,
1838
+ });
1839
+ }
1840
+ }
1841
+ // Only known engine keys and validated `studio.*` keys are persisted. A key that is
1842
+ // NEITHER is dropped, not refused: request bodies are forward-additive too (DES-STUDIO-001
1843
+ // §5.1), so an older daemon meeting a newer client's engine key must not fail the whole
1844
+ // patch and take the caller's other keys down with it. The cost is that a typo goes
1845
+ // unnoticed on the wire — so the dropped keys are NAMED in the audit entry below.
1846
+ const allowed = [
1847
+ 'graphNodeLimit',
1848
+ 'worker_config_root',
1849
+ 'workerStallMinutes',
1850
+ ];
1146
1851
  const safe = {};
1147
1852
  for (const key of allowed) {
1148
- if (key in patch)
1853
+ // `Object.hasOwn`, NOT `key in patch` (Copilot on #324): `in` walks the prototype chain, so
1854
+ // a body whose prototype carries an engine key would be persisted from a value the caller
1855
+ // never sent. Not reachable through the default JSON parser — `JSON.parse` yields a plain
1856
+ // object and Fastify refuses `__proto__` — but this route already accepts custom
1857
+ // content-type parsers, which can produce non-plain objects. Own properties only, and the
1858
+ // same spelling the `ignored` filter below uses, so the two can never disagree about what
1859
+ // "present" means.
1860
+ if (Object.hasOwn(patch, key))
1149
1861
  safe[key] = patch[key];
1150
1862
  }
1151
- return { settings: await adapter.updateSettings(safe) };
1863
+ for (const key of studioKeys) {
1864
+ safe[key] = patch[key];
1865
+ }
1866
+ // `Object.hasOwn`, NOT `k in safe`: `in` walks the prototype chain, so a dropped key named
1867
+ // `toString` / `valueOf` / `constructor` would test as "kept" and vanish from `ignored` —
1868
+ // the exact silent drop this route exists to end.
1869
+ const ignored = Object.keys(patch).filter((k) => !Object.hasOwn(safe, k));
1870
+ const settings = await adapter.updateSettings(safe);
1871
+ // Re-apply the worker-config root to this process's env (seat sign-in). The engine reads
1872
+ // WICKED_WORKER_HOME per worker spawn — never cached — so this alone makes the change live
1873
+ // at the next spawn: no daemon restart, no engine restart.
1874
+ applyWorkerConfigRoot(settings.worker_config_root);
1875
+ // `changed` names every persisted key, engine and `studio.*` alike; `ignored` (present only
1876
+ // when there is one) is where a dropped unknown key stops being invisible.
1877
+ audit.record('settings.updated', actorOf(req), {
1878
+ detail: { changed: Object.keys(safe), ...(ignored.length > 0 ? { ignored } : {}) },
1879
+ });
1880
+ return { settings };
1881
+ });
1882
+ // ── Projects (DES-PROJECT-001) — the 9-route experience-plane surface ────────
1883
+ registerProjectRoutes(app, adapter, { ...projects, settings: projectSettings }, security);
1884
+ // ── The wicked-interactive bridge, reverse-proxied (DES-MERGE-001 §5.3/§7.2) ──
1885
+ // Mounted BESIDE the routes above and under the same `${V}` prefix, so it inherits one
1886
+ // origin, one auth hook, and one CORS posture — the whole point of slice 1.
1887
+ registerInteractiveProxy(app, adapter, {
1888
+ settings: projectSettings,
1889
+ pool: runtime.interactiveBridges ??
1890
+ new InteractiveBridgePool({
1891
+ log: (m) => app.log.warn(m),
1892
+ debug: (m) => app.log.debug(m),
1893
+ // #298: the daemon's own origin, read LAZILY off the bound server — the pool is built
1894
+ // before `listen`, but only consulted while serving a request, i.e. once bound. The
1895
+ // pool POSTs it to the bridge's /api/studio-origin on start/adopt so the bridge's
1896
+ // `GET /` redirects into studio.
1897
+ studioOrigin: () => boundOrigin(app.server.address()),
1898
+ }),
1899
+ log: (m) => app.log.warn(m),
1152
1900
  });
1153
1901
  }
1154
1902
  //# sourceMappingURL=routes.js.map