wicked-crew 0.7.28 → 0.7.30

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 (146) hide show
  1. package/README.md +12 -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 +728 -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 +171 -0
  11. package/dist/api/governance-health.d.ts.map +1 -0
  12. package/dist/api/governance-health.js +379 -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 +70 -12
  22. package/dist/api/routes.d.ts.map +1 -1
  23. package/dist/api/routes.js +375 -60
  24. package/dist/api/routes.js.map +1 -1
  25. package/dist/api/seat-health.d.ts +38 -0
  26. package/dist/api/seat-health.d.ts.map +1 -1
  27. package/dist/api/seat-health.js +88 -5
  28. package/dist/api/seat-health.js.map +1 -1
  29. package/dist/api/seat-signin.d.ts.map +1 -1
  30. package/dist/api/seat-signin.js +49 -23
  31. package/dist/api/seat-signin.js.map +1 -1
  32. package/dist/api/seat-standing.d.ts +101 -0
  33. package/dist/api/seat-standing.d.ts.map +1 -0
  34. package/dist/api/seat-standing.js +138 -0
  35. package/dist/api/seat-standing.js.map +1 -0
  36. package/dist/api/server.d.ts.map +1 -1
  37. package/dist/api/server.js +37 -0
  38. package/dist/api/server.js.map +1 -1
  39. package/dist/campaigns/routes.d.ts +4 -4
  40. package/dist/cli/governance.d.ts +131 -0
  41. package/dist/cli/governance.d.ts.map +1 -0
  42. package/dist/cli/governance.js +354 -0
  43. package/dist/cli/governance.js.map +1 -0
  44. package/dist/cli/index.js +102 -3
  45. package/dist/cli/index.js.map +1 -1
  46. package/dist/core/adapter.d.ts +99 -1
  47. package/dist/core/adapter.d.ts.map +1 -1
  48. package/dist/core/adapter.js +149 -8
  49. package/dist/core/adapter.js.map +1 -1
  50. package/dist/core/bridge-path.d.ts +21 -0
  51. package/dist/core/bridge-path.d.ts.map +1 -1
  52. package/dist/core/bridge-path.js +37 -0
  53. package/dist/core/bridge-path.js.map +1 -1
  54. package/dist/core/bridge-reaper.d.ts.map +1 -1
  55. package/dist/core/bridge-reaper.js +11 -4
  56. package/dist/core/bridge-reaper.js.map +1 -1
  57. package/dist/core/deliver-text.d.ts +184 -0
  58. package/dist/core/deliver-text.d.ts.map +1 -0
  59. package/dist/core/deliver-text.js +434 -0
  60. package/dist/core/deliver-text.js.map +1 -0
  61. package/dist/core/deliver-triage.d.ts +90 -0
  62. package/dist/core/deliver-triage.d.ts.map +1 -0
  63. package/dist/core/deliver-triage.js +106 -0
  64. package/dist/core/deliver-triage.js.map +1 -0
  65. package/dist/core/deliver.d.ts +103 -9
  66. package/dist/core/deliver.d.ts.map +1 -1
  67. package/dist/core/deliver.js +244 -46
  68. package/dist/core/deliver.js.map +1 -1
  69. package/dist/core/estate-mcp-client.d.ts.map +1 -1
  70. package/dist/core/estate-mcp-client.js +7 -2
  71. package/dist/core/estate-mcp-client.js.map +1 -1
  72. package/dist/core/exec.d.ts.map +1 -1
  73. package/dist/core/exec.js +11 -1
  74. package/dist/core/exec.js.map +1 -1
  75. package/dist/core/governance-store.d.ts +219 -0
  76. package/dist/core/governance-store.d.ts.map +1 -0
  77. package/dist/core/governance-store.js +290 -0
  78. package/dist/core/governance-store.js.map +1 -0
  79. package/dist/core/repoPaths.d.ts +36 -5
  80. package/dist/core/repoPaths.d.ts.map +1 -1
  81. package/dist/core/repoPaths.js +46 -4
  82. package/dist/core/repoPaths.js.map +1 -1
  83. package/dist/interactive/bridge-pool.d.ts +29 -24
  84. package/dist/interactive/bridge-pool.d.ts.map +1 -1
  85. package/dist/interactive/bridge-pool.js +74 -7
  86. package/dist/interactive/bridge-pool.js.map +1 -1
  87. package/dist/interactive/chat-events.d.ts.map +1 -1
  88. package/dist/interactive/chat-events.js +5 -2
  89. package/dist/interactive/chat-events.js.map +1 -1
  90. package/dist/interactive/council-outcome.d.ts +28 -0
  91. package/dist/interactive/council-outcome.d.ts.map +1 -0
  92. package/dist/interactive/council-outcome.js +57 -0
  93. package/dist/interactive/council-outcome.js.map +1 -0
  94. package/dist/interactive/demo-events.d.ts.map +1 -1
  95. package/dist/interactive/demo-events.js +5 -2
  96. package/dist/interactive/demo-events.js.map +1 -1
  97. package/dist/interactive/doc-delete-routes.d.ts.map +1 -1
  98. package/dist/interactive/doc-delete-routes.js +3 -2
  99. package/dist/interactive/doc-delete-routes.js.map +1 -1
  100. package/dist/interactive/draft-events.d.ts.map +1 -1
  101. package/dist/interactive/draft-events.js +5 -2
  102. package/dist/interactive/draft-events.js.map +1 -1
  103. package/dist/interactive/edit-events.d.ts.map +1 -1
  104. package/dist/interactive/edit-events.js +5 -2
  105. package/dist/interactive/edit-events.js.map +1 -1
  106. package/dist/projects/auto-refresh.d.ts +54 -0
  107. package/dist/projects/auto-refresh.d.ts.map +1 -0
  108. package/dist/projects/auto-refresh.js +76 -0
  109. package/dist/projects/auto-refresh.js.map +1 -0
  110. package/dist/projects/graph-paths.d.ts +4 -2
  111. package/dist/projects/graph-paths.d.ts.map +1 -1
  112. package/dist/projects/graph-paths.js +4 -2
  113. package/dist/projects/graph-paths.js.map +1 -1
  114. package/dist/projects/graph.d.ts +20 -6
  115. package/dist/projects/graph.d.ts.map +1 -1
  116. package/dist/projects/graph.js +40 -13
  117. package/dist/projects/graph.js.map +1 -1
  118. package/dist/projects/routes.d.ts.map +1 -1
  119. package/dist/projects/routes.js +30 -11
  120. package/dist/projects/routes.js.map +1 -1
  121. package/dist/projects/state-home.d.ts +6 -0
  122. package/dist/projects/state-home.d.ts.map +1 -1
  123. package/dist/projects/state-home.js +9 -1
  124. package/dist/projects/state-home.js.map +1 -1
  125. package/dist/skills/guards.d.ts +17 -6
  126. package/dist/skills/guards.d.ts.map +1 -1
  127. package/dist/skills/guards.js +42 -15
  128. package/dist/skills/guards.js.map +1 -1
  129. package/dist/skills/plugin-source.d.ts.map +1 -1
  130. package/dist/skills/plugin-source.js +3 -0
  131. package/dist/skills/plugin-source.js.map +1 -1
  132. package/dist/skills/refs.d.ts +181 -17
  133. package/dist/skills/refs.d.ts.map +1 -1
  134. package/dist/skills/refs.js +345 -26
  135. package/dist/skills/refs.js.map +1 -1
  136. package/dist/skills/store.d.ts +22 -1
  137. package/dist/skills/store.d.ts.map +1 -1
  138. package/dist/skills/store.js +161 -23
  139. package/dist/skills/store.js.map +1 -1
  140. package/dist/studio/assets/index-CWUS9Ump.js +542 -0
  141. package/dist/studio/assets/{index-CK1uJWs2.css → index-_ydd-jKd.css} +1 -1
  142. package/dist/studio/index.html +2 -2
  143. package/dist/studio/testid-inventory.json +567 -6
  144. package/endpoint-manifest.json +67 -14
  145. package/package.json +4 -4
  146. 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,8 @@ 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 { chatSeatAdmission, seatStanding } from './seat-standing.js';
