wicked-crew 0.7.28 → 0.7.29

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 (96) hide show
  1. package/README.md +9 -1
  2. package/dist/api/chat-scope.d.ts +210 -0
  3. package/dist/api/chat-scope.d.ts.map +1 -0
  4. package/dist/api/chat-scope.js +724 -0
  5. package/dist/api/chat-scope.js.map +1 -0
  6. package/dist/api/diagnostics.d.ts +30 -2
  7. package/dist/api/diagnostics.d.ts.map +1 -1
  8. package/dist/api/diagnostics.js +73 -5
  9. package/dist/api/diagnostics.js.map +1 -1
  10. package/dist/api/governance-health.d.ts +163 -0
  11. package/dist/api/governance-health.d.ts.map +1 -0
  12. package/dist/api/governance-health.js +348 -0
  13. package/dist/api/governance-health.js.map +1 -0
  14. package/dist/api/open-path.d.ts.map +1 -1
  15. package/dist/api/open-path.js +2 -1
  16. package/dist/api/open-path.js.map +1 -1
  17. package/dist/api/post-hoc-deliver.d.ts +9 -5
  18. package/dist/api/post-hoc-deliver.d.ts.map +1 -1
  19. package/dist/api/post-hoc-deliver.js +21 -7
  20. package/dist/api/post-hoc-deliver.js.map +1 -1
  21. package/dist/api/routes.d.ts +48 -12
  22. package/dist/api/routes.d.ts.map +1 -1
  23. package/dist/api/routes.js +309 -48
  24. package/dist/api/routes.js.map +1 -1
  25. package/dist/api/seat-health.d.ts.map +1 -1
  26. package/dist/api/seat-health.js +25 -5
  27. package/dist/api/seat-health.js.map +1 -1
  28. package/dist/api/seat-signin.d.ts.map +1 -1
  29. package/dist/api/seat-signin.js +49 -23
  30. package/dist/api/seat-signin.js.map +1 -1
  31. package/dist/api/server.d.ts.map +1 -1
  32. package/dist/api/server.js +20 -0
  33. package/dist/api/server.js.map +1 -1
  34. package/dist/campaigns/routes.d.ts +4 -4
  35. package/dist/cli/governance.d.ts +131 -0
  36. package/dist/cli/governance.d.ts.map +1 -0
  37. package/dist/cli/governance.js +354 -0
  38. package/dist/cli/governance.js.map +1 -0
  39. package/dist/cli/index.js +69 -2
  40. package/dist/cli/index.js.map +1 -1
  41. package/dist/core/adapter.d.ts +89 -1
  42. package/dist/core/adapter.d.ts.map +1 -1
  43. package/dist/core/adapter.js +123 -7
  44. package/dist/core/adapter.js.map +1 -1
  45. package/dist/core/bridge-reaper.d.ts.map +1 -1
  46. package/dist/core/bridge-reaper.js +4 -1
  47. package/dist/core/bridge-reaper.js.map +1 -1
  48. package/dist/core/deliver-text.d.ts +184 -0
  49. package/dist/core/deliver-text.d.ts.map +1 -0
  50. package/dist/core/deliver-text.js +434 -0
  51. package/dist/core/deliver-text.js.map +1 -0
  52. package/dist/core/deliver-triage.d.ts +90 -0
  53. package/dist/core/deliver-triage.d.ts.map +1 -0
  54. package/dist/core/deliver-triage.js +106 -0
  55. package/dist/core/deliver-triage.js.map +1 -0
  56. package/dist/core/deliver.d.ts +103 -9
  57. package/dist/core/deliver.d.ts.map +1 -1
  58. package/dist/core/deliver.js +244 -46
  59. package/dist/core/deliver.js.map +1 -1
  60. package/dist/core/estate-mcp-client.d.ts.map +1 -1
  61. package/dist/core/estate-mcp-client.js +7 -2
  62. package/dist/core/estate-mcp-client.js.map +1 -1
  63. package/dist/core/exec.d.ts.map +1 -1
  64. package/dist/core/exec.js +11 -1
  65. package/dist/core/exec.js.map +1 -1
  66. package/dist/core/governance-store.d.ts +197 -0
  67. package/dist/core/governance-store.d.ts.map +1 -0
  68. package/dist/core/governance-store.js +283 -0
  69. package/dist/core/governance-store.js.map +1 -0
  70. package/dist/core/repoPaths.d.ts +36 -5
  71. package/dist/core/repoPaths.d.ts.map +1 -1
  72. package/dist/core/repoPaths.js +46 -4
  73. package/dist/core/repoPaths.js.map +1 -1
  74. package/dist/interactive/bridge-pool.d.ts.map +1 -1
  75. package/dist/interactive/bridge-pool.js +9 -4
  76. package/dist/interactive/bridge-pool.js.map +1 -1
  77. package/dist/projects/graph-paths.d.ts +4 -2
  78. package/dist/projects/graph-paths.d.ts.map +1 -1
  79. package/dist/projects/graph-paths.js +4 -2
  80. package/dist/projects/graph-paths.js.map +1 -1
  81. package/dist/projects/graph.d.ts +5 -4
  82. package/dist/projects/graph.d.ts.map +1 -1
  83. package/dist/projects/graph.js +22 -5
  84. package/dist/projects/graph.js.map +1 -1
  85. package/dist/projects/routes.d.ts.map +1 -1
  86. package/dist/projects/routes.js +18 -9
  87. package/dist/projects/routes.js.map +1 -1
  88. package/dist/skills/plugin-source.d.ts.map +1 -1
  89. package/dist/skills/plugin-source.js +3 -0
  90. package/dist/skills/plugin-source.js.map +1 -1
  91. package/dist/studio/assets/index-CeJTutxP.js +540 -0
  92. package/dist/studio/index.html +1 -1
  93. package/dist/studio/testid-inventory.json +212 -6
  94. package/endpoint-manifest.json +60 -12
  95. package/package.json +3 -3
  96. package/dist/studio/assets/index-CdmCMh2f.js +0 -539
