wicked-crew 0.6.0 → 0.7.1

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 (117) hide show
  1. package/README.md +103 -0
  2. package/dist/api/audit.d.ts +13 -0
  3. package/dist/api/audit.d.ts.map +1 -1
  4. package/dist/api/audit.js +18 -2
  5. package/dist/api/audit.js.map +1 -1
  6. package/dist/api/endpoint-manifest-live.d.ts +29 -0
  7. package/dist/api/endpoint-manifest-live.d.ts.map +1 -0
  8. package/dist/api/endpoint-manifest-live.js +72 -0
  9. package/dist/api/endpoint-manifest-live.js.map +1 -0
  10. package/dist/api/endpoint-manifest.d.ts +107 -0
  11. package/dist/api/endpoint-manifest.d.ts.map +1 -0
  12. package/dist/api/endpoint-manifest.js +108 -0
  13. package/dist/api/endpoint-manifest.js.map +1 -0
  14. package/dist/api/guidance-index.d.ts +39 -0
  15. package/dist/api/guidance-index.d.ts.map +1 -0
  16. package/dist/api/guidance-index.js +67 -0
  17. package/dist/api/guidance-index.js.map +1 -0
  18. package/dist/api/open-path.d.ts +16 -0
  19. package/dist/api/open-path.d.ts.map +1 -1
  20. package/dist/api/open-path.js +22 -0
  21. package/dist/api/open-path.js.map +1 -1
  22. package/dist/api/requirements.d.ts +7 -0
  23. package/dist/api/requirements.d.ts.map +1 -1
  24. package/dist/api/requirements.js +23 -2
  25. package/dist/api/requirements.js.map +1 -1
  26. package/dist/api/retry-index.d.ts +30 -0
  27. package/dist/api/retry-index.d.ts.map +1 -0
  28. package/dist/api/retry-index.js +45 -0
  29. package/dist/api/retry-index.js.map +1 -0
  30. package/dist/api/routes.d.ts +53 -1
  31. package/dist/api/routes.d.ts.map +1 -1
  32. package/dist/api/routes.js +399 -30
  33. package/dist/api/routes.js.map +1 -1
  34. package/dist/api/run-files.d.ts +63 -0
  35. package/dist/api/run-files.d.ts.map +1 -0
  36. package/dist/api/run-files.js +271 -0
  37. package/dist/api/run-files.js.map +1 -0
  38. package/dist/api/server.d.ts +79 -0
  39. package/dist/api/server.d.ts.map +1 -1
  40. package/dist/api/server.js +183 -8
  41. package/dist/api/server.js.map +1 -1
  42. package/dist/api/stall-watchdog.d.ts +62 -0
  43. package/dist/api/stall-watchdog.d.ts.map +1 -0
  44. package/dist/api/stall-watchdog.js +138 -0
  45. package/dist/api/stall-watchdog.js.map +1 -0
  46. package/dist/cli/index.js +89 -15
  47. package/dist/cli/index.js.map +1 -1
  48. package/dist/core/adapter.d.ts +36 -10
  49. package/dist/core/adapter.d.ts.map +1 -1
  50. package/dist/core/adapter.js +245 -31
  51. package/dist/core/adapter.js.map +1 -1
  52. package/dist/core/bridge-reaper.d.ts +134 -0
  53. package/dist/core/bridge-reaper.d.ts.map +1 -0
  54. package/dist/core/bridge-reaper.js +286 -0
  55. package/dist/core/bridge-reaper.js.map +1 -0
  56. package/dist/core/deliver.d.ts +118 -0
  57. package/dist/core/deliver.d.ts.map +1 -0
  58. package/dist/core/deliver.js +241 -0
  59. package/dist/core/deliver.js.map +1 -0
  60. package/dist/core/deliverable-floor.d.ts +155 -0
  61. package/dist/core/deliverable-floor.d.ts.map +1 -0
  62. package/dist/core/deliverable-floor.js +248 -0
  63. package/dist/core/deliverable-floor.js.map +1 -0
  64. package/dist/core/exec.d.ts +2 -0
  65. package/dist/core/exec.d.ts.map +1 -1
  66. package/dist/core/exec.js.map +1 -1
  67. package/dist/core/types.d.ts +79 -1
  68. package/dist/core/types.d.ts.map +1 -1
  69. package/dist/core/types.js +3 -0
  70. package/dist/core/types.js.map +1 -1
  71. package/dist/interactive/bridge-pool.d.ts +52 -0
  72. package/dist/interactive/bridge-pool.d.ts.map +1 -1
  73. package/dist/interactive/bridge-pool.js +93 -12
  74. package/dist/interactive/bridge-pool.js.map +1 -1
  75. package/dist/interactive/chat-events.d.ts +207 -0
  76. package/dist/interactive/chat-events.d.ts.map +1 -0
  77. package/dist/interactive/chat-events.js +769 -0
  78. package/dist/interactive/chat-events.js.map +1 -0
  79. package/dist/interactive/demo-events.d.ts +283 -0
  80. package/dist/interactive/demo-events.d.ts.map +1 -0
  81. package/dist/interactive/demo-events.js +889 -0
  82. package/dist/interactive/demo-events.js.map +1 -0
  83. package/dist/interactive/draft-events.d.ts +87 -7
  84. package/dist/interactive/draft-events.d.ts.map +1 -1
  85. package/dist/interactive/draft-events.js +352 -49
  86. package/dist/interactive/draft-events.js.map +1 -1
  87. package/dist/interactive/edit-events.d.ts +25 -2
  88. package/dist/interactive/edit-events.d.ts.map +1 -1
  89. package/dist/interactive/edit-events.js +88 -9
  90. package/dist/interactive/edit-events.js.map +1 -1
  91. package/dist/interactive/repo-snapshot.d.ts +100 -0
  92. package/dist/interactive/repo-snapshot.d.ts.map +1 -0
  93. package/dist/interactive/repo-snapshot.js +289 -0
  94. package/dist/interactive/repo-snapshot.js.map +1 -0
  95. package/dist/projects/graph-paths.d.ts +122 -0
  96. package/dist/projects/graph-paths.d.ts.map +1 -0
  97. package/dist/projects/graph-paths.js +175 -0
  98. package/dist/projects/graph-paths.js.map +1 -0
  99. package/dist/projects/graph.d.ts +191 -0
  100. package/dist/projects/graph.d.ts.map +1 -0
  101. package/dist/projects/graph.js +829 -0
  102. package/dist/projects/graph.js.map +1 -0
  103. package/dist/projects/routes.d.ts +17 -0
  104. package/dist/projects/routes.d.ts.map +1 -1
  105. package/dist/projects/routes.js +139 -0
  106. package/dist/projects/routes.js.map +1 -1
  107. package/dist/qe/ledger.d.ts +3 -2
  108. package/dist/qe/ledger.d.ts.map +1 -1
  109. package/dist/qe/ledger.js +5 -4
  110. package/dist/qe/ledger.js.map +1 -1
  111. package/dist/studio/assets/index-D-BFUYnY.js +530 -0
  112. package/dist/studio/assets/index-D6S9zUtO.css +32 -0
  113. package/dist/studio/index.html +5 -3
  114. package/endpoint-manifest.json +624 -0
  115. package/package.json +8 -5
  116. package/dist/studio/assets/index-CCwXa1cn.js +0 -428
  117. 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.