21
+ import { ChatScopeIndex, chatScopeDeps, prepareChatScratch, removeChatScratch, resolveChatScope, } from './chat-scope.js';
20
22
  import { allowedRootsFor, isInsideRoot, openWithSystemDefault } from './open-path.js';
21
23
  import { InvalidDiffBaseError, NotARegularFileError, UnresolvableDiffBaseError, readFileCapped, worktreeDiff, } from './run-files.js';
22
24
  import { resolveProjectGraphBinding } from '../projects/graph.js';
@@ -30,6 +32,7 @@ import { registerSkillsRoutes } from './skills.js';
30
32
  import { disabledSkillsHealth } from '../skills/runtime.js';
31
33
  import { ProjectSettingsStore } from '../projects/settings.js';
32
34
  import { boundOrigin, InteractiveBridgePool } from '../interactive/bridge-pool.js';
35
+ import { composeDeliverText, factsFromRun, framedDeliverText, runUrlFor } from '../core/deliver-text.js';
33
36
  import { registerInteractiveProxy } from '../interactive/proxy-routes.js';
34
37
  import { registerInteractiveDocDelete } from '../interactive/doc-delete-routes.js';
35
38
  import { registerInteractiveDocList } from '../interactive/doc-list-routes.js';
@@ -37,6 +40,8 @@ import { MembershipIndex } from '../projects/membership-index.js';
37
40
  import { MEMBERSHIP_ATTACHED, membershipAttachedKey } from '../projects/events.js';
38
41
  import { AuditLog } from './audit.js';
39
42
  import { AcpFoldCache, EngineVersionCache, eventsDirOf, installedPackageVersion, listStoreFiles, readStudioBundleVersion, } from './diagnostics.js';
43
+ import { GovernanceDiagnostics } from './governance-health.js';
44
+ import { legacyHomeOutboxPath } from '../core/governance-store.js';
40
45
  import { RetryIndex } from './retry-index.js';
41
46
  import { GroupIndex } from './group-index.js';
42
47
  import { RunTimingIndex, recordRunLaunched } from './run-timing-index.js';