@@ -6,7 +6,7 @@ import { promises as fsp } from 'node:fs';
6
6
  import { isAbsolute, join, relative, resolve } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { CampaignsUnsupportedError, ChatUnsupportedError, CoreAdapter, ElicitationUnsupportedError, SteeringUnsupportedError, humanGatePhaseIds } from '../core/adapter.js';
9
- import { codeGraphDb, requirementsGraph } from '../core/repoPaths.js';
9
+ import { codeGraphDb, codeGraphErrorStatus, requirementsGraph } from '../core/repoPaths.js';
10
10
  import { resolveCursorUnit } from '../core/cursor.js';
11
11
  import { detectRefusal } from './gate-cache.js';
12
12
  import { QeGateCache } from '../qe/gate-events.js';
@@ -17,6 +17,7 @@ import { execCapped, ExecOutputTooLarge } from '../core/exec.js';
17
17
  import { callEstateTool, EstateMcpError } from '../core/estate-mcp-client.js';
18
18
  import { SeatHealthTracker } from './seat-health.js';
19
19
  import { applyWorkerConfigRoot, signedInHeuristic } from './seat-signin.js';
20
+ import { ChatScopeIndex, chatScopeDeps, prepareChatScratch, removeChatScratch, resolveChatScope, } from './chat-scope.js';
20
21
  import { allowedRootsFor, isInsideRoot, openWithSystemDefault } from './open-path.js';
21
22
  import { InvalidDiffBaseError, NotARegularFileError, UnresolvableDiffBaseError, readFileCapped, worktreeDiff, } from './run-files.js';
22
23
  import { resolveProjectGraphBinding } from '../projects/graph.js';
@@ -30,6 +31,7 @@ import { registerSkillsRoutes } from './skills.js';
30
31
  import { disabledSkillsHealth } from '../skills/runtime.js';
31
32
  import { ProjectSettingsStore } from '../projects/settings.js';
32
33
  import { boundOrigin, InteractiveBridgePool } from '../interactive/bridge-pool.js';
34
+ import { composeDeliverText, factsFromRun, framedDeliverText, runUrlFor } from '../core/deliver-text.js';
33
35
  import { registerInteractiveProxy } from '../interactive/proxy-routes.js';
34
36
  import { registerInteractiveDocDelete } from '../interactive/doc-delete-routes.js';
35
37
  import { registerInteractiveDocList } from '../interactive/doc-list-routes.js';
@@ -37,6 +39,8 @@ import { MembershipIndex } from '../projects/membership-index.js';
37
39
  import { MEMBERSHIP_ATTACHED, membershipAttachedKey } from '../projects/events.js';
38
40
  import { AuditLog } from './audit.js';
39
41
  import { AcpFoldCache, EngineVersionCache, eventsDirOf, installedPackageVersion, listStoreFiles, readStudioBundleVersion, } from './diagnostics.js';
42
+ import { GovernanceDiagnostics } from './governance-health.js';
43
+ import { legacyHomeOutboxPath } from '../core/governance-store.js';
40
44
  import { RetryIndex } from './retry-index.js';
41
45
  import { GroupIndex } from './group-index.js';
42
46
  import { RunTimingIndex, recordRunLaunched } from './run-timing-index.js';