@@ -181,7 +242,12 @@ runtime = {}) {
181
242
  // still gets the ONE actor shape via this accessor.
182
243
  const actorOf = (req) => req.actor ?? LOCAL_ACTOR;
183
244
  // Liveness — also proves the actor + event pump are up.
184
- app.get(`${V}/health`, async () => {
245
+ // `config.manifest` on the routes below (TH-11): the declaration channel the endpoint manifest
246
+ // reads — type names bind to `wicked-crew-api-types` exports where one exists, structural
247
+ // spellings where the contract has no name, statusCodes list every code the route answers on
248
+ // purpose. Declared on the highest-traffic run-lifecycle routes first; the manifest records
249
+ // null / [] for the rest ("where declared", never invented). See src/api/endpoint-manifest.ts.
250
+ app.get(`${V}/health`, { config: { manifest: { statusCodes: [200] } } }, async () => {
185
251
  const ping = await adapter.ping();
186
252
  return { status: 'ok', version: PKG_VERSION, ping };
187
253
  });
@@ -261,24 +327,18 @@ runtime = {}) {
261
327
  return reply.code(400).send({ error: '`path` must be an absolute path' });
262
328
  }
263
329
  const target = resolve(rawPath);
264
- const roots = [];
330
+ let roots;
265
331
  try {
332
+ let session;
266
333
  if (runId !== undefined) {
267
334
  const views = await adapter.sessionsDetail();
268
335
  const view = views.find((v) => v.session.id === runId);
269
336
  if (view === undefined) {
270
337
  return reply.code(404).send({ error: `unknown run: ${runId}` });
271
338
  }
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
- }
339
+ session = view.session;
279
340
  }
280
- for (const repo of await adapter.listRepos())
281
- roots.push(repo.root_path);
341
+ roots = allowedRootsFor(session, await adapter.listRepos());
282
342
  }
283
343
  catch (err) {
284
344
  return reply.code(500).send({ error: message(err) });
@@ -296,6 +356,144 @@ runtime = {}) {
296
356
  }
297
357
  return { status: 'opened' };
298
358
  });