@@ -69,6 +74,18 @@ const STATUS_ORDER = {
69
74
  function sortActionableFirst(views) {
70
75
  return [...views].sort((a, b) => (STATUS_ORDER[a.session.status] ?? 9) - (STATUS_ORDER[b.session.status] ?? 9));
71
76
  }
77
+ function codeGraphDbOr503(repo, reply) {
78
+ try {
79
+ return codeGraphDb(repo);
80
+ }
81
+ catch (err) {
82
+ const status = codeGraphErrorStatus(err);
83
+ if (status === null)
84
+ throw err;
85
+ void reply.code(status).send({ error: message(err) });
86
+ return null;
87
+ }
88
+ }
72
89
  function message(err) {
73
90
  return err instanceof Error ? err.message : String(err);
74
91
  }
@@ -411,6 +428,21 @@ const STUDIO_SETTINGS_MAX_BYTES = 512 * 1024;
411
428
  * The daemon REST surface. Every endpoint is a thin wrapper over one adapter /
412
429
  * core-ts call (DES-STUDIO-001 §2). `session`/`phase` nouns are now `run`/`unit`.
413
430
  */
431
+ /**
432
+ * `POST /chats` body (crew#165; `projectId` DES-PROJECT-001 §2.2; `repoRefs` + scope semantics
433
+ * crew#502). Exported for the wire-contract drift guard (`tests/wire-contract.test.ts`).
434
+ */
435
+ export const ChatOpenSchema = z.object({
436
+ chatId: z.string().min(1).max(128).regex(/^[A-Za-z0-9._-]+$/).optional(),
437
+ clis: z.array(z.string().min(1)).min(1).max(8).optional(),
438
+ /** One repo in scope — the legacy spelling, merged into `repoRefs` (crew#502). */
439
+ repoRef: z.string().min(1).optional(),
440
+ /** The repos in scope, by registry id or name (crew#502). */
441
+ repoRefs: z.array(z.string().min(1)).min(1).max(32).optional(),
442
+ /** File the chat into a project (`crew.chat` membership) — and, with no `repoRefs`, scope it to
443
+ * every registered `crew.repo` member of that project (crew#502). */
444
+ projectId: z.string().min(1).optional(),
445
+ }).strict();
414
446
  export function registerRoutes(app, adapter, gateCache, elicitationCache,
415
447
  // Defaulted so a caller that never arms the bus seam (tests drive this
416
448
  // function directly) gets the same behavior as an unarmed daemon: an empty
@@ -435,6 +467,7 @@ runtime = {}) {
435
467
  const groupIndex = runtime.groupIndex ?? new GroupIndex();
436
468
  const runTimingIndex = runtime.runTimingIndex ?? new RunTimingIndex();
437
469
  const guidanceIndex = runtime.guidanceIndex ?? new GuidanceIndex();
470
+ const chatScopes = runtime.chatScopes ?? new ChatScopeIndex();
438
471
  const deliveryIndex = runtime.deliveryIndex ?? new DeliveryIndex();
439
472
  const worktreeExists = runtime.worktreeExists ?? ((p) => existsSync(p));
440
473
  const vacuityProbes = {
@@ -457,6 +490,12 @@ runtime = {}) {
457
490
  isDelivered: (runId) => deliveryIndex.urlFor(runId) !== undefined,
458
491
  });
459
492
  const deliverExec = runtime.deliverExec ?? runDeliverScript;
493
+ // crew#524: the deliver phase's script asks THIS daemon for the run-derived PR text, and the PR
494
+ // body links the run here — so the adapter learns the bound origin lazily (the server has not
495
+ // listened yet when routes register). Guarded: directly-driven route sets hand in fakes.
496
+ if (typeof adapter.setDeliverApiOrigin === 'function') {
497
+ adapter.setDeliverApiOrigin(() => boundOrigin(app.server.address()));
498
+ }
460
499
  const reprovisionWorktree = runtime.reprovisionWorktree ?? gitReprovisionWorktree;
461
500
  // The estate MCP client behind `/proposals*` AND `/memory*` (DES-MEM-FACETED-001) — one seam. The
462
501
  // default is the real spawn-per-call `wicked-estate-mcp` client; route tests inject a stub so no
@@ -572,16 +611,22 @@ runtime = {}) {
572
611
  // can never turn this GET into an event-log re-reader or a `--version` spawner per request.
573
612
  const acpFoldCache = new AcpFoldCache();
574
613
  const engineVersionCache = new EngineVersionCache();
614
+ // The governance block (crew#495): the store the engine's emit seam writes to (as the adapter
615
+ // exported it — `null` on a boot that resolved none), its record count through the engine
616
+ // binding when the addon has one, and the dead-letter outbox folded on change. Built at boot so
617
+ // the record baseline is the boot baseline.
618
+ const governanceDiagnostics = new GovernanceDiagnostics(adapter.governanceStore ?? null, CoreAdapter.eventStoreCounter(), runtime.governanceLegacyOutboxPath === undefined ? legacyHomeOutboxPath() : runtime.governanceLegacyOutboxPath);
575
619
  app.get(`${V}/diagnostics`, { config: { manifest: { responseType: 'DiagnosticsResponse', statusCodes: [200] } } }, async () => {
576
620
  // `dbPath` is a readonly field of the real adapter; a stub-driven route set may lack it,
577
621
  // and diagnostics over an unknown store honestly reports no stores and no ACP record.
578
622
  const dbPath = typeof adapter.dbPath === 'string' && adapter.dbPath !== '' ? adapter.dbPath : null;
579
- const [stores, byCli, engineBinaries] = await Promise.all([
623
+ const [stores, byCli, engineBinaries, governance] = await Promise.all([
580
624
  dbPath !== null ? listStoreFiles(dbPath) : Promise.resolve([]),
581
625
  dbPath !== null
582
626
  ? acpFoldCache.get(eventsDirOf(dbPath))
583
627
  : Promise.resolve({}),
584
628
  engineVersionCache.get(),
629
+ governanceDiagnostics.health(),
585
630
  ]);
586
631
  const uptimeMs = Math.round(process.uptime() * 1000);
587
632
  const addr = app.server.address();
@@ -601,6 +646,9 @@ runtime = {}) {
601
646
  // config-error, with the `skills.*` findings the ladder produced — the operator's one
602
647
  // read-only answer to "why do launches refuse the snapshot".
603
648
  skills: runtime.skills?.health() ?? disabledSkillsHealth(),
649
+ // Is the governance evidence LANDING (crew#495): the store, the records on it, the dead
650
+ // letters — with a `governance.deadletter` finding the moment the outbox holds one.
651
+ governance,
604
652
  };
605
653
  });
606
654
  // Who am I talking to the daemon as? (task #88). In local mode this is
@@ -653,16 +701,29 @@ runtime = {}) {
653
701
  // gains `signed_in`: the cheap file/env presence heuristic, computed against the LIVE
654
702
  // `WICKED_WORKER_HOME` env — the same value the engine reads at the next worker spawn, kept
655
703
  // current by `applyWorkerConfigRoot` at boot and on every settings change.
656
- app.get(`${V}/roster`, async () => {
704
+ //
705
+ // Each seat ALSO carries its STANDING (F-2R2-009): `auth` re-reads `signed_in` for what it means
706
+ // (a `signed_out` codex is benched on its first ballot; a `not_required` opencode answers on its
707
+ // free tier), and `council_eligible` says what a council would do with the seat as far as the
708
+ // daemon can tell. `POST /chats` seats its defaults through the SAME standing, so the roster and
709
+ // the chat never disagree about a seat.
710
+ const rosterWithStanding = () => {
657
711
  const workerRoot = process.env['WICKED_WORKER_HOME'];
658
- return {
659
- roster: CoreAdapter.roster().map((seat) => ({
712
+ return CoreAdapter.roster().map((seat) => {
713
+ const key = String(seat.key);
714
+ const health = seatHealth.healthFor(key);
715
+ const signed = signedIn(key, workerRoot === '' ? undefined : workerRoot);
716
+ return {
660
717
  ...seat,
661
- health: seatHealth.healthFor(String(seat.key)),
662
- signed_in: signedIn(String(seat.key), workerRoot === '' ? undefined : workerRoot),
663
- })),
664
- };
665
- });
718
+ health,
719
+ signed_in: signed,
720
+ // The bench is THIS daemon's council evidence (councilSeatFailed, bounded window) — the
721
+ // prediction learns from what the engine actually did with the seat (#533 review, F-1).
722
+ ...seatStanding(seat, signed, health, seatHealth.councilBenchFor(key)),
723
+ };
724
+ });
725
+ };
726
+ app.get(`${V}/roster`, async () => ({ roster: rosterWithStanding() }));
666
727
  // Open a file/folder with the OS default application (crew#273) — the studio Files tab's
667
728
  // click-to-open. The open MUST happen daemon-side (the SPA cannot spawn a process), which is
668
729
  // why the path is validated first: absolute, and inside one of the caller-visible roots — the
@@ -1181,6 +1242,28 @@ runtime = {}) {
1181
1242
  return reply.code(404).send({ error: 'Run not found' });
1182
1243
  return { run: decorateRun(run) };
1183
1244
  });
