wicked-crew 0.6.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 (99) hide show
  1. package/dist/api/audit.d.ts +13 -0
  2. package/dist/api/audit.d.ts.map +1 -1
  3. package/dist/api/audit.js +18 -2
  4. package/dist/api/audit.js.map +1 -1
  5. package/dist/api/guidance-index.d.ts +39 -0
  6. package/dist/api/guidance-index.d.ts.map +1 -0
  7. package/dist/api/guidance-index.js +67 -0
  8. package/dist/api/guidance-index.js.map +1 -0
  9. package/dist/api/open-path.d.ts +16 -0
  10. package/dist/api/open-path.d.ts.map +1 -1
  11. package/dist/api/open-path.js +22 -0
  12. package/dist/api/open-path.js.map +1 -1
  13. package/dist/api/retry-index.d.ts +30 -0
  14. package/dist/api/retry-index.d.ts.map +1 -0
  15. package/dist/api/retry-index.js +45 -0
  16. package/dist/api/retry-index.js.map +1 -0
  17. package/dist/api/routes.d.ts +53 -1
  18. package/dist/api/routes.d.ts.map +1 -1
  19. package/dist/api/routes.js +352 -23
  20. package/dist/api/routes.js.map +1 -1
  21. package/dist/api/run-files.d.ts +63 -0
  22. package/dist/api/run-files.d.ts.map +1 -0
  23. package/dist/api/run-files.js +271 -0
  24. package/dist/api/run-files.js.map +1 -0
  25. package/dist/api/server.d.ts +79 -0
  26. package/dist/api/server.d.ts.map +1 -1
  27. package/dist/api/server.js +135 -6
  28. package/dist/api/server.js.map +1 -1
  29. package/dist/api/stall-watchdog.d.ts +62 -0
  30. package/dist/api/stall-watchdog.d.ts.map +1 -0
  31. package/dist/api/stall-watchdog.js +138 -0
  32. package/dist/api/stall-watchdog.js.map +1 -0
  33. package/dist/cli/index.js +78 -13
  34. package/dist/cli/index.js.map +1 -1
  35. package/dist/core/adapter.d.ts +24 -10
  36. package/dist/core/adapter.d.ts.map +1 -1
  37. package/dist/core/adapter.js +191 -30
  38. package/dist/core/adapter.js.map +1 -1
  39. package/dist/core/bridge-reaper.d.ts +134 -0
  40. package/dist/core/bridge-reaper.d.ts.map +1 -0
  41. package/dist/core/bridge-reaper.js +286 -0
  42. package/dist/core/bridge-reaper.js.map +1 -0
  43. package/dist/core/deliver.d.ts +118 -0
  44. package/dist/core/deliver.d.ts.map +1 -0
  45. package/dist/core/deliver.js +241 -0
  46. package/dist/core/deliver.js.map +1 -0
  47. package/dist/core/deliverable-floor.d.ts +103 -0
  48. package/dist/core/deliverable-floor.d.ts.map +1 -0
  49. package/dist/core/deliverable-floor.js +173 -0
  50. package/dist/core/deliverable-floor.js.map +1 -0
  51. package/dist/core/exec.d.ts +2 -0
  52. package/dist/core/exec.d.ts.map +1 -1
  53. package/dist/core/exec.js.map +1 -1
  54. package/dist/core/types.d.ts +79 -1
  55. package/dist/core/types.d.ts.map +1 -1
  56. package/dist/core/types.js +3 -0
  57. package/dist/core/types.js.map +1 -1
  58. package/dist/interactive/bridge-pool.d.ts +28 -0
  59. package/dist/interactive/bridge-pool.d.ts.map +1 -1
  60. package/dist/interactive/bridge-pool.js +67 -10
  61. package/dist/interactive/bridge-pool.js.map +1 -1
  62. package/dist/interactive/chat-events.d.ts +207 -0
  63. package/dist/interactive/chat-events.d.ts.map +1 -0
  64. package/dist/interactive/chat-events.js +769 -0
  65. package/dist/interactive/chat-events.js.map +1 -0
  66. package/dist/interactive/demo-events.d.ts +283 -0
  67. package/dist/interactive/demo-events.d.ts.map +1 -0
  68. package/dist/interactive/demo-events.js +889 -0
  69. package/dist/interactive/demo-events.js.map +1 -0
  70. package/dist/interactive/draft-events.d.ts +87 -7
  71. package/dist/interactive/draft-events.d.ts.map +1 -1
  72. package/dist/interactive/draft-events.js +352 -49
  73. package/dist/interactive/draft-events.js.map +1 -1
  74. package/dist/interactive/edit-events.d.ts +22 -0
  75. package/dist/interactive/edit-events.d.ts.map +1 -1
  76. package/dist/interactive/edit-events.js +73 -2
  77. package/dist/interactive/edit-events.js.map +1 -1
  78. package/dist/interactive/repo-snapshot.d.ts +100 -0
  79. package/dist/interactive/repo-snapshot.d.ts.map +1 -0
  80. package/dist/interactive/repo-snapshot.js +289 -0
  81. package/dist/interactive/repo-snapshot.js.map +1 -0
  82. package/dist/projects/graph-paths.d.ts +92 -0
  83. package/dist/projects/graph-paths.d.ts.map +1 -0
  84. package/dist/projects/graph-paths.js +130 -0
  85. package/dist/projects/graph-paths.js.map +1 -0
  86. package/dist/projects/graph.d.ts +179 -0
  87. package/dist/projects/graph.d.ts.map +1 -0
  88. package/dist/projects/graph.js +775 -0
  89. package/dist/projects/graph.js.map +1 -0
  90. package/dist/projects/routes.d.ts +7 -0
  91. package/dist/projects/routes.d.ts.map +1 -1
  92. package/dist/projects/routes.js +122 -0
  93. package/dist/projects/routes.js.map +1 -1
  94. package/dist/studio/assets/index-8p8uwCxG.js +530 -0
  95. package/dist/studio/assets/index-D6S9zUtO.css +32 -0
  96. package/dist/studio/index.html +5 -3
  97. package/package.json +3 -3
  98. package/dist/studio/assets/index-CCwXa1cn.js +0 -428
  99. package/dist/studio/assets/index-HWxo0h41.css +0 -32