@@ -69,6 +73,25 @@ const STATUS_ORDER = {
69
73
  function sortActionableFirst(views) {
70
74
  return [...views].sort((a, b) => (STATUS_ORDER[a.session.status] ?? 9) - (STATUS_ORDER[b.session.status] ?? 9));
71
75
  }
76
+ /**
77
+ * `codeGraphDb(repo)` for a repo surface, or `null` after answering **503** — a CURRENT engine that
78
+ * resolved no repo-graph root (`code_graph_root_unresolvable`, wicked-core#406) is a daemon-
79
+ * environment fault, not a bad request and not Fastify's generic 500. The mapping is the shared
80
+ * `codeGraphErrorStatus`; anything it does not classify rethrows unchanged (the stale-addon error
81
+ * keeps its pre-existing shape here).
82
+ */
83
+ function codeGraphDbOr503(repo, reply) {
84
+ try {
85
+ return codeGraphDb(repo);
86
+ }
87
+ catch (err) {
88
+ const status = codeGraphErrorStatus(err);
89
+ if (status === null)
90
+ throw err;
91
+ void reply.code(status).send({ error: message(err) });
92
+ return null;
93
+ }
94
+ }
72
95
  function message(err) {
73
96
  return err instanceof Error ? err.message : String(err);
74
97
  }
@@ -411,6 +434,21 @@ const STUDIO_SETTINGS_MAX_BYTES = 512 * 1024;
411
434
  * The daemon REST surface. Every endpoint is a thin wrapper over one adapter /
412
435
  * core-ts call (DES-STUDIO-001 §2). `session`/`phase` nouns are now `run`/`unit`.
413
436
  */
437
+ /**
438
+ * `POST /chats` body (crew#165; `projectId` DES-PROJECT-001 §2.2; `repoRefs` + scope semantics
439
+ * crew#502). Exported for the wire-contract drift guard (`tests/wire-contract.test.ts`).
440
+ */
441
+ export const ChatOpenSchema = z.object({
442
+ chatId: z.string().min(1).max(128).regex(/^[A-Za-z0-9._-]+$/).optional(),
443
+ clis: z.array(z.string().min(1)).min(1).max(8).optional(),
444
+ /** One repo in scope — the legacy spelling, merged into `repoRefs` (crew#502). */
445
+ repoRef: z.string().min(1).optional(),
446
+ /** The repos in scope, by registry id or name (crew#502). */
447
+ repoRefs: z.array(z.string().min(1)).min(1).max(32).optional(),
448
+ /** File the chat into a project (`crew.chat` membership) — and, with no `repoRefs`, scope it to
449
+ * every registered `crew.repo` member of that project (crew#502). */
450
+ projectId: z.string().min(1).optional(),
451
+ }).strict();
414
452
  export function registerRoutes(app, adapter, gateCache, elicitationCache,
415
453
  // Defaulted so a caller that never arms the bus seam (tests drive this
416
454
  // function directly) gets the same behavior as an unarmed daemon: an empty
@@ -435,6 +473,7 @@ runtime = {}) {
435
473
  const groupIndex = runtime.groupIndex ?? new GroupIndex();
436
474
  const runTimingIndex = runtime.runTimingIndex ?? new RunTimingIndex();
437
475
  const guidanceIndex = runtime.guidanceIndex ?? new GuidanceIndex();
476
+ const chatScopes = runtime.chatScopes ?? new ChatScopeIndex();
438
477
  const deliveryIndex = runtime.deliveryIndex ?? new DeliveryIndex();
439
478
  const worktreeExists = runtime.worktreeExists ?? ((p) => existsSync(p));
440
479
  const vacuityProbes = {
@@ -457,6 +496,12 @@ runtime = {}) {
457
496
  isDelivered: (runId) => deliveryIndex.urlFor(runId) !== undefined,
458
497
  });
459
498
  const deliverExec = runtime.deliverExec ?? runDeliverScript;
499
+ // crew#524: the deliver phase's script asks THIS daemon for the run-derived PR text, and the PR
500
+ // body links the run here — so the adapter learns the bound origin lazily (the server has not
501
+ // listened yet when routes register). Guarded: directly-driven route sets hand in fakes.
502
+ if (typeof adapter.setDeliverApiOrigin === 'function') {
503
+ adapter.setDeliverApiOrigin(() => boundOrigin(app.server.address()));
504
+ }
460
505
  const reprovisionWorktree = runtime.reprovisionWorktree ?? gitReprovisionWorktree;
461
506
  // The estate MCP client behind `/proposals*` AND `/memory*` (DES-MEM-FACETED-001) — one seam. The
462
507
  // default is the real spawn-per-call `wicked-estate-mcp` client; route tests inject a stub so no
@@ -572,16 +617,22 @@ runtime = {}) {
572
617
  // can never turn this GET into an event-log re-reader or a `--version` spawner per request.
573
618
  const acpFoldCache = new AcpFoldCache();
574
619
  const engineVersionCache = new EngineVersionCache();
620
+ // The governance block (crew#495): the store the engine's emit seam writes to (as the adapter
621
+ // exported it — `null` on a boot that resolved none), its record count through the engine
622
+ // binding when the addon has one, and the dead-letter outbox folded on change. Built at boot so
623
+ // the record baseline is the boot baseline.
624
+ const governanceDiagnostics = new GovernanceDiagnostics(adapter.governanceStore ?? null, CoreAdapter.eventStoreCounter(), runtime.governanceLegacyOutboxPath === undefined ? legacyHomeOutboxPath() : runtime.governanceLegacyOutboxPath);
575
625
  app.get(`${V}/diagnostics`, { config: { manifest: { responseType: 'DiagnosticsResponse', statusCodes: [200] } } }, async () => {
576
626
  // `dbPath` is a readonly field of the real adapter; a stub-driven route set may lack it,
577
627
  // and diagnostics over an unknown store honestly reports no stores and no ACP record.
578
628
  const dbPath = typeof adapter.dbPath === 'string' && adapter.dbPath !== '' ? adapter.dbPath : null;
579
- const [stores, byCli, engineBinaries] = await Promise.all([
629
+ const [stores, byCli, engineBinaries, governance] = await Promise.all([
580
630
  dbPath !== null ? listStoreFiles(dbPath) : Promise.resolve([]),
581
631
  dbPath !== null
582
632
  ? acpFoldCache.get(eventsDirOf(dbPath))
583
633
  : Promise.resolve({}),
584
634
  engineVersionCache.get(),
635
+ governanceDiagnostics.health(),
585
636
  ]);
586
637
  const uptimeMs = Math.round(process.uptime() * 1000);
587
638
  const addr = app.server.address();
@@ -601,6 +652,9 @@ runtime = {}) {
601
652
  // config-error, with the `skills.*` findings the ladder produced — the operator's one
602
653
  // read-only answer to "why do launches refuse the snapshot".
603
654
  skills: runtime.skills?.health() ?? disabledSkillsHealth(),
655
+ // Is the governance evidence LANDING (crew#495): the store, the records on it, the dead
656
+ // letters — with a `governance.deadletter` finding the moment the outbox holds one.
657
+ governance,
604
658
  };
605
659
  });
606
660
  // Who am I talking to the daemon as? (task #88). In local mode this is
@@ -1181,6 +1235,28 @@ runtime = {}) {
1181
1235
  return reply.code(404).send({ error: 'Run not found' });
1182
1236
  return { run: decorateRun(run) };
1183
1237
  });