1245
+ /** The full workflow registry, or `[]` on a directly-driven route set whose fake adapter has none. */
1246
+ const listWorkflowsSafe = () => typeof adapter.listWorkflows === 'function' ? adapter.listWorkflows() : [];
1247
+ // ── Deliver text (crew#524 / F-3R2-014) — the PR title + body a run's delivery carries ──
1248
+ // `gh pr create --fill` gave wicked-studio#249 a mid-word title and an EMPTY body. The deliver
1249
+ // script now asks this route for the text composed from the PERSISTED RUN RECORD: the intent,
1250
+ // `Fixes #N`, the run link, every phase with its seat and gate outcome, the repo checks with
1251
+ // their exit codes, the evaluator verdict (`core/deliver-text.ts`). text/plain, FRAMED as line 1
1252
+ // title, line 2 blank, then the body — the same shape the script's embedded fallback has, so the
1253
+ // script parses exactly one thing. Read-only; the run's `deliver` unit is still running when it
1254
+ // asks, and is listed as `this PR`.
1255
+ app.get(`${V}/runs/:id/deliver-text`, { config: { manifest: { statusCodes: [200, 404] } } }, async (req, reply) => {
1256
+ const { id } = req.params;
1257
+ const views = await adapter.sessionsDetail();
1258
+ const run = views.find((v) => v.session.id === id);
1259
+ if (!run)
1260
+ return reply.code(404).send({ error: 'Run not found' });
1261
+ const origin = boundOrigin(app.server.address());
1262
+ // The workflow DEFINITION (a user-registered workflow's view carries the engine instance id).
1263
+ const def = resolveRunWorkflow(run, listWorkflowsSafe());
1264
+ const text = composeDeliverText(factsFromRun(decorateRun(run), runUrlFor(origin, id), { workflowId: def?.id ?? null }));
1265
+ return reply.type('text/plain; charset=utf-8').send(framedDeliverText(text));
1266
+ });
1184
1267
  // ── Post-hoc delivery (crew#393) — lift a stranded run's worktree into a PR ──
1185
1268
  // The recovery path for the run 83052f0b class: a COMPLETED repo-scoped run whose reviewable
1186
1269
  // work was never lifted (`delivery: 'stranded'` on the wire — including runs recorded long
@@ -1299,7 +1382,15 @@ runtime = {}) {
1299
1382
  workdir = reprov.workdir;
1300
1383
  cw = reprov.cleanup;
1301
1384
  }
1302
- result = await deliverExec(workdir, s.problem ?? undefined);
1385
+ // crew#524: the post-hoc lift composes its PR text from the run record it already
1386
+ // holds (the fallback), and names this daemon so the script can re-ask at delivery.
1387
+ const origin = boundOrigin(app.server.address());
1388
+ const def = resolveRunWorkflow(run, listWorkflowsSafe());
1389
+ result = await deliverExec(workdir, s.problem ?? undefined, {
1390
+ runId: id,
1391
+ apiOrigin: origin,
1392
+ facts: factsFromRun(run, runUrlFor(origin, id), { workflowId: def?.id ?? null }),
1393
+ });
1303
1394
  }