@@ -3,7 +3,7 @@ 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 { isAbsolute, join, resolve } 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';
@@ -14,14 +14,18 @@ import { outputUnavailableReason, resolveUnit, unitKeysFor } from './unit-output
14
14
  import { execCapped, ExecOutputTooLarge } from '../core/exec.js';
15
15
  import { SeatHealthTracker } from './seat-health.js';
16
16
  import { applyWorkerConfigRoot, signedInHeuristic } from './seat-signin.js';
17
- import { isInsideRoot, openWithSystemDefault } from './open-path.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';
18
20
  import { registerProjectRoutes } from '../projects/routes.js';
19
21
  import { ProjectSettingsStore } from '../projects/settings.js';
20
- import { InteractiveBridgePool } from '../interactive/bridge-pool.js';
22
+ import { boundOrigin, InteractiveBridgePool } from '../interactive/bridge-pool.js';
21
23
  import { registerInteractiveProxy } from '../interactive/proxy-routes.js';
22
24
  import { MembershipIndex } from '../projects/membership-index.js';
23
25
  import { MEMBERSHIP_ATTACHED, membershipAttachedKey } from '../projects/events.js';
24
26
  import { AuditLog } from './audit.js';
27
+ import { RetryIndex } from './retry-index.js';
28
+ import { GuidanceIndex } from './guidance-index.js';
25
29
  import { LOCAL_ACTOR } from './auth.js';
26
30
  // Re-exported so existing `import { API_PREFIX } from './routes.js'` callers keep working; the
27
31
  // value lives in the leaf module api-prefix.ts to keep unit-output.ts out of this file's cycle.