359
+ // ── Run file & diff reads (DES-FEEDBACK-002 CREW-1) ────────────────────────
360
+ // The studio's in-app viewer (P0-3). Both routes are GET-only assembly of reviewed machinery:
361
+ // the SAME containment `POST /open` runs (`allowedRootsFor` + fail-closed `isInsideRoot`) over
362
+ // the SAME root set (run workdir + extra write roots + registered repo roots), capped payloads,
363
+ // and `execCapped` git with argv arrays. Threat delta over /open is strictly smaller: these only
364
+ // return bytes the daemon can already read inside the same containment — no OS opener, no write.
365
+ /** Resolve `:id` → the run's session, and the contained target from `?path=` when present.
366
+ * Shared by both routes so their validation ladders (404 unknown run → 400 non-absolute →
367
+ * 403 outside every root) cannot drift. Returns `null` after replying. */
368
+ const resolveRunPath = async (reply, id, rawPath) => {
369
+ let session;
370
+ let roots;
371
+ try {
372
+ const views = await adapter.sessionsDetail();
373
+ const view = views.find((v) => v.session.id === id);
374
+ if (view === undefined) {
375
+ await reply.code(404).send({ error: `unknown run: ${id}` });
376
+ return null;
377
+ }
378
+ session = view.session;
379
+ if (rawPath === undefined)
380
+ return { session };
381
+ roots = allowedRootsFor(session, await adapter.listRepos());
382
+ }
383
+ catch (err) {
384
+ await reply.code(500).send({ error: message(err) });
385
+ return null;
386
+ }
387
+ if (!isAbsolute(rawPath)) {
388
+ await reply.code(400).send({ error: '`path` must be an absolute path' });
389
+ return null;
390
+ }
391
+ const target = resolve(rawPath);
392
+ if (!roots.some((root) => isInsideRoot(root, target))) {
393
+ await reply.code(403).send({
394
+ error: "path is outside every allowed root (the run's workdir/write roots and the registered repos)",
395
+ });
396
+ return null;
397
+ }
398
+ return { session, target };
399
+ };
400
+ // Fastify parses a repeated param as string[] (Copilot, #250/#266). These are FILE-READ
401
+ // routes: `?path=a&path=b` is rejected outright (400) rather than silently reading as
402
+ // either (Copilot, #305).
403
+ const REPEATED_PATH = Symbol('repeated path param');
404
+ const singlePathQ = (v) => Array.isArray(v) ? REPEATED_PATH : v?.trim() || undefined;
405
+ // File content from the run's contained roots: 512 KB cap (`truncated: true` past it, first
406
+ // 512 KB served), NUL-in-first-8KB binary sniff (`binary: true`, `content: ""`). Read-only by
407
+ // construction (`fs` read); no directory listing — the studio already has the file list.
408
+ app.get(`${V}/runs/:id/files`, async (req, reply) => {
409
+ const { id } = req.params;
410
+ const rawPath = singlePathQ(req.query.path);
411
+ if (rawPath === REPEATED_PATH) {
412
+ return reply.code(400).send({ error: '`path` may be given at most once' });
413
+ }
414
+ if (rawPath === undefined) {
415
+ return reply.code(400).send({ error: '`path` query parameter is required' });
416
+ }
417
+ const resolved = await resolveRunPath(reply, id, rawPath);
418
+ if (resolved === null)
419
+ return reply;
420
+ const target = resolved.target;
421
+ try {
422
+ const read = await readFileCapped(target);
423
+ return { path: target, ...read };
424
+ }
425
+ catch (err) {
426
+ if (err.code === 'ENOENT') {
427
+ return reply.code(404).send({ error: `no such file: ${target}` });
428
+ }
429
+ if (err instanceof NotARegularFileError) {
430
+ return reply.code(400).send({ error: `\`path\` is not a regular file: ${target}` });
431
+ }
432
+ return reply.code(500).send({ error: message(err) });
433
+ }
434
+ });
435
+ // The run's worktree diff against HEAD — or, with `?base=` (CREW-UX-1, DES-UX-001 §8.1),
436
+ // against the run branch's fork point (`base=merge-base`) or a plain in-repo ref, so committed
437
+ // run work is visible. Staged + unstaged; untracked appended as all-addition `--no-index`
438
+ // hunks; whole-tree or `?path=` narrowed. 1 MB output cap. `diff: ""` is a real answer (clean
439
+ // tree), not an error. 409 — not 404 — when the run has no workdir or the workdir has been
440
+ // reaped: the RUN exists; what is gone is the thing to diff against. `base` is a baseline,
441
+ // NEVER a command surface: anything that is not the merge-base literal or a plain resolvable
442
+ // ref (flags, paths, ranges, separators) is a named 400 before any git process sees it.
443
+ app.get(`${V}/runs/:id/diff`, async (req, reply) => {
444
+ const { id } = req.params;
445
+ const q = req.query;
446
+ const rawPath = singlePathQ(q.path);
447
+ if (rawPath === REPEATED_PATH) {
448
+ return reply.code(400).send({ error: '`path` may be given at most once' });
449
+ }
450
+ const rawBase = singlePathQ(q.base);
451
+ if (rawBase === REPEATED_PATH) {
452
+ return reply.code(400).send({ error: '`base` may be given at most once' });
453
+ }
454
+ const resolved = await resolveRunPath(reply, id, rawPath);
455
+ if (resolved === null)
456
+ return reply;
457
+ const workdir = resolved.session.workdir;
458
+ if (typeof workdir !== 'string' || workdir.length === 0) {
459
+ return reply.code(409).send({ error: `run ${id} has no workdir — nothing to diff` });
460
+ }
461
+ if (!existsSync(workdir)) {
462
+ return reply.code(409).send({ error: `run ${id}'s workdir no longer exists: ${workdir}` });
463
+ }
464
+ // Narrowing is WORKTREE-scoped: a contained-but-outside-the-worktree path (extra write
465
+ // root / repo root) is a valid FILE read but has no meaning as a diff pathspec — rejected
466
+ // explicitly here rather than handing git a `../`-prefixed pathspec and surfacing its
467
+ // "outside repository" error as a 500 (Copilot, #305).
468
+ if (resolved.target !== undefined && !isInsideRoot(workdir, resolved.target)) {
469
+ return reply.code(400).send({
470
+ error: `\`path\` must be inside the run's worktree to diff: ${workdir}`,
471
+ });
472
+ }
473
+ const rel = resolved.target === undefined ? undefined : relative(workdir, resolved.target);
474
+ try {
475
+ return await worktreeDiff(workdir, rel, rawBase);
476
+ }
477
+ catch (err) {
478
+ // Named 400s (§8.1): malformed base (not a plain ref) and well-formed-but-unresolvable
479
+ // base are both client errors, each with its error name in the body — never a git 500.
480
+ if (err instanceof InvalidDiffBaseError || err instanceof UnresolvableDiffBaseError) {
481
+ return reply.code(400).send({ error: `${err.name}: ${message(err)}` });
482
+ }
483
+ if (err.code === 'ENOENT') {
484
+ return reply.code(500).send({ error: 'git executable not found on server' });
485
+ }
486
+ // execCapped throws (no partial output attached) past its 64 MiB daemon-wide buffer —
487
+ // beyond graceful truncation, so the answer is an explicit, actionable refusal
488
+ // rather than a generic 500 (Copilot, #305).
489
+ if (err instanceof ExecOutputTooLarge) {
490
+ return reply.code(507).send({
491
+ error: "diff output exceeds the server's execution buffer — narrow the request with ?path=",
492
+ });
493
+ }
494
+ return reply.code(500).send({ error: message(err) });
495
+ }
496
+ });
299
497
  // Registered repos → target-repo picker.