1304
1395
  finally {
1305
1396
  if (cw !== null)
@@ -1394,28 +1485,27 @@ runtime = {}) {
1394
1485
  // ── Chat sessions (crew#165): warm ACP seat pool + group fan-out (core#134) ──
1395
1486
  // A chat is NOT a run: no council, no gates, no units. Seats warm on open;
1396
1487
  // 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) => {
1488
+ //
1489
+ // A chat is SCOPED (crew#502, F-067): its seats run in a private scratch root — never a repo,
1490
+ // never the daemon's cwd — with the scoped repos as read roots and, where a graph binds, the
1491
+ // READ-ONLY estate MCP over it (the grounding governed runs get). The scope is resolved and
1492
+ // validated BEFORE any seat warms, stated to the seats in the scratch root's AGENTS.md /
1493
+ // CLAUDE.md, and returned as `scope` (see `chat-scope.ts`).
1494
+ app.post(`${V}/chats`, {
1495
+ config: {
1496
+ manifest: {
1497
+ requestType: 'ChatOpenBody',
1498
+ responseType: 'ChatOpenResponse',
1499
+ statusCodes: [201, 400, 404, 409, 500, 501],
1500
+ },
1501
+ },
1502
+ }, async (req, reply) => {
1405
1503
  const parsed = ChatOpenSchema.safeParse(req.body ?? {});
1406
1504
  if (!parsed.success) {
1407
1505
  return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
1408
1506
  }
1409
1507
  const b = parsed.data;
1410
1508
  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
1509
  // Validate the project BEFORE opening seats: a chat has no launch record for the engine to
1420
1510
  // attach against atomically (chats are an in-memory seat pool), so the route validates
1421
1511
  // up-front and attaches right after open — the one non-atomic attach, documented in the ADR
@@ -1436,32 +1526,205 @@ runtime = {}) {
1436
1526
  return reply.code(501).send({ error: message(err) });
1437
1527
  }
1438
1528
  }
1439
- const clis = b.clis ??
1440
- CoreAdapter.roster()
1441
- .map((s) => s.key)
1442
- .filter((k) => typeof k === 'string');
1529
+ // A chat id this daemon already holds a scope for is LIVE: re-preparing its scratch root and
1530
+ // re-opening it would overwrite the statement its seats are reading and (on a changed scope)
1531
+ // evict them mid-conversation (Copilot, #518). Close it first, or let the daemon mint the id.
1532
+ const token = chatScopes.reserve(chatId);
1533
+ if (token === null) {
1534
+ const state = chatScopes.stateOf(chatId);
1535
+ return reply.code(409).send({
1536
+ error: state === 'closing'
1537
+ ? `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`
1538
+ : `chat ${chatId} is already open on this daemon; DELETE /chats/${chatId} first, or omit chatId to mint a fresh one`,
1539
+ });
1540
+ }
1541
+ // Everything below either ends in `chatScopes.set(chatId, …, token)` or releases the reservation.
1443
1542
  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));