1238
+ /** The full workflow registry, or `[]` on a directly-driven route set whose fake adapter has none. */
1239
+ const listWorkflowsSafe = () => typeof adapter.listWorkflows === 'function' ? adapter.listWorkflows() : [];
1240
+ // ── Deliver text (crew#524 / F-3R2-014) — the PR title + body a run's delivery carries ──
1241
+ // `gh pr create --fill` gave wicked-studio#249 a mid-word title and an EMPTY body. The deliver
1242
+ // script now asks this route for the text composed from the PERSISTED RUN RECORD: the intent,
1243
+ // `Fixes #N`, the run link, every phase with its seat and gate outcome, the repo checks with
1244
+ // their exit codes, the evaluator verdict (`core/deliver-text.ts`). text/plain, FRAMED as line 1
1245
+ // title, line 2 blank, then the body — the same shape the script's embedded fallback has, so the
1246
+ // script parses exactly one thing. Read-only; the run's `deliver` unit is still running when it
1247
+ // asks, and is listed as `this PR`.
1248
+ app.get(`${V}/runs/:id/deliver-text`, { config: { manifest: { statusCodes: [200, 404] } } }, async (req, reply) => {
1249
+ const { id } = req.params;
1250
+ const views = await adapter.sessionsDetail();
1251
+ const run = views.find((v) => v.session.id === id);
1252
+ if (!run)
1253
+ return reply.code(404).send({ error: 'Run not found' });
1254
+ const origin = boundOrigin(app.server.address());
1255
+ // The workflow DEFINITION (a user-registered workflow's view carries the engine instance id).
1256
+ const def = resolveRunWorkflow(run, listWorkflowsSafe());
1257
+ const text = composeDeliverText(factsFromRun(decorateRun(run), runUrlFor(origin, id), { workflowId: def?.id ?? null }));
1258
+ return reply.type('text/plain; charset=utf-8').send(framedDeliverText(text));
1259
+ });
1184
1260
  // ── Post-hoc delivery (crew#393) — lift a stranded run's worktree into a PR ──
1185
1261
  // The recovery path for the run 83052f0b class: a COMPLETED repo-scoped run whose reviewable
1186
1262
  // work was never lifted (`delivery: 'stranded'` on the wire — including runs recorded long
@@ -1299,7 +1375,15 @@ runtime = {}) {
1299
1375
  workdir = reprov.workdir;
1300
1376
  cw = reprov.cleanup;
1301
1377
  }
1302
- result = await deliverExec(workdir, s.problem ?? undefined);
1378
+ // crew#524: the post-hoc lift composes its PR text from the run record it already
1379
+ // holds (the fallback), and names this daemon so the script can re-ask at delivery.
1380
+ const origin = boundOrigin(app.server.address());
1381
+ const def = resolveRunWorkflow(run, listWorkflowsSafe());
1382
+ result = await deliverExec(workdir, s.problem ?? undefined, {
1383
+ runId: id,
1384
+ apiOrigin: origin,
1385
+ facts: factsFromRun(run, runUrlFor(origin, id), { workflowId: def?.id ?? null }),
1386
+ });
1303
1387
  }