300
498
  app.get(`${V}/repos`, async () => ({ repos: await adapter.listRepos() }));
301
499
  app.post(`${V}/repos`, async (req, reply) => {
@@ -349,7 +547,17 @@ runtime = {}) {
349
547
  });
350
548
  // Launch a run (replaces POST /sessions). `clisJson` defaults to the roster;
351
549
  // `sessionId` is minted if the client omits it.
352
- app.post(`${V}/runs`, async (req, reply) => {
550
+ app.post(`${V}/runs`, {
551
+ config: {
552
+ manifest: {
553
+ requestType: 'LaunchRunBody',
554
+ responseType: '{ runId: string }',
555
+ // 404/409: unknown project / archived-or-synthesized project + busy engine (see the
556
+ // catch below); 400: zod reject or a retryOf naming no existing run.
557
+ statusCodes: [201, 400, 404, 409],
558
+ },
559
+ },
560
+ }, async (req, reply) => {
353
561
  const parsed = LaunchSchema.safeParse(req.body);
354
562
  if (!parsed.success) {
355
563
  return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
@@ -368,8 +576,34 @@ runtime = {}) {
368
576
  input.repoRef = b.repoRef;
369
577
  if (b.workflow !== undefined)
370
578
  input.workflow = b.workflow;
371
- if (b.projectId !== undefined)
579
+ if (b.projectId !== undefined) {
372
580
  input.projectId = b.projectId;
581
+ // A project is a CONTEXT (crew#326): a run filed into one should see the project's whole
582
+ // co-located graph, not just its own repo's. Resolved — never REFRESHED — at launch: a
583
+ // launch that silently indexed N repos would block this response for as long as the slowest
584
+ // of them takes, so a missing or stale graph degrades to the repo graph and says why.
585
+ //
586
+ // The decision is recorded either way. "This run sees the project" and "this run sees one
587
+ // repo, because X" are both facts about what the run could observe, and the second is the one
588
+ // an operator needs when a worker reports that a sibling repo does not exist.
589
+ const decision = await resolveProjectGraphBinding(adapter, b.projectId, b.repoRef);
590
+ if (decision.binding !== null)
591
+ input.projectGraph = decision.binding;
592
+ req.log.info({ runId: input.sessionId, projectId: b.projectId, repoRef: b.repoRef ?? null }, `run ${input.sessionId}: ${decision.reason}`);
593
+ }
594
+ if (b.deliver !== undefined)
595
+ input.deliver = b.deliver;
596
+ // Retry lineage (DES-UX-001 §8.3): `retryOf` must name an EXISTING run — recording lineage
597
+ // to a run that never existed would be provenance pointing at nothing, so the launch fails
598
+ // loudly (400, before anything is committed) rather than filing a dangling edge.
599
+ if (b.retryOf !== undefined) {
600
+ const known = await adapter.sessions();
601
+ if (!known.includes(b.retryOf)) {
602
+ return reply.code(400).send({
603
+ error: `retryOf names an unknown run: ${b.retryOf} — lineage must point at an existing run id`,
604
+ });
605
+ }
606
+ }
373
607
  try {
374
608
  const runId = await adapter.launchRun(input);
375
609
  // Who launched it — the engine's LaunchOptions carries no actor field
@@ -381,8 +615,14 @@ runtime = {}) {
381
615
  ...(b.workflow !== undefined ? { workflow: b.workflow } : {}),
382
616
  ...(b.repoRef !== undefined ? { repoRef: b.repoRef } : {}),
383
617
  ...(b.projectId !== undefined ? { projectId: b.projectId } : {}),
618
+ ...(b.deliver !== undefined ? { deliver: b.deliver } : {}),
619
+ // CREW-UX-3: the trail is the durable record of lineage — the retry index (and a
620
+ // restarted daemon's hydrate) reads it back from exactly this entry.
621
+ ...(b.retryOf !== undefined ? { retryOf: b.retryOf } : {}),
384
622
  },
385
623
  });
624
+ if (b.retryOf !== undefined)
625
+ retryIndex.set(runId, b.retryOf);
386
626
  if (b.projectId !== undefined) {
387
627
  // The engine attached the crew.run membership ATOMICALLY with the launch record
388
628
  // (DES-PROJECT-001 §2.2) — this is the post-commit half: tag future /ws frames and
@@ -411,7 +651,7 @@ runtime = {}) {
411
651
  });
412
652
  // Run list (replaces GET /sessions). Actionable-first; reconciles the gate and elicitation caches
413
653
  // so that terminal-run entries are pruned even when their terminal CoreEvent was missed.
414
- app.get(`${V}/runs`, async (req) => {
654
+ app.get(`${V}/runs`, { config: { manifest: { responseType: '{ runs: SessionView[] }', statusCodes: [200] } } }, async (req) => {
415
655
  const views = await adapter.sessionsDetail();
416
656
  gateCache.reconcile(views);
417
657
  elicitationCache.reconcile(views);
@@ -426,7 +666,7 @@ runtime = {}) {
426
666
  const visible = includeArchived
427
667
  ? views
428
668
  : views.filter((v) => v.session.archived_at == null);
429
- return { runs: sortActionableFirst(visible) };
669
+ return { runs: sortActionableFirst(visible).map(decorateRun) };
430
670
  });
431
671
  // ── Run archival (crew#265) — write-off, not delete ────────────────────────
432
672
  const ArchiveSchema = z.object({
@@ -480,13 +720,53 @@ runtime = {}) {
480
720
  return { results, archived: results.filter((r) => r.ok).length };
481
721
  });
482
722
  // One run's detail.
483
- app.get(`${V}/runs/:id`, async (req, reply) => {
723
+ app.get(`${V}/runs/:id`, { config: { manifest: { responseType: '{ run: SessionView }', statusCodes: [200, 404] } } }, async (req, reply) => {
484
724
  const { id } = req.params;
485
725
  const views = await adapter.sessionsDetail();
486
726
  const run = views.find((v) => v.session.id === id);
487
727
  if (!run)
488
728
  return reply.code(404).send({ error: 'Run not found' });
489
- return { run };
729
+ return { run: decorateRun(run) };
730
+ });
731
+ // Durable pre-gate guidance (DES-UX-002 §7.2 — spec'd there as CREW-UX-4, implemented as
732
+ // CREW-UX-7 because crew#308 already spent that id; see guidance-index.ts). Upserts the ONE
733
+ // operator note on the run; the empty string clears it. The durable record is the
734
+ // `guidance.set` audit entry (actor + full text); the index is the read-side layer the run
735
+ // DTOs echo it from.
736
+ //
737
+ // GOVERNANCE ISOLATION (deliberate): the governance gate does NOT read this field — the
738
+ // engine's `LaunchOptions` never sees it, and no gate evaluation consults it. It is
739
+ // operator-visible context only; the amend text at gate decision (`POST /runs/:id/gate`)
740
+ // stays the ONE injection point. The studio pre-populates its steer textarea from this note,
741
+ // and injection still happens only through the governed amend.
742
+ app.put(`${V}/runs/:id/guidance`, {
743
+ config: {
744
+ manifest: {
745
+ requestType: 'SetGuidanceBody',
746
+ responseType: 'SetGuidanceResult',
747
+ statusCodes: [200, 400, 404],
748
+ },
749
+ },
750
+ }, async (req, reply) => {
751
+ const { id } = req.params;
752
+ const parsed = GuidanceSchema.safeParse(req.body);
753
+ if (!parsed.success) {
754
+ return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
755
+ }
756
+ const { text } = parsed.data;
757
+ if (Buffer.byteLength(text, 'utf8') > GUIDANCE_MAX_BYTES) {
758
+ return reply.code(400).send({
759
+ error: `guidance exceeds the ${GUIDANCE_MAX_BYTES}-byte cap — a note this size belongs in the problem statement or a linked doc`,
760
+ });
761
+ }
762
+ const known = await adapter.sessions();
763
+ if (!known.includes(id))
764
+ return reply.code(404).send({ error: 'Run not found' });
765
+ // The trail is the durable record (CREW-UX-3 posture): a restarted daemon's
766
+ // GuidanceIndex.hydrate reads the note back from exactly this entry.
767
+ audit.record('guidance.set', actorOf(req), { runId: id, detail: { text } });
768
+ guidanceIndex.set(id, text);
769
+ return { runId: id, guidance: text };
490
770
  });
491
771
  // ── Chat sessions (crew#165): warm ACP seat pool + group fan-out (core#134) ──
492
772
  // A chat is NOT a run: no council, no gates, no units. Seats warm on open;
@@ -700,7 +980,16 @@ runtime = {}) {
700
980
  });
701
981
  });
702
982
  // The steering gate (§11.1). approve+amend = approve-with-steer; approve:false = reject (cancels).
703
- app.post(`${V}/runs/:id/gate`, async (req, reply) => {
983
+ app.post(`${V}/runs/:id/gate`, {
984
+ config: {
985
+ manifest: {
986
+ requestType: 'GateDecision',
987
+ responseType: '{ status: SessionStatus }',
988
+ // 409 twice over: a run not awaiting a human gate, and an engine refusal at confirm.
989
+ statusCodes: [200, 400, 404, 409],
990
+ },
991
+ },
992
+ }, async (req, reply) => {
704
993
  const { id } = req.params;
705
994
  const parsed = GateSchema.safeParse(req.body);
706
995
  if (!parsed.success) {
@@ -1287,6 +1576,12 @@ runtime = {}) {
1287
1576
  if (page === null) {
1288
1577
  return reply.code(404).send({ error: 'requirements_graph.json not generated for this repo yet' });
1289
1578
  }
1579
+ // Overrides keyed by ids the corpus no longer mints (an estate id-scheme migration re-keys
1580
+ // method/field SymbolIds) would otherwise vanish without a trace — the count is on the page
1581
+ // AND in the log, because the operator who edited them is not the one reading the response.
1582
+ if ((page.orphanedOverrides ?? 0) > 0) {
1583
+ req.log.warn({ repo: id, orphanedOverrides: page.orphanedOverrides }, 'requirements_overrides.json holds keys matching no requirement — stale after a re-index/migration; re-run the annotation workflow, then re-apply or delete them');
1584
+ }
1290
1585
  return page;
1291
1586
  });
1292
1587
  app.get(`${V}/repos/:id/requirements/:key`, async (req, reply) => {
@@ -1385,8 +1680,10 @@ runtime = {}) {
1385
1680
  });
1386
1681
  // ── Git history (last 20 commits via git log) ─────────────────────────────
1387
1682
  // ── Blast radius for a symbol (via wicked-estate blast-radius --json).
1388
- // Carries the honesty contract through: dependents PLUS the unresolved-call
1389
- // count — an empty dependents list must never read as "safe to change".
1683
+ // Carries the honesty contract through: dependents PLUS the count of references
1684
+ // no resolver bound (ENGINE-CONTRACT §2.1 — repeat sites of an already-bound
1685
+ // relationship are not counted, so 0 is legitimate for a fully-resolved symbol).
1686
+ // When non-zero, an empty dependents list must never read as "safe to change".
1390
1687
  app.get(`${V}/repos/:id/graph/blast-radius`, async (req, reply) => {
1391
1688
  const { id } = req.params;
1392
1689
  const q = req.query;
@@ -1521,19 +1818,22 @@ runtime = {}) {
1521
1818
  }
1522
1819
  });
1523
1820
  // ── System settings ──────────────────────────────────────────────────────────
1821
+ // The store is SHARED with the skin (crew#323): beside the engine's own keys it round-trips
1822
+ // the studio's `studio.*` preference blobs verbatim — see `CrewSystemSettings`'s index
1823
+ // signature in core/types.ts, which states that rather than leaving it to a client comment.
1524
1824
  app.get(`${V}/settings`, async () => ({ settings: await adapter.getSettings() }));
1525
1825
  app.put(`${V}/settings`, async (req, reply) => {
1526
1826
  const patch = req.body;
1527
1827
  if (typeof patch !== 'object' || patch === null || Array.isArray(patch)) {
1528
1828
  return reply.code(400).send({ error: 'body must be a JSON object' });
1529
1829
  }
1530
- if ('graphNodeLimit' in patch) {
1830
+ if (Object.hasOwn(patch, 'graphNodeLimit')) {
1531
1831
  const limit = patch.graphNodeLimit;
1532
1832
  if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 20 || limit > 500) {
1533
1833
  return reply.code(400).send({ error: 'graphNodeLimit must be an integer between 20 and 500' });
1534
1834
  }
1535
1835
  }
1536
- if ('worker_config_root' in patch) {
1836
+ if (Object.hasOwn(patch, 'worker_config_root')) {
1537
1837
  const root = patch.worker_config_root;
1538
1838
  if (typeof root !== 'string' || (root !== '' && !isAbsolute(root))) {
1539
1839
  return reply.code(400).send({
@@ -1541,22 +1841,82 @@ runtime = {}) {
1541
1841
  });
1542
1842
  }
1543
1843
  }
1544
- // Only allow known keys through.
1844
+ // workerStallMinutes (crew#287): the stall watchdog's silence threshold. Bounded to a day —
1845
+ // a huge value is "off in practice", which should be a deliberate choice, not a typo.
1846
+ if (Object.hasOwn(patch, 'workerStallMinutes')) {
1847
+ const mins = patch.workerStallMinutes;
1848
+ if (typeof mins !== 'number' || !Number.isInteger(mins) || mins < 1 || mins > 1440) {
1849
+ return reply
1850
+ .code(400)
1851
+ .send({ error: 'workerStallMinutes must be an integer between 1 and 1440' });
1852
+ }
1853
+ }
1854
+ // Skin-owned keys (crew#323): allowed through, but VALIDATED rather than trusted. The
1855
+ // daemon does not read these values, so the only two things it can check are the two that
1856
+ // can hurt it — a value it cannot persist, and a value big enough to bloat settings.json.
1857
+ // Both answer 400 naming the key: silence is exactly what made #323 invisible for a whole
1858
+ // campaign of appearance work.
1859
+ const studioKeys = Object.keys(patch).filter((k) => STUDIO_SETTINGS_KEY.test(k));
1860
+ for (const key of studioKeys) {
1861
+ const value = patch[key];
1862
+ // `undefined` from a throwing/circular value AND from a value JSON.stringify simply
1863
+ // drops (a function, a symbol) — both are unpersistable, both are refused.
1864
+ let encoded;
1865
+ try {
1866
+ encoded = JSON.stringify(value);
1867
+ }
1868
+ catch {
1869
+ encoded = undefined;
1870
+ }
1871
+ if (encoded === undefined) {
1872
+ return reply.code(400).send({ error: `${key} must be a JSON-serializable value` });
1873
+ }
1874
+ const bytes = Buffer.byteLength(encoded, 'utf8');
1875
+ if (bytes > STUDIO_SETTINGS_MAX_BYTES) {
1876
+ return reply.code(400).send({
1877
+ error: `${key} is ${bytes} bytes of JSON, over the ${STUDIO_SETTINGS_MAX_BYTES}-byte per-key cap on studio.* settings`,
1878
+ });
1879
+ }
1880
+ }
1881
+ // Only known engine keys and validated `studio.*` keys are persisted. A key that is
1882
+ // NEITHER is dropped, not refused: request bodies are forward-additive too (DES-STUDIO-001
1883
+ // §5.1), so an older daemon meeting a newer client's engine key must not fail the whole
1884
+ // patch and take the caller's other keys down with it. The cost is that a typo goes
1885
+ // unnoticed on the wire — so the dropped keys are NAMED in the audit entry below.
1545
1886
  const allowed = [
1546
1887
  'graphNodeLimit',
1547
1888
  'worker_config_root',
1889
+ 'workerStallMinutes',
1548
1890
  ];
1549
1891
  const safe = {};
1550
1892
  for (const key of allowed) {
1551
- if (key in patch)
1893
+ // `Object.hasOwn`, NOT `key in patch` (Copilot on #324): `in` walks the prototype chain, so
1894
+ // a body whose prototype carries an engine key would be persisted from a value the caller
1895
+ // never sent. Not reachable through the default JSON parser — `JSON.parse` yields a plain
1896
+ // object and Fastify refuses `__proto__` — but this route already accepts custom
1897
+ // content-type parsers, which can produce non-plain objects. Own properties only, and the
1898
+ // same spelling the `ignored` filter below uses, so the two can never disagree about what
1899
+ // "present" means.
1900
+ if (Object.hasOwn(patch, key))
1552
1901
  safe[key] = patch[key];
1553
1902
  }
1903
+ for (const key of studioKeys) {
1904
+ safe[key] = patch[key];
1905
+ }
1906
+ // `Object.hasOwn`, NOT `k in safe`: `in` walks the prototype chain, so a dropped key named
1907
+ // `toString` / `valueOf` / `constructor` would test as "kept" and vanish from `ignored` —
1908
+ // the exact silent drop this route exists to end.
1909
+ const ignored = Object.keys(patch).filter((k) => !Object.hasOwn(safe, k));
1554
1910
  const settings = await adapter.updateSettings(safe);
1555
1911
  // Re-apply the worker-config root to this process's env (seat sign-in). The engine reads
1556
1912
  // WICKED_WORKER_HOME per worker spawn — never cached — so this alone makes the change live
1557
1913
  // at the next spawn: no daemon restart, no engine restart.
1558
1914
  applyWorkerConfigRoot(settings.worker_config_root);
1559
- audit.record('settings.updated', actorOf(req), { detail: { changed: Object.keys(safe) } });
1915
+ // `changed` names every persisted key, engine and `studio.*` alike; `ignored` (present only
1916
+ // when there is one) is where a dropped unknown key stops being invisible.
1917
+ audit.record('settings.updated', actorOf(req), {
1918
+ detail: { changed: Object.keys(safe), ...(ignored.length > 0 ? { ignored } : {}) },
1919
+ });
1560
1920
  return { settings };
1561
1921
  });
1562
1922
  // ── Projects (DES-PROJECT-001) — the 9-route experience-plane surface ────────
@@ -1566,7 +1926,16 @@ runtime = {}) {
1566
1926
  // origin, one auth hook, and one CORS posture — the whole point of slice 1.
1567
1927
  registerInteractiveProxy(app, adapter, {
1568
1928
  settings: projectSettings,
1569
- pool: runtime.interactiveBridges ?? new InteractiveBridgePool({ log: (m) => app.log.warn(m) }),
1929
+ pool: runtime.interactiveBridges ??
1930
+ new InteractiveBridgePool({
1931
+ log: (m) => app.log.warn(m),
1932
+ debug: (m) => app.log.debug(m),
1933
+ // #298: the daemon's own origin, read LAZILY off the bound server — the pool is built
1934
+ // before `listen`, but only consulted while serving a request, i.e. once bound. The
1935
+ // pool POSTs it to the bridge's /api/studio-origin on start/adopt so the bridge's
1936
+ // `GET /` redirects into studio.
1937
+ studioOrigin: () => boundOrigin(app.server.address()),
1938
+ }),
1570
1939
  log: (m) => app.log.warn(m),
1571
1940
  });
1572
1941
  }