1543
+ // The scope (crew#502): explicit repos (`repoRefs`, the legacy `repoRef` merged in) or the
1544
+ // project's members — each ref checked against the registry, EVERY missing one named — else
1545
+ // none. Resolved before any seat warms: a 404 here warmed nothing.
1546
+ const resolution = await resolveChatScope({
1547
+ chatId,
1548
+ ...(b.projectId !== undefined ? { projectId: b.projectId } : {}),
1549
+ repoRefs: [...(b.repoRef !== undefined ? [b.repoRef] : []), ...(b.repoRefs ?? [])],
1550
+ }, { ...chatScopeDeps(adapter), scratchBase: chatScopes.base, log: (m) => req.log.warn(m) });
1551
+ if (!resolution.ok) {
1552
+ chatScopes.release(chatId, token);
1553
+ return reply.code(resolution.status).send({
1554
+ error: resolution.error,
1555
+ ...(resolution.missing !== undefined ? { missing: resolution.missing } : {}),
1556
+ });
1557
+ }
1558
+ const { scope, engine } = resolution;
1559
+ try {
1560
+ // Cleans up only what it created itself on failure (Copilot, #518).
1561
+ prepareChatScratch(chatId, scope);
1562
+ }
1563
+ catch (err) {
1564
+ chatScopes.release(chatId, token);
1565
+ return reply
1566
+ .code(500)
1567
+ .send({ error: `cannot prepare the chat's scratch root ${scope.cwd}: ${message(err)}` });
1568
+ }
1569
+ // The DEFAULT seats are the roster seats the daemon's own admission admits (F-2R2-007,
1570
+ // F-2R2-009): the seat's auth standing (a signed-out seat is refused up front, with the
1571
+ // reason, instead of failing its first turn — the same predicate `GET /roster` reports as
1572
+ // `council_eligible`) and, for a SCOPED chat, the engine's rule restated (only a seat whose
1573
+ // ACP adapter asks permissions or whose record arms the kernel write floor can be held to
1574
+ // read-only roots — with the default roster that is claude and opencode). Every seat dropped
1575
+ // here is NAMED on the response (`refused`) and in the thread (`chatSeatRefused`), so a
1576
+ // person can see why pi is missing. Explicit `clis` are passed through as asked; the engine
1577
+ // refuses per seat with its reason, which is copied into `refused` too.
1578
+ const scoped = scope.kind !== 'none';
1579
+ const refused = [];
1580
+ let clis;
1581
+ if (b.clis !== undefined) {
1582
+ clis = b.clis;
1583
+ }
1584
+ else {
1585
+ clis = [];
1586
+ for (const seat of rosterWithStanding()) {
1587
+ const key = String(seat.key);
1588
+ const admission = chatSeatAdmission(seat, seat.auth ?? 'unknown', scoped);
1589
+ if (admission.ok)
1590
+ clis.push(key);
1591
+ else
1592
+ refused.push({ cliKey: key, reason: admission.reason });
1593
+ }
1594
+ }
1595
+ if (clis.length === 0) {
1596
+ chatScopes.release(chatId, token);
1597
+ removeChatScratch(scope.cwd, chatScopes.base);
1598
+ return reply.code(409).send({
1599
+ error: (scoped
1600
+ ? 'no seat in the roster can be held to a scoped chat (an ACP adapter admitted to input ' +
1601
+ 'governance, or `os_sandbox = true` on its [cli.acp] record) and take a turn; open the ' +
1602
+ 'chat unscoped, sign a seat in, or name seats with `clis`'
1603
+ : 'no seat in the roster can take a turn (every seat is signed out); sign a seat in from ' +
1604
+ 'the System page, or name seats with `clis`') + ' — see refused',
1605
+ refused,
1606
+ });
1607
+ }
1608
+ try {
1609
+ const seats = await adapter.chatOpen(chatId, clis, engine.cwd, {
1610
+ codeGraphDb: engine.codeGraphDb,
1611
+ readRoots: engine.readRoots,
1612
+ });
1613
+ // A REQUESTED seat the engine refused joins the same list, with the engine's reason.
1614
+ for (const s of seats) {
1615
+ if (!s.ok)
1616
+ refused.push({ cliKey: s.cliKey, reason: s.error ?? 'the engine refused the seat' });
1617
+ }
1618
+ // Nothing warmed (independent review, W1): the engine holds no pool row and has dropped the
1619
+ // scope itself (no `chatClosed` will come) — report the PER-SEAT reasons, never an
1620
+ // engine-version guess; the root goes, the id is free again.
1621
+ if (seats.every((s) => !s.ok)) {
1622
+ chatScopes.release(chatId, token);
1623
+ removeChatScratch(scope.cwd, chatScopes.base);
1624
+ return reply.code(409).send({
1625
+ error: `chat ${chatId}: no seat warmed (${seats.length} failed) — see seats`,
1626
+ seats,
1627
+ refused,
1628
+ });
1629
+ }
1630
+ // Honest scope (Copilot, #518): a SCOPED chat is a promise — the roots are read-only, the
1631
+ // graph is attached, the seats see nothing else — and only an engine that CONFIRMS it
1632
+ // recorded the scope (its `chatList` row carries the scope fields) can keep it. An engine
1633
+ // predating chat scope (wicked-core#410) dropped `scopeJson` and runs the seats unbounded in
1634
+ // the scratch root; an engine that cannot be asked has confirmed nothing. Neither may hold a
1635
+ // scoped chat: it is closed again, its root removed, and the caller gets a 501 naming the
1636
+ // remedy — never a chat whose statement promises what its seats do not enforce. An UNSCOPED
1637
+ // chat (`kind: 'none'`) promises nothing beyond its scratch root and proceeds.
1638
+ const applied = await adapter.chatScopeApplied(chatId);
1639
+ if (applied !== true && scoped) {
1640
+ await adapter.chatClose(chatId).catch(() => undefined);
1641
+ // The engine's `chatClosed` for this id is still on its way: park, do not release
1642
+ // (Copilot, #518), so a reuse cannot have its root removed by the late event.
1643
+ chatScopes.abortToClosing(chatId, token);
1644
+ removeChatScratch(scope.cwd, chatScopes.base);
1645
+ if (applied === false) {
1646
+ // A row WITHOUT the scope fields: the engine predates chat scope (wicked-core#410).
1647
+ return reply.code(501).send({
1648
+ error: 'the installed wicked-core-ts predates chat scope (wicked-core#410): it cannot ground ' +
1649
+ 'a scoped chat or hold its read roots read-only — upgrade the engine, or open the ' +
1650
+ 'chat without projectId/repoRefs.',
1651
+ seats,
1652
+ });
1451
1653
  }
1654
+ // NO row although a seat reported warm: nothing is actually held for this chat.
1655
+ return reply.code(409).send({
1656
+ error: `chat ${chatId}: a seat reported warm but the engine holds no row for the chat — ` +
1657
+ 'nothing warmed; open it again (see seats)',
1658
+ seats,
1659
+ });
1452
1660
  }
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) });
1661
+ // File the chat into its project WHILE the id is still reserved (Copilot, #518): publishing
1662
+ // first would let a concurrent DELETE / engine `chatClosed` land during this await and leave
1663
+ // a 201 with a stale scope and a membership attached to a closed chat.
1664
+ let projectAttachError;
1665
+ let attachedMemberId;
1666
+ if (b.projectId !== undefined) {
1667
+ try {
1668
+ const { member, created } = await adapter.projectMemberAttach(b.projectId, 'crew.chat', chatId);
1669
+ if (created)
1670
+ attachedMemberId = member.id;
1671
+ if (created) {
1672
+ projects.index.set(chatId, b.projectId);
1673
+ 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));
1674
+ }
1675
+ }
1676
+ catch (err) {
1677
+ // The chat is open and usable; the filing failed. Said on the 201 rather than failing
1678
+ // the open — the caller can re-attach via POST /projects/:id/members.
1679
+ projectAttachError = message(err);
1680
+ }
1681
+ }
1682
+ if (!chatScopes.set(chatId, scope, token)) {
1683
+ // The reservation was cancelled while the open was in flight — an engine `chatClosed` or a
1684
+ // `DELETE` for this id (Copilot, #518). Nothing was recorded; tear the chat down (engine
1685
+ // session, scratch root, and the filing just made) instead of returning a stale 201.
1686
+ await adapter.chatClose(chatId).catch(() => undefined);
1687
+ removeChatScratch(scope.cwd, chatScopes.base);
1688
+ if (b.projectId !== undefined && attachedMemberId !== undefined) {
1689
+ await adapter.projectMemberDetach(b.projectId, attachedMemberId).catch(() => false);
1690
+ projects.index.delete(chatId);
1691
+ }
1692
+ return reply.code(409).send({
1693
+ error: `chat ${chatId} was closed while it was being opened; open it again`,
1694
+ });
1459
1695
  }