@@ -120,11 +124,30 @@ export const LaunchSchema = z.object({
120
124
  /** DES-PROJECT-001 §2.2 — file the run into a project; membership attaches atomically with
121
125
  * the launch record. Unknown/archived ⇒ the launch fails (never a silent unfiled run). */
122
126
  projectId: z.string().min(1).optional(),
123
- }).strict();
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
+ });
124
139
  export const GateSchema = z.object({
125
140
  approve: z.boolean(),
126
141
  amend: z.string().optional(),
127
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;
128
151
  const InjectSchema = z.object({
129
152
  message: z.string().min(1),
130
153
  /** `"all"` broadcasts to every active worker; any other value is a CLI key. */
@@ -148,6 +171,26 @@ export const OpenPathSchema = z.object({
148
171
  path: z.string().min(1),
149
172
  runId: z.string().min(1).optional(),
150
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;
151
194
  /**
152
195
  * The daemon REST surface. Every endpoint is a thin wrapper over one adapter /
153
196
  * core-ts call (DES-STUDIO-001 §2). `session`/`phase` nouns are now `run`/`unit`.
@@ -172,6 +215,24 @@ runtime = {}) {
172
215
  const seatHealth = runtime.seatHealth ?? new SeatHealthTracker();
173
216
  const openWithOs = runtime.openWithOs ?? openWithSystemDefault;
174
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
+ };
175
236
  // Resolved ONCE and shared by the project routes (which read/write `interactiveRoot`) and the
176
237
  // interactive proxy (which resolves a root from it) — two stores would let a PATCH land in one
177
238
  // and the proxy keep reading the other.
@@ -261,24 +322,18 @@ runtime = {}) {
261
322
  return reply.code(400).send({ error: '`path` must be an absolute path' });
262
323
  }
263
324
  const target = resolve(rawPath);
264
- const roots = [];
325
+ let roots;
265
326
  try {
327
+ let session;
266
328
  if (runId !== undefined) {
267
329
  const views = await adapter.sessionsDetail();
268
330
  const view = views.find((v) => v.session.id === runId);
269
331
  if (view === undefined) {
270
332
  return reply.code(404).send({ error: `unknown run: ${runId}` });
271
333
  }
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
- }
334
+ session = view.session;
279
335
  }
280
- for (const repo of await adapter.listRepos())
281
- roots.push(repo.root_path);
336
+ roots = allowedRootsFor(session, await adapter.listRepos());
282
337
  }
283
338
  catch (err) {
284
339
  return reply.code(500).send({ error: message(err) });
@@ -296,6 +351,144 @@ runtime = {}) {
296
351
  }
297
352
  return { status: 'opened' };
298
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
+ });
299
492
  // Registered repos → target-repo picker.
300
493
  app.get(`${V}/repos`, async () => ({ repos: await adapter.listRepos() }));
301
494
  app.post(`${V}/repos`, async (req, reply) => {
@@ -368,8 +561,34 @@ runtime = {}) {
368
561
  input.repoRef = b.repoRef;
369
562
  if (b.workflow !== undefined)
370
563
  input.workflow = b.workflow;
371
- if (b.projectId !== undefined)
564
+ if (b.projectId !== undefined) {
372
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
+ }
373
592
  try {
374
593
  const runId = await adapter.launchRun(input);
375
594
  // Who launched it — the engine's LaunchOptions carries no actor field
@@ -381,8 +600,14 @@ runtime = {}) {
381
600
  ...(b.workflow !== undefined ? { workflow: b.workflow } : {}),
382
601
  ...(b.repoRef !== undefined ? { repoRef: b.repoRef } : {}),
383
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 } : {}),
384
607
  },
385
608
  });
609
+ if (b.retryOf !== undefined)
610
+ retryIndex.set(runId, b.retryOf);
386
611
  if (b.projectId !== undefined) {
387
612
  // The engine attached the crew.run membership ATOMICALLY with the launch record
388
613
  // (DES-PROJECT-001 §2.2) — this is the post-commit half: tag future /ws frames and
@@ -426,7 +651,7 @@ runtime = {}) {
426
651
  const visible = includeArchived
427
652
  ? views
428
653
  : views.filter((v) => v.session.archived_at == null);
429
- return { runs: sortActionableFirst(visible) };
654
+ return { runs: sortActionableFirst(visible).map(decorateRun) };
430
655
  });
431
656
  // ── Run archival (crew#265) — write-off, not delete ────────────────────────
432
657
  const ArchiveSchema = z.object({
@@ -486,7 +711,39 @@ runtime = {}) {
486
711
  const run = views.find((v) => v.session.id === id);
487
712
  if (!run)
488
713
  return reply.code(404).send({ error: 'Run not found' });
489
- 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 };
490
747
  });
491
748
  // ── Chat sessions (crew#165): warm ACP seat pool + group fan-out (core#134) ──
492
749
  // A chat is NOT a run: no council, no gates, no units. Seats warm on open;
@@ -1521,19 +1778,22 @@ runtime = {}) {
1521
1778
  }
1522
1779
  });
1523
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.
1524
1784
  app.get(`${V}/settings`, async () => ({ settings: await adapter.getSettings() }));
1525
1785
  app.put(`${V}/settings`, async (req, reply) => {
1526
1786
  const patch = req.body;
1527
1787
  if (typeof patch !== 'object' || patch === null || Array.isArray(patch)) {
1528
1788
  return reply.code(400).send({ error: 'body must be a JSON object' });
1529
1789
  }
1530
- if ('graphNodeLimit' in patch) {
1790
+ if (Object.hasOwn(patch, 'graphNodeLimit')) {
1531
1791
  const limit = patch.graphNodeLimit;
1532
1792
  if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 20 || limit > 500) {
1533
1793
  return reply.code(400).send({ error: 'graphNodeLimit must be an integer between 20 and 500' });
1534
1794
  }
1535
1795
  }
1536
- if ('worker_config_root' in patch) {
1796
+ if (Object.hasOwn(patch, 'worker_config_root')) {
1537
1797
  const root = patch.worker_config_root;
1538
1798
  if (typeof root !== 'string' || (root !== '' && !isAbsolute(root))) {
1539
1799
  return reply.code(400).send({
@@ -1541,22 +1801,82 @@ runtime = {}) {
1541
1801
  });
1542
1802
  }
1543
1803
  }
1544
- // Only allow known keys through.
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.
1545
1846
  const allowed = [
1546
1847
  'graphNodeLimit',
1547
1848
  'worker_config_root',
1849
+ 'workerStallMinutes',
1548
1850
  ];
1549
1851
  const safe = {};
1550
1852
  for (const key of allowed) {
1551
- 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))
1552
1861
  safe[key] = patch[key];
1553
1862
  }
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));
1554
1870
  const settings = await adapter.updateSettings(safe);
1555
1871
  // Re-apply the worker-config root to this process's env (seat sign-in). The engine reads
1556
1872
  // WICKED_WORKER_HOME per worker spawn — never cached — so this alone makes the change live
1557
1873
  // at the next spawn: no daemon restart, no engine restart.
1558
1874
  applyWorkerConfigRoot(settings.worker_config_root);
1559
- audit.record('settings.updated', actorOf(req), { detail: { changed: Object.keys(safe) } });
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
+ });
1560
1880
  return { settings };
1561
1881
  });
1562
1882
  // ── Projects (DES-PROJECT-001) — the 9-route experience-plane surface ────────
@@ -1566,7 +1886,16 @@ runtime = {}) {
1566
1886
  // origin, one auth hook, and one CORS posture — the whole point of slice 1.
1567
1887
  registerInteractiveProxy(app, adapter, {
1568
1888
  settings: projectSettings,
1569
- pool: runtime.interactiveBridges ?? new InteractiveBridgePool({ log: (m) => app.log.warn(m) }),
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
+ }),
1570
1899
  log: (m) => app.log.warn(m),
1571
1900
  });
1572
1901
  }