1304
1388
  finally {
1305
1389
  if (cw !== null)
@@ -1394,28 +1478,27 @@ runtime = {}) {
1394
1478
  // ── Chat sessions (crew#165): warm ACP seat pool + group fan-out (core#134) ──
1395
1479
  // A chat is NOT a run: no council, no gates, no units. Seats warm on open;
1396
1480
  // messages fan out to warm seats; replies stream on /ws as chatDelta/chatReply.
1397
- const ChatOpenSchema = z.object({
1398
- chatId: z.string().min(1).max(128).regex(/^[A-Za-z0-9._-]+$/).optional(),
1399
- clis: z.array(z.string().min(1)).min(1).max(8).optional(),
1400
- repoRef: z.string().optional(),
1401
- /** DES-PROJECT-001 §2.2 — file the chat into a project (`crew.chat` membership). */
1402
- projectId: z.string().min(1).optional(),
1403
- }).strict();
1404
- app.post(`${V}/chats`, async (req, reply) => {
1481
+ //
1482
+ // A chat is SCOPED (crew#502, F-067): its seats run in a private scratch root — never a repo,
1483
+ // never the daemon's cwd — with the scoped repos as read roots and, where a graph binds, the
1484
+ // READ-ONLY estate MCP over it (the grounding governed runs get). The scope is resolved and
1485
+ // validated BEFORE any seat warms, stated to the seats in the scratch root's AGENTS.md /
1486
+ // CLAUDE.md, and returned as `scope` (see `chat-scope.ts`).
1487
+ app.post(`${V}/chats`, {
1488
+ config: {
1489
+ manifest: {
1490
+ requestType: 'ChatOpenBody',
1491
+ responseType: 'ChatOpenResponse',
1492
+ statusCodes: [201, 400, 404, 409, 500, 501],
1493
+ },
1494
+ },
1495
+ }, async (req, reply) => {
1405
1496
  const parsed = ChatOpenSchema.safeParse(req.body ?? {});
1406
1497
  if (!parsed.success) {
1407
1498
  return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
1408
1499
  }
1409
1500
  const b = parsed.data;
1410
1501
  const chatId = b.chatId ?? randomUUID();
1411
- let cwd;
1412
- if (b.repoRef !== undefined) {
1413
- const repos = await adapter.listRepos();
1414
- const repo = repos.find((r) => r.id === b.repoRef);
1415
- if (!repo)
1416
- return reply.code(404).send({ error: `Repo ${b.repoRef} not found` });
1417
- cwd = repo.root_path;
1418
- }
1419
1502
  // Validate the project BEFORE opening seats: a chat has no launch record for the engine to
1420
1503
  // attach against atomically (chats are an in-memory seat pool), so the route validates
1421
1504
  // up-front and attaches right after open — the one non-atomic attach, documented in the ADR
@@ -1436,32 +1519,169 @@ runtime = {}) {
1436
1519
  return reply.code(501).send({ error: message(err) });
1437
1520
  }
1438
1521
  }
1439
- const clis = b.clis ??
1440
- CoreAdapter.roster()
1441
- .map((s) => s.key)
1442
- .filter((k) => typeof k === 'string');
1522
+ // A chat id this daemon already holds a scope for is LIVE: re-preparing its scratch root and
1523
+ // re-opening it would overwrite the statement its seats are reading and (on a changed scope)
1524
+ // evict them mid-conversation (Copilot, #518). Close it first, or let the daemon mint the id.
1525
+ const token = chatScopes.reserve(chatId);
1526
+ if (token === null) {
1527
+ const state = chatScopes.stateOf(chatId);
1528
+ return reply.code(409).send({
1529
+ error: state === 'closing'
1530
+ ? `chat ${chatId} is closing; wait for its chatClosed (a few seconds at most) before reusing the id, or omit chatId to mint a fresh one`
1531
+ : `chat ${chatId} is already open on this daemon; DELETE /chats/${chatId} first, or omit chatId to mint a fresh one`,
1532
+ });
1533
+ }
1534
+ // Everything below either ends in `chatScopes.set(chatId, …, token)` or releases the reservation.
1443
1535
  try {
1444
- const seats = await adapter.chatOpen(chatId, clis, cwd);
1445
- if (b.projectId !== undefined) {
1446
- try {
1447
- const { member, created } = await adapter.projectMemberAttach(b.projectId, 'crew.chat', chatId);
1448
- if (created) {
1449
- projects.index.set(chatId, b.projectId);
1450
- 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));
1536
+ // The scope (crew#502): explicit repos (`repoRefs`, the legacy `repoRef` merged in) or the
1537
+ // project's members — each ref checked against the registry, EVERY missing one named — else
1538
+ // none. Resolved before any seat warms: a 404 here warmed nothing.
1539
+ const resolution = await resolveChatScope({
1540
+ chatId,
1541
+ ...(b.projectId !== undefined ? { projectId: b.projectId } : {}),
1542
+ repoRefs: [...(b.repoRef !== undefined ? [b.repoRef] : []), ...(b.repoRefs ?? [])],
1543
+ }, { ...chatScopeDeps(adapter), scratchBase: chatScopes.base, log: (m) => req.log.warn(m) });
1544
+ if (!resolution.ok) {
1545
+ chatScopes.release(chatId, token);
1546
+ return reply.code(resolution.status).send({
1547
+ error: resolution.error,
1548
+ ...(resolution.missing !== undefined ? { missing: resolution.missing } : {}),
1549
+ });
1550
+ }
1551
+ const { scope, engine } = resolution;
1552
+ try {
1553
+ // Cleans up only what it created itself on failure (Copilot, #518).
1554
+ prepareChatScratch(chatId, scope);
1555
+ }
1556
+ catch (err) {
1557
+ chatScopes.release(chatId, token);
1558
+ return reply
1559
+ .code(500)
1560
+ .send({ error: `cannot prepare the chat's scratch root ${scope.cwd}: ${message(err)}` });
1561
+ }
1562
+ // The default seats of a SCOPED chat are pre-filtered by admissibility (independent review,
1563
+ // W5): the engine admits a seat to a scoped chat only when its ACP adapter asks permissions
1564
+ // (`acp_input_governance`) or its record arms the kernel write floor (`os_sandbox`) — with the
1565
+ // default roster that is claude and opencode — so a default project-scoped open does not show
1566
+ // four refused seats. Explicit `clis` are passed through as asked; the engine refuses per seat
1567
+ // with the reason.
1568
+ const scoped = scope.kind !== 'none';
1569
+ const roster = CoreAdapter.roster();
1570
+ const clis = b.clis ??
1571
+ roster
1572
+ .filter((s) => !scoped || s.acp?.acp_input_governance === true || s.acp?.os_sandbox === true)
1573
+ .map((s) => s.key)
1574
+ .filter((k) => typeof k === 'string');
1575
+ if (clis.length === 0) {
1576
+ chatScopes.release(chatId, token);
1577
+ removeChatScratch(scope.cwd, chatScopes.base);
1578
+ return reply.code(409).send({
1579
+ error: 'no seat in the roster can be held to a scoped chat (an ACP adapter admitted to input ' +
1580
+ 'governance, or `os_sandbox = true` on its [cli.acp] record); open the chat unscoped, or ' +
1581
+ 'name seats with `clis`',
1582
+ });
1583
+ }
1584
+ try {
1585
+ const seats = await adapter.chatOpen(chatId, clis, engine.cwd, {
1586
+ codeGraphDb: engine.codeGraphDb,
1587
+ readRoots: engine.readRoots,
1588
+ });
1589
+ // Nothing warmed (independent review, W1): the engine holds no pool row and has dropped the
1590
+ // scope itself (no `chatClosed` will come) — report the PER-SEAT reasons, never an
1591
+ // engine-version guess; the root goes, the id is free again.
1592
+ if (seats.every((s) => !s.ok)) {
1593
+ chatScopes.release(chatId, token);
1594
+ removeChatScratch(scope.cwd, chatScopes.base);
1595
+ return reply.code(409).send({
1596
+ error: `chat ${chatId}: no seat warmed (${seats.length} failed) — see seats`,
1597
+ seats,
1598
+ });
1599
+ }
1600
+ // Honest scope (Copilot, #518): a SCOPED chat is a promise — the roots are read-only, the
1601
+ // graph is attached, the seats see nothing else — and only an engine that CONFIRMS it
1602
+ // recorded the scope (its `chatList` row carries the scope fields) can keep it. An engine
1603
+ // predating chat scope (wicked-core#410) dropped `scopeJson` and runs the seats unbounded in
1604
+ // the scratch root; an engine that cannot be asked has confirmed nothing. Neither may hold a
1605
+ // scoped chat: it is closed again, its root removed, and the caller gets a 501 naming the
1606
+ // remedy — never a chat whose statement promises what its seats do not enforce. An UNSCOPED
1607
+ // chat (`kind: 'none'`) promises nothing beyond its scratch root and proceeds.
1608
+ const applied = await adapter.chatScopeApplied(chatId);
1609
+ if (applied !== true && scoped) {
1610
+ await adapter.chatClose(chatId).catch(() => undefined);
1611
+ // The engine's `chatClosed` for this id is still on its way: park, do not release
1612
+ // (Copilot, #518), so a reuse cannot have its root removed by the late event.
1613
+ chatScopes.abortToClosing(chatId, token);
1614
+ removeChatScratch(scope.cwd, chatScopes.base);
1615
+ if (applied === false) {
1616
+ // A row WITHOUT the scope fields: the engine predates chat scope (wicked-core#410).
1617
+ return reply.code(501).send({
1618
+ error: 'the installed wicked-core-ts predates chat scope (wicked-core#410): it cannot ground ' +
1619
+ 'a scoped chat or hold its read roots read-only — upgrade the engine, or open the ' +
1620
+ 'chat without projectId/repoRefs.',
1621
+ seats,
1622
+ });
1451
1623
  }
1624
+ // NO row although a seat reported warm: nothing is actually held for this chat.
1625
+ return reply.code(409).send({
1626
+ error: `chat ${chatId}: a seat reported warm but the engine holds no row for the chat — ` +
1627
+ 'nothing warmed; open it again (see seats)',
1628
+ seats,
1629
+ });
1452
1630
  }
1453
- catch (err) {
1454
- // The chat is open and usable; the filing failed. Say so instead of failing the open —
1455
- // the caller can re-attach via POST /projects/:id/members.
1456
- return reply
1457
- .code(201)
1458
- .send({ chatId, seats, projectAttachError: message(err) });
1631
+ // File the chat into its project WHILE the id is still reserved (Copilot, #518): publishing
1632
+ // first would let a concurrent DELETE / engine `chatClosed` land during this await and leave
1633
+ // a 201 with a stale scope and a membership attached to a closed chat.
1634
+ let projectAttachError;
1635
+ let attachedMemberId;
1636
+ if (b.projectId !== undefined) {
1637
+ try {
1638
+ const { member, created } = await adapter.projectMemberAttach(b.projectId, 'crew.chat', chatId);
1639
+ if (created)
1640
+ attachedMemberId = member.id;
1641
+ if (created) {
1642
+ projects.index.set(chatId, b.projectId);
1643
+ 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));
1644
+ }
1645
+ }
1646
+ catch (err) {
1647
+ // The chat is open and usable; the filing failed. Said on the 201 rather than failing
1648
+ // the open — the caller can re-attach via POST /projects/:id/members.
1649
+ projectAttachError = message(err);
1650
+ }
1651
+ }
1652
+ if (!chatScopes.set(chatId, scope, token)) {
1653
+ // The reservation was cancelled while the open was in flight — an engine `chatClosed` or a
1654
+ // `DELETE` for this id (Copilot, #518). Nothing was recorded; tear the chat down (engine
1655
+ // session, scratch root, and the filing just made) instead of returning a stale 201.
1656
+ await adapter.chatClose(chatId).catch(() => undefined);
1657
+ removeChatScratch(scope.cwd, chatScopes.base);
1658
+ if (b.projectId !== undefined && attachedMemberId !== undefined) {
1659
+ await adapter.projectMemberDetach(b.projectId, attachedMemberId).catch(() => false);
1660
+ projects.index.delete(chatId);
1661
+ }
1662
+ return reply.code(409).send({
1663
+ error: `chat ${chatId} was closed while it was being opened; open it again`,
1664
+ });
1459
1665
  }
1666
+ return reply.code(201).send({
1667
+ chatId,
1668
+ seats,
1669
+ scope,
1670
+ ...(projectAttachError !== undefined ? { projectAttachError } : {}),
1671
+ });
1672
+ }
1673
+ catch (err) {
1674
+ // Nothing warmed: the scratch root prepared above must not linger — removed under the SAME
1675
+ // base the resolver created it in (Copilot, #518), fail-closed like every removal.
1676
+ chatScopes.release(chatId, token);
1677
+ removeChatScratch(scope.cwd, chatScopes.base);
1678
+ return reply.code(400).send({ error: message(err) });
1460
1679
  }
1461
- return reply.code(201).send({ chatId, seats });
1462
1680
  }
1463
- catch (err) {
1464
- return reply.code(400).send({ error: message(err) });
1681
+ finally {
1682
+ // A reservation that never became a scope (any early return above) must not pin the id —
1683
+ // token-guarded, so a reservation that is no longer ours is left alone.
1684
+ chatScopes.release(chatId, token);
1465
1685
  }
1466
1686
  });
1467
1687
  const ChatMessageSchema = z.object({
@@ -1488,7 +1708,7 @@ runtime = {}) {
1488
1708
  // lived in the tab that abandoned it, and an operator could not reclaim one without restarting
1489
1709
  // the daemon. Registered BEFORE `/chats/:id` is irrelevant to fastify (it routes on the literal
1490
1710
  // segment first), but the order reads the way the routes nest.
1491
- app.get(`${V}/chats`, async (_req, reply) => {
1711
+ app.get(`${V}/chats`, { config: { manifest: { responseType: 'ChatListResponse', statusCodes: [200, 400, 501] } } }, async (_req, reply) => {
1492
1712
  try {
1493
1713
  return { chats: await adapter.chatList() };
1494
1714
  }
@@ -1503,10 +1723,11 @@ runtime = {}) {
1503
1723
  .send({ error: message(err) });
1504
1724
  }
1505
1725
  });
1506
- app.get(`${V}/chats/:id`, async (req, reply) => {
1726
+ app.get(`${V}/chats/:id`, { config: { manifest: { responseType: 'ChatDetailResponse', statusCodes: [200, 400] } } }, async (req, reply) => {
1507
1727
  const { id } = req.params;
1508
1728
  try {
1509
- return { chatId: id, seats: await adapter.chatSeats(id) };
1729
+ // The scope recorded at open (crew#502) — `null` for a chat this daemon did not open.
1730
+ return { chatId: id, seats: await adapter.chatSeats(id), scope: chatScopes.get(id) ?? null };
1510
1731
  }
1511
1732
  catch (err) {
1512
1733
  return reply.code(400).send({ error: message(err) });
@@ -1514,6 +1735,10 @@ runtime = {}) {
1514
1735
  });
1515
1736
  app.delete(`${V}/chats/:id`, async (req, reply) => {
1516
1737
  const { id } = req.params;
1738
+ // The scratch root goes with the chat at once, whether or not the engine still knew it
1739
+ // (crew#502); the id stays parked until the engine's `chatClosed` is observed, so a delayed
1740
+ // close can never land on a chat that reused it (Copilot, #518).
1741
+ chatScopes.beginClose(id);
1517
1742
  try {
1518
1743
  await adapter.chatClose(id);
1519
1744
  return { ok: true };
@@ -2411,7 +2636,9 @@ runtime = {}) {
2411
2636
  if (!repo)
2412
2637
  return reply.code(404).send({ error: `Repo ${id} not found` });
2413
2638
  const graphPath = requirementsGraph(repo);
2414
- const dbPath = codeGraphDb(repo);
2639
+ const dbPath = codeGraphDbOr503(repo, reply);
2640
+ if (dbPath === null)
2641
+ return reply;
2415
2642
  // Coverage from the live estate store — computed by wicked-core governance layer.
2416
2643
  let coverage = null;
2417
2644
  if (existsSync(dbPath)) {
@@ -2469,7 +2696,17 @@ runtime = {}) {
2469
2696
  if (!parsed.success) {
2470
2697
  return reply.code(400).send(invalidBody(parsed.error, 'Invalid query'));
2471
2698
  }
2472
- const page = await listRequirements(repo, parsed.data);
2699
+ let page;
2700
+ try {
2701
+ page = await listRequirements(repo, parsed.data);
2702
+ }
2703
+ catch (err) {
2704
+ // wicked-core#406: a CURRENT engine with no repo-graph root is a 503, not a 500.
2705
+ const status = codeGraphErrorStatus(err);
2706
+ if (status === null)
2707
+ throw err;
2708
+ return reply.code(status).send({ error: message(err) });
2709
+ }
2473
2710
  if (page === null) {
2474
2711
  return reply.code(404).send({ error: 'requirements_graph.json not generated for this repo yet' });
2475
2712
  }
@@ -2494,7 +2731,17 @@ runtime = {}) {
2494
2731
  catch {
2495
2732
  return reply.code(400).send({ error: 'Malformed requirement key encoding' });
2496
2733
  }
2497
- const detail = await getRequirement(repo, decoded);
2734
+ let detail;
2735
+ try {
2736
+ detail = await getRequirement(repo, decoded);
2737
+ }
2738
+ catch (err) {
2739
+ // wicked-core#406: a CURRENT engine with no repo-graph root is a 503, not a 500.
2740
+ const status = codeGraphErrorStatus(err);
2741
+ if (status === null)
2742
+ throw err;
2743
+ return reply.code(status).send({ error: message(err) });
2744
+ }
2498
2745
  if (detail === null)
2499
2746
  return reply.code(404).send({ error: 'Requirement not found' });
2500
2747
  return { requirement: detail };
@@ -2529,7 +2776,17 @@ runtime = {}) {
2529
2776
  catch {
2530
2777
  return reply.code(400).send({ error: 'Malformed requirement key encoding' });
2531
2778
  }
2532
- const detail = await patchRequirement(repo, decodedKey, parsed.data);
2779
+ let detail;
2780
+ try {
2781
+ detail = await patchRequirement(repo, decodedKey, parsed.data);
2782
+ }
2783
+ catch (err) {
2784
+ // wicked-core#406: a CURRENT engine with no repo-graph root is a 503, not a 500.
2785
+ const status = codeGraphErrorStatus(err);
2786
+ if (status === null)
2787
+ throw err;
2788
+ return reply.code(status).send({ error: message(err) });
2789
+ }
2533
2790
  if (detail === null)
2534
2791
  return reply.code(404).send({ error: 'Requirement not found' });
2535
2792
  return { requirement: detail };
@@ -2540,7 +2797,9 @@ runtime = {}) {
2540
2797
  const repo = repos.find((r) => r.id === id);
2541
2798
  if (!repo)
2542
2799
  return reply.code(404).send({ error: `Repo ${id} not found` });
2543
- const dbPath = codeGraphDb(repo);
2800
+ const dbPath = codeGraphDbOr503(repo, reply);
2801
+ if (dbPath === null)
2802
+ return reply;
2544
2803
  if (!existsSync(dbPath)) {
2545
2804
  return reply.send({ graph: null });
2546
2805
  }
@@ -2591,7 +2850,9 @@ runtime = {}) {
2591
2850
  const repo = repos.find((r) => r.id === id);
2592
2851
  if (!repo)
2593
2852
  return reply.code(404).send({ error: `Repo ${id} not found` });
2594
- const dbPath = codeGraphDb(repo);
2853
+ const dbPath = codeGraphDbOr503(repo, reply);
2854
+ if (dbPath === null)
2855
+ return reply;
2595
2856
  if (!existsSync(dbPath)) {
2596
2857
  return reply.code(404).send({ error: 'Code graph not built for this repo yet' });
2597
2858
  }