1696
+ // The thread learns of every refused seat the way it learns of everything else — a frame
1697
+ // on /ws — AFTER the scope is published, so a reader never sees a refusal for a chat it
1698
+ // cannot yet look up.
1699
+ for (const r of refused) {
1700
+ runtime.broadcast?.({
1701
+ type: 'chatSeatRefused',
1702
+ chat: chatId,
1703
+ cliKey: r.cliKey,
1704
+ reason: r.reason,
1705
+ ...(b.projectId !== undefined ? { project_id: b.projectId } : {}),
1706
+ });
1707
+ }
1708
+ return reply.code(201).send({
1709
+ chatId,
1710
+ seats,
1711
+ scope,
1712
+ refused,
1713
+ ...(projectAttachError !== undefined ? { projectAttachError } : {}),
1714
+ });
1715
+ }
1716
+ catch (err) {
1717
+ // Nothing warmed: the scratch root prepared above must not linger — removed under the SAME
1718
+ // base the resolver created it in (Copilot, #518), fail-closed like every removal.
1719
+ chatScopes.release(chatId, token);
1720
+ removeChatScratch(scope.cwd, chatScopes.base);
1721
+ return reply.code(400).send({ error: message(err) });
1460
1722
  }
1461
- return reply.code(201).send({ chatId, seats });
1462
1723
  }
1463
- catch (err) {
1464
- return reply.code(400).send({ error: message(err) });
1724
+ finally {
1725
+ // A reservation that never became a scope (any early return above) must not pin the id —
1726
+ // token-guarded, so a reservation that is no longer ours is left alone.
1727
+ chatScopes.release(chatId, token);
1465
1728
  }
1466
1729
  });
1467
1730
  const ChatMessageSchema = z.object({
@@ -1488,7 +1751,7 @@ runtime = {}) {
1488
1751
  // lived in the tab that abandoned it, and an operator could not reclaim one without restarting
1489
1752
  // the daemon. Registered BEFORE `/chats/:id` is irrelevant to fastify (it routes on the literal
1490
1753
  // segment first), but the order reads the way the routes nest.
1491
- app.get(`${V}/chats`, async (_req, reply) => {
1754
+ app.get(`${V}/chats`, { config: { manifest: { responseType: 'ChatListResponse', statusCodes: [200, 400, 501] } } }, async (_req, reply) => {
1492
1755
  try {
1493
1756
  return { chats: await adapter.chatList() };
1494
1757
  }
@@ -1503,10 +1766,11 @@ runtime = {}) {
1503
1766
  .send({ error: message(err) });
1504
1767
  }
1505
1768
  });
1506
- app.get(`${V}/chats/:id`, async (req, reply) => {
1769
+ app.get(`${V}/chats/:id`, { config: { manifest: { responseType: 'ChatDetailResponse', statusCodes: [200, 400] } } }, async (req, reply) => {
1507
1770
  const { id } = req.params;
1508
1771
  try {
1509
- return { chatId: id, seats: await adapter.chatSeats(id) };
1772
+ // The scope recorded at open (crew#502) — `null` for a chat this daemon did not open.
1773
+ return { chatId: id, seats: await adapter.chatSeats(id), scope: chatScopes.get(id) ?? null };
1510
1774
  }
1511
1775
  catch (err) {
1512
1776
  return reply.code(400).send({ error: message(err) });
@@ -1514,6 +1778,10 @@ runtime = {}) {
1514
1778
  });
1515
1779
  app.delete(`${V}/chats/:id`, async (req, reply) => {
1516
1780
  const { id } = req.params;
1781
+ // The scratch root goes with the chat at once, whether or not the engine still knew it
1782
+ // (crew#502); the id stays parked until the engine's `chatClosed` is observed, so a delayed
1783
+ // close can never land on a chat that reused it (Copilot, #518).
1784
+ chatScopes.beginClose(id);
1517
1785
  try {
1518
1786
  await adapter.chatClose(id);
1519
1787
  return { ok: true };
@@ -2411,7 +2679,9 @@ runtime = {}) {
2411
2679
  if (!repo)
2412
2680
  return reply.code(404).send({ error: `Repo ${id} not found` });
2413
2681
  const graphPath = requirementsGraph(repo);
2414
- const dbPath = codeGraphDb(repo);
2682
+ const dbPath = codeGraphDbOr503(repo, reply);
2683
+ if (dbPath === null)
2684
+ return reply;
2415
2685
  // Coverage from the live estate store — computed by wicked-core governance layer.
2416
2686
  let coverage = null;
2417
2687
  if (existsSync(dbPath)) {
@@ -2469,7 +2739,17 @@ runtime = {}) {
2469
2739
  if (!parsed.success) {
2470
2740
  return reply.code(400).send(invalidBody(parsed.error, 'Invalid query'));
2471
2741
  }
2472
- const page = await listRequirements(repo, parsed.data);
2742
+ let page;
2743
+ try {
2744
+ page = await listRequirements(repo, parsed.data);
2745
+ }
2746
+ catch (err) {
2747
+ // wicked-core#406: a CURRENT engine with no repo-graph root is a 503, not a 500.
2748
+ const status = codeGraphErrorStatus(err);
2749
+ if (status === null)
2750
+ throw err;
2751
+ return reply.code(status).send({ error: message(err) });
2752
+ }
2473
2753
  if (page === null) {
2474
2754
  return reply.code(404).send({ error: 'requirements_graph.json not generated for this repo yet' });
2475
2755
  }
@@ -2494,7 +2774,17 @@ runtime = {}) {
2494
2774
  catch {
2495
2775
  return reply.code(400).send({ error: 'Malformed requirement key encoding' });
2496
2776
  }
2497
- const detail = await getRequirement(repo, decoded);
2777
+ let detail;
2778
+ try {
2779
+ detail = await getRequirement(repo, decoded);
2780
+ }
2781
+ catch (err) {
2782
+ // wicked-core#406: a CURRENT engine with no repo-graph root is a 503, not a 500.
2783
+ const status = codeGraphErrorStatus(err);
2784
+ if (status === null)
2785
+ throw err;
2786
+ return reply.code(status).send({ error: message(err) });
2787
+ }
2498
2788
  if (detail === null)
2499
2789
  return reply.code(404).send({ error: 'Requirement not found' });
2500
2790
  return { requirement: detail };
@@ -2529,20 +2819,42 @@ runtime = {}) {
2529
2819
  catch {
2530
2820
  return reply.code(400).send({ error: 'Malformed requirement key encoding' });
2531
2821
  }
2532
- const detail = await patchRequirement(repo, decodedKey, parsed.data);
2822
+ let detail;
2823
+ try {
2824
+ detail = await patchRequirement(repo, decodedKey, parsed.data);
2825
+ }
2826
+ catch (err) {
2827
+ // wicked-core#406: a CURRENT engine with no repo-graph root is a 503, not a 500.
2828
+ const status = codeGraphErrorStatus(err);
2829
+ if (status === null)
2830
+ throw err;
2831
+ return reply.code(status).send({ error: message(err) });
2832
+ }
2533
2833
  if (detail === null)
2534
2834
  return reply.code(404).send({ error: 'Requirement not found' });
2535
2835
  return { requirement: detail };
2536
2836
  });
2537
- app.get(`${V}/repos/:id/graph`, async (req, reply) => {
2837
+ app.get(`${V}/repos/:id/graph`, { config: { manifest: { responseType: 'RepoGraphResponse', statusCodes: [200, 404, 500, 503] } } }, async (req, reply) => {
2538
2838
  const { id } = req.params;
2539
2839
  const repos = await adapter.listRepos();
2540
2840
  const repo = repos.find((r) => r.id === id);
2541
2841
  if (!repo)
2542
2842
  return reply.code(404).send({ error: `Repo ${id} not found` });
2543
- const dbPath = codeGraphDb(repo);
2843
+ const dbPath = codeGraphDbOr503(repo, reply);
2844
+ if (dbPath === null)
2845
+ return reply;
2544
2846
  if (!existsSync(dbPath)) {
2545
- return reply.send({ graph: null });
2847
+ // `graph: null` alone cannot tell "not indexed" from "empty" (F-2R2-005): say WHY, with the
2848
+ // same finding text the repos wire carries when the engine has one.
2849
+ const finding = (repo.findings ?? []).find((f) => f.code === 'in_tree_code_graph_ignored' || f.code === 'code_graph_root_unresolvable');
2850
+ const unbuilt = {
2851
+ graph: null,
2852
+ reason: finding?.message ??
2853
+ `no code graph has been built for '${repo.name}' yet (nothing at ${dbPath}) — run onboarding ` +
2854
+ `(POST /api/v1/repos/${repo.id}/onboard) to index it`,
2855
+ ...(finding !== undefined ? { finding } : {}),
2856
+ };
2857
+ return reply.send(unbuilt);
2546
2858
  }
2547
2859
  try {
2548
2860
  const settings = await adapter.getSettings();
@@ -2563,13 +2875,14 @@ runtime = {}) {
2563
2875
  });
2564
2876
  const raw = JSON.parse(stdout);
2565
2877
  const fileCount = new Set(raw.nodes.map((n) => n.file)).size;
2566
- return reply.send({
2878
+ const built = {
2567
2879
  graph: {
2568
2880
  nodes: raw.nodes,
2569
2881
  edges: raw.edges,
2570
2882
  stats: { nodeCount: raw.nodes.length, edgeCount: raw.edges.length, fileCount },
2571
2883
  },
2572
- });
2884
+ };
2885
+ return reply.send(built);
2573
2886
  }
2574
2887
  catch (err) {
2575
2888
  return reply.code(500).send({ error: message(err) });
@@ -2591,7 +2904,9 @@ runtime = {}) {
2591
2904
  const repo = repos.find((r) => r.id === id);
2592
2905
  if (!repo)
2593
2906
  return reply.code(404).send({ error: `Repo ${id} not found` });
2594
- const dbPath = codeGraphDb(repo);
2907
+ const dbPath = codeGraphDbOr503(repo, reply);
2908
+ if (dbPath === null)
2909
+ return reply;
2595
2910
  if (!existsSync(dbPath)) {
2596
2911
  return reply.code(404).send({ error: 'Code graph not built for this repo yet' });
2597
2912
  }