wicked-crew 0.8.6 → 0.8.8

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 (91) hide show
  1. package/dist/api/ask-paths.d.ts +18 -1
  2. package/dist/api/ask-paths.d.ts.map +1 -1
  3. package/dist/api/ask-paths.js +36 -1
  4. package/dist/api/ask-paths.js.map +1 -1
  5. package/dist/api/ask-relay.d.ts.map +1 -1
  6. package/dist/api/ask-relay.js +20 -0
  7. package/dist/api/ask-relay.js.map +1 -1
  8. package/dist/api/chat-transcripts.d.ts +14 -6
  9. package/dist/api/chat-transcripts.d.ts.map +1 -1
  10. package/dist/api/chat-transcripts.js +66 -10
  11. package/dist/api/chat-transcripts.js.map +1 -1
  12. package/dist/api/diagnostics.d.ts +10 -0
  13. package/dist/api/diagnostics.d.ts.map +1 -1
  14. package/dist/api/diagnostics.js +37 -2
  15. package/dist/api/diagnostics.js.map +1 -1
  16. package/dist/api/roster-standing.d.ts.map +1 -1
  17. package/dist/api/roster-standing.js +13 -3
  18. package/dist/api/roster-standing.js.map +1 -1
  19. package/dist/api/routes.d.ts +50 -17
  20. package/dist/api/routes.d.ts.map +1 -1
  21. package/dist/api/routes.js +170 -18
  22. package/dist/api/routes.js.map +1 -1
  23. package/dist/api/seat-governance.d.ts +115 -0
  24. package/dist/api/seat-governance.d.ts.map +1 -0
  25. package/dist/api/seat-governance.js +166 -0
  26. package/dist/api/seat-governance.js.map +1 -0
  27. package/dist/api/seat-standing.d.ts +12 -1
  28. package/dist/api/seat-standing.d.ts.map +1 -1
  29. package/dist/api/seat-standing.js +11 -9
  30. package/dist/api/seat-standing.js.map +1 -1
  31. package/dist/api/server.d.ts.map +1 -1
  32. package/dist/api/server.js +4 -0
  33. package/dist/api/server.js.map +1 -1
  34. package/dist/api/testing.d.ts +2 -2
  35. package/dist/api/unit-output.d.ts +17 -0
  36. package/dist/api/unit-output.d.ts.map +1 -1
  37. package/dist/api/unit-output.js +47 -0
  38. package/dist/api/unit-output.js.map +1 -1
  39. package/dist/cli/index.d.ts.map +1 -1
  40. package/dist/cli/index.js +7 -0
  41. package/dist/cli/index.js.map +1 -1
  42. package/dist/core/bridge-path.d.ts +24 -3
  43. package/dist/core/bridge-path.d.ts.map +1 -1
  44. package/dist/core/bridge-path.js +96 -6
  45. package/dist/core/bridge-path.js.map +1 -1
  46. package/dist/core/engine-roster.d.ts.map +1 -1
  47. package/dist/core/engine-roster.js +3 -0
  48. package/dist/core/engine-roster.js.map +1 -1
  49. package/dist/core/estate-mcp-client.d.ts +8 -0
  50. package/dist/core/estate-mcp-client.d.ts.map +1 -1
  51. package/dist/core/estate-mcp-client.js +7 -0
  52. package/dist/core/estate-mcp-client.js.map +1 -1
  53. package/dist/core/linked-issues.d.ts +20 -2
  54. package/dist/core/linked-issues.d.ts.map +1 -1
  55. package/dist/core/linked-issues.js +41 -6
  56. package/dist/core/linked-issues.js.map +1 -1
  57. package/dist/core/worker-env.d.ts +31 -0
  58. package/dist/core/worker-env.d.ts.map +1 -0
  59. package/dist/core/worker-env.js +34 -0
  60. package/dist/core/worker-env.js.map +1 -0
  61. package/dist/interactive/bridge-pool.d.ts +20 -0
  62. package/dist/interactive/bridge-pool.d.ts.map +1 -1
  63. package/dist/interactive/bridge-pool.js +34 -2
  64. package/dist/interactive/bridge-pool.js.map +1 -1
  65. package/dist/qe/acceptance.d.ts +3 -0
  66. package/dist/qe/acceptance.d.ts.map +1 -1
  67. package/dist/qe/acceptance.js +1 -0
  68. package/dist/qe/acceptance.js.map +1 -1
  69. package/dist/qe/conformance.d.ts +36 -3
  70. package/dist/qe/conformance.d.ts.map +1 -1
  71. package/dist/qe/conformance.js +111 -4
  72. package/dist/qe/conformance.js.map +1 -1
  73. package/dist/skills/runtime.d.ts +56 -1
  74. package/dist/skills/runtime.d.ts.map +1 -1
  75. package/dist/skills/runtime.js +135 -2
  76. package/dist/skills/runtime.js.map +1 -1
  77. package/dist/skills/store.d.ts +27 -0
  78. package/dist/skills/store.d.ts.map +1 -1
  79. package/dist/skills/store.js +63 -0
  80. package/dist/skills/store.js.map +1 -1
  81. package/dist/studio/assets/index-BPYzJzBk.css +32 -0
  82. package/dist/studio/assets/index-DtVsHuuC.js +584 -0
  83. package/dist/studio/editors/wicked-page/editor.json +1 -1
  84. package/dist/studio/editors/wicked-page/index.html +1 -1
  85. package/dist/studio/index.html +2 -2
  86. package/dist/studio/testid-inventory.json +269 -1713
  87. package/dist/team/routes.d.ts +2 -2
  88. package/endpoint-manifest.json +11 -1
  89. package/package.json +3 -3
  90. package/dist/studio/assets/index-CUNMURZ4.js +0 -581
  91. package/dist/studio/assets/index-DPqNRzrT.css +0 -32
@@ -14,12 +14,13 @@ import { detectRefusal } from './gate-cache.js';
14
14
  import { acceptanceRequirementOf, buildAcceptanceView } from '../qe/acceptance.js';
15
15
  import { runIdentityOf, wireIdentity } from '../core/run-identity.js';
16
16
  import { buildEvidenceBundle, coreUnitId, evidenceFilename } from './evidence.js';
17
- import { outputUnavailableReason, resolveUnit, unitKeysFor } from './unit-output.js';
17
+ import { attemptUnavailableReason, capturedAttempts, outputUnavailableReason, resolveUnit, storedAttempt, unitKeysFor } from './unit-output.js';
18
18
  import { execCapped, ExecOutputTooLarge } from '../core/exec.js';
19
- import { callEstateTool, EstateMcpError } from '../core/estate-mcp-client.js';
19
+ import { callEstateTool, EstateMcpError, memoryStoreInfo } from '../core/estate-mcp-client.js';
20
20
  import { SeatHealthTracker } from './seat-health.js';
21
21
  import { applyWorkerConfigRoot, signedInHeuristic } from './seat-signin.js';
22
22
  import { chatSeatAdmission } from './seat-standing.js';
23
+ import { seatGovernanceDiagnostics, seatGovernanceWarnings, } from './seat-governance.js';
23
24
  import { rosterWithStandingFactory } from './roster-standing.js';
24
25
  import { ChatTurnIndex } from './chat-turns.js';
25
26
  import { ChatScopeIndex, chatScopeDeps, chatScopeStatement, prepareChatScratch, removeChatScratch, namedKindRefusal, resolveChatScope, } from './chat-scope.js';
@@ -64,7 +65,7 @@ import { MembershipIndex } from '../projects/membership-index.js';
64
65
  import { MEMBERSHIP_ATTACHED, membershipAttachedKey } from '../projects/events.js';
65
66
  import { resolveLinkedIssues } from '../core/linked-issues.js';
66
67
  import { AuditLog } from './audit.js';
67
- import { AcpFoldCache, EngineVersionCache, eventsDirOf, installedPackageVersion, listStoreFiles, readStudioBundleVersion, } from './diagnostics.js';
68
+ import { AcpFoldCache, EngineVersionCache, eventsDirOf, installedPackageVersion, hostSandboxLauncher, seatRosterFile, listStoreFiles, readStudioBundleVersion, } from './diagnostics.js';
68
69
  import { GovernanceDiagnostics } from './governance-health.js';
69
70
  import { governanceStoreFix, legacyHomeOutboxPath } from '../core/governance-store.js';
70
71
  import { UsageError, replayOutboxInto } from '../cli/governance.js';
@@ -147,15 +148,28 @@ function message(err) {
147
148
  * the caller comparing their JSON against the source, so the unknown keys are named in `error`
148
149
  * itself, where a human and a `curl | jq .error` both see it without reading `details`.
149
150
  */
150
- function invalidBody(err, what) {
151
+ export function invalidBody(err, what) {
151
152
  const unknown = err.issues.flatMap((i) => (i.code === 'unrecognized_keys' ? i.keys : []));
152
153
  const error = unknown.length > 0
153
154
  ? `${what}: unknown field${unknown.length > 1 ? 's' : ''} ${unknown
154
155
  .map((k) => `\`${k}\``)
155
156
  .join(', ')} — this endpoint does not accept ${unknown.length > 1 ? 'them' : 'it'}, and ignoring ${unknown.length > 1 ? 'them' : 'it'} would run a different request than you sent`
156
- : what;
157
+ : namedIssues(what, err.issues);
157
158
  return { error, details: err.issues };
158
159
  }
160
+ /**
161
+ * crew#662: a schema rejection that is NOT an unknown key names the field and what was expected —
162
+ * `Invalid request body: \`revisesPr\` — Expected number, received boolean`. The bare
163
+ * "Invalid request body" sent a caller off to misdiagnose the contract. Up to three issues are
164
+ * spelled out; the rest are counted (all of them stay in `details`).
165
+ */
166
+ function namedIssues(what, issues) {
167
+ if (issues.length === 0)
168
+ return what;
169
+ const shown = issues.slice(0, 3).map((i) => (i.path.length > 0 ? `\`${i.path.join('.')}\` — ${i.message}` : i.message));
170
+ const more = issues.length > 3 ? `; and ${issues.length - 3} more (see details)` : '';
171
+ return `${what}: ${shown.join('; ')}${more}`;
172
+ }
159
173
  /** First value of a possibly-repeated query param, WITHOUT trimming — `''` is PRESERVED because a
160
174
  * blank `scope`/`scope_prefix` is meaningful to estate (root / whole-subtree), unlike an omitted
161
175
  * one. Returns undefined only when the param is absent. */
@@ -312,6 +326,11 @@ const OnboardBodySchema = z
312
326
  // compile time, that every body the published contract (`wicked-crew-api-types`) lets a client
313
327
  // send is a body these schemas accept — the request-direction half of the drift guard (task #84).
314
328
  export { PlanSchema };
329
+ /** crew#825: the issue refs a launch leaves out of the linked-issue expansion. */
330
+ const ExcludeLinkedIssuesSchema = z
331
+ .array(z.string().regex(/^\s*(?:\d+|(?:(?:[\w.-]+\/)?[\w.-]+)?#\d+)\s*$/, 'an issue ref: 541, #541, repo#541 or owner/repo#541'))
332
+ .max(50)
333
+ .optional();
315
334
  export const LaunchSchema = z.object({
316
335
  problem: z.string().min(1),
317
336
  sessionId: z.string().min(1).optional(),
@@ -374,6 +393,11 @@ export const LaunchSchema = z.object({
374
393
  * launch (e.g. a CI job name or a studio tab id). Persisted on the `run.launched` audit entry
375
394
  * (`detail.actor`) and served on the run DTO; absent = caller omitted it. */
376
395
  actor: z.string().min(1).max(256).optional(),
396
+ /** crew#825 (api-types additive) — issue refs the intent names that the daemon must NOT read and
397
+ * append (`541`, `#541`, `repo#541`, `owner/repo#541`): a reference in passing is not a request to inline
398
+ * the issue. They are answered as `linkedIssues[].excluded`. Send only when
399
+ * `GET /health.capabilities.linkedIssuesExclude === true`. */
400
+ excludeLinkedIssues: ExcludeLinkedIssuesSchema,
377
401
  }).strict().refine((b) => b.plan === undefined || b.workflow === undefined, {
378
402
  message: 'plan and workflow are mutually exclusive — a launch carries a plan or names a preset, not both',
379
403
  path: ['plan'],
@@ -763,7 +787,8 @@ runtime = {}) {
763
787
  /** DES-L9: PR number → head branch, through `gh pr view` unless the runtime injected an answerer. */
764
788
  const resolvePullRequest = runtime.resolvePullRequest ?? resolvePullRequestViaGh;
765
789
  /** crew#627: the intent's linked issues, read under the daemon's identity at launch. */
766
- const readLinkedIssues = runtime.resolveLinkedIssues ?? ((p, root, ref) => resolveLinkedIssues(p, root, ref));
790
+ const readLinkedIssues = runtime.resolveLinkedIssues ??
791
+ ((p, root, ref, exclude) => resolveLinkedIssues(p, root, ref, undefined, exclude));
767
792
  /** crew#550 P-7: the follow-ups the run's evaluator units flagged, read from their captured output.
768
793
  * A unit whose output cannot be read contributes nothing (the section then says what WAS read);
769
794
  * an adapter without transcripts (a directly-driven route set) reads as "not read" — absent. */
@@ -999,6 +1024,8 @@ runtime = {}) {
999
1024
  : { deliverGate: false, revisesPr: false, chatIdOnLaunch: false, seatChipOnCreate: false }),
1000
1025
  // C1: `AgentSession.chat_id` is served from the daemon's own launch index, whatever the engine.
1001
1026
  runChatId: true,
1027
+ // crew#825: `LaunchRunBody.excludeLinkedIssues` + `POST /linked-issues/preview` are served.
1028
+ linkedIssuesExclude: true,
1002
1029
  // ASK-C1 (DES-ASK-TEAM-CHAT-001 §5.1): an ask starts a path — the whole contract is the
1003
1030
  // pinned core-ts floor the adapter reports; an older studio reads ABSENT as "every helper
1004
1031
  // answers", a newer one renders the one PA voice.
@@ -1035,6 +1062,9 @@ runtime = {}) {
1035
1062
  ...(adapter.busUnavailable != null ? [busUnavailableWarning(adapter.busUnavailable)] : []),
1036
1063
  // wicked-core#631: the engine has the bus but not the calls crew reaches it through.
1037
1064
  ...(adapter.busSeamsOff != null ? [busSeamsOffWarning(adapter.busSeamsOff)] : []),
1065
+ // crew#742: a seat that runs ungoverned, or floor seats on a host that cannot arm their
1066
+ // boundary, are named before any run starts.
1067
+ ...seatGovernanceWarnings(seatGovernanceNow(), hostSandboxLauncher()),
1038
1068
  ];
1039
1069
  return {
1040
1070
  status: 'ok',
@@ -1100,6 +1130,16 @@ runtime = {}) {
1100
1130
  // who classified (the engine, or crew's registry copy on an older addon) and whether that
1101
1131
  // refuses launches — re-surveyed per read. `null` on a route set booted without the watch.
1102
1132
  stateHome: runtime.stateHome !== undefined ? await runtime.stateHome.refresh() : null,
1133
+ // crew#742: per seat — transport, the record's OS sandbox, the engine's governance class,
1134
+ // input governance enforced / claimed / unenforced, the mode per scope kind; plus the
1135
+ // registry overlay file and this host's OS launcher.
1136
+ seatGovernance: {
1137
+ rosterFile: seatRosterFile(),
1138
+ hostBoundary: hostSandboxLauncher(),
1139
+ seats: seatGovernanceNow(),
1140
+ },
1141
+ // crew#740: which memory store this daemon reads and writes, and the isolation notice.
1142
+ memoryStore: memoryStoreInfo(),
1103
1143
  };
1104
1144
  });
1105
1145
  // Who am I talking to the daemon as? (task #88). In local mode this is
@@ -1161,6 +1201,16 @@ runtime = {}) {
1161
1201
  // disagree about a seat.
1162
1202
  const rosterWithStanding = runtime.rosterWithStanding ?? rosterWithStandingFactory({ seatHealth, signedIn });
1163
1203
  app.get(`${V}/roster`, async () => ({ roster: rosterWithStanding() }));
1204
+ /** crew#742: the seats' governance standing, read from the standing roster (empty when the
1205
+ * roster cannot be read — the roster route reports that failure itself). */
1206
+ const seatGovernanceNow = () => {
1207
+ try {
1208
+ return seatGovernanceDiagnostics(rosterWithStanding());
1209
+ }
1210
+ catch {
1211
+ return [];
1212
+ }
1213
+ };
1164
1214
  // Each seat's week (studio's weekly 1:1 per agent; api-types 0.52.0): units, first pass, rework,
1165
1215
  // stalls, bench and cost, folded from the runs' durable event logs by `seatRecord` — nothing new
1166
1216
  // is stored. The daemon's remembered stall frames join the engine's, as on `/runs/:id/events`.
@@ -1562,6 +1612,33 @@ runtime = {}) {
1562
1612
  const projectAttachError = await recordOnboardingLaunch(req, runId, repo.id, body.data);
1563
1613
  return reply.code(201).send({ runId, ...(projectAttachError !== undefined ? { projectAttachError } : {}) });
1564
1614
  });
1615
+ // crew#825: what a workflow launch WOULD append — the same reading `POST /runs` does, before Send,
1616
+ // so the composer shows "N linked issues will be appended (≈K chars)" with a way to leave one out
1617
+ // (`excludeLinkedIssues`). Read-only: nothing launches, nothing is stored.
1618
+ const LinkedIssuesPreviewSchema = z
1619
+ .object({
1620
+ problem: z.string().min(1),
1621
+ repoRef: z.string().min(1).optional(),
1622
+ excludeLinkedIssues: ExcludeLinkedIssuesSchema,
1623
+ })
1624
+ .strict();
1625
+ app.post(`${V}/linked-issues/preview`, { config: { manifest: { requestType: 'LinkedIssuesPreviewBody', responseType: 'LinkedIssuesPreviewResponse', statusCodes: [200, 400] } } }, async (req, reply) => {
1626
+ const parsed = LinkedIssuesPreviewSchema.safeParse(req.body);
1627
+ if (!parsed.success)
1628
+ return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
1629
+ const b = parsed.data;
1630
+ // The launch's own best-effort contract (codex on this PR): a resolver fault launches on the
1631
+ // intent alone, so the preview answers "nothing appended" and says why — never a 500.
1632
+ try {
1633
+ const root = b.repoRef !== undefined ? await repoRootOf(b.repoRef) : undefined;
1634
+ const linked = await readLinkedIssues(b.problem, root, b.repoRef, b.excludeLinkedIssues ?? []);
1635
+ return { issues: linked.issues, appendedChars: linked.block === null ? 0 : linked.block.length + 2 };
1636
+ }
1637
+ catch (err) {
1638
+ req.log.warn(`linked-issue preview failed; a launch would run on the intent alone: ${message(err)}`);
1639
+ return { issues: [], appendedChars: 0, error: `linked issues could not be read: ${message(err)} — a launch now runs on the intent alone` };
1640
+ }
1641
+ });
1565
1642
  // Launch a run (replaces POST /sessions). `clisJson` defaults to the roster;
1566
1643
  // `sessionId` is minted if the client omits it.
1567
1644
  app.post(`${V}/runs`, {
@@ -1774,11 +1851,12 @@ runtime = {}) {
1774
1851
  // fault (the repo list unreadable, say) launches the run on its intent alone and is logged.
1775
1852
  try {
1776
1853
  const root = b.repoRef !== undefined ? await repoRootOf(b.repoRef) : undefined;
1777
- const linked = await readLinkedIssues(b.problem, root, b.repoRef);
1778
- if (linked.block !== null) {
1854
+ const linked = await readLinkedIssues(b.problem, root, b.repoRef, b.excludeLinkedIssues ?? []);
1855
+ if (linked.block !== null)
1779
1856
  input.problem = `${b.problem.trimEnd()}\n\n${linked.block}`;
1857
+ // crew#825: an all-excluded intent appends nothing but still answers what it left out.
1858
+ if (linked.issues.length > 0)
1780
1859
  linkedIssues = linked.issues;
1781
- }
1782
1860
  }
1783
1861
  catch (err) {
1784
1862
  req.log.warn(`linked-issue resolution failed; launching on the intent alone: ${message(err)}`);
@@ -1875,7 +1953,11 @@ runtime = {}) {
1875
1953
  // planner (one unit, the brief verbatim). Say so in the answer, with how to get work done,
1876
1954
  // instead of letting it pass for a launch of real work.
1877
1955
  const freeText = input.workflow === undefined && input.plan === undefined ? { freeText: { notice: FREE_TEXT_NOTICE } } : {};
1878
- return reply.code(201).send({ runId, ...(linkedIssues !== undefined ? { linkedIssues } : {}), ...freeText });
1956
+ // crew#874: the run uses the PUBLISHED generation; when the installed garden is not the one it
1957
+ // was built from, the launch answer says so (a recorded proof can disclose which garden ran).
1958
+ const skillsAhead = runtime.skills?.installedAheadFinding({ fresh: true }) ?? null; // fresh: an upgrade just before Send counts (codex r1)
1959
+ const skillsWarning = skillsAhead === null ? {} : { skillsWarning: { kind: skillsAhead.kind, severity: skillsAhead.severity, message: skillsAhead.message } };
1960
+ return reply.code(201).send({ runId, ...(linkedIssues !== undefined ? { linkedIssues } : {}), ...freeText, ...skillsWarning });
1879
1961
  }
1880
1962
  catch (err) {
1881
1963
  const msg = message(err);
@@ -2495,7 +2577,10 @@ runtime = {}) {
2495
2577
  refused.push({ cliKey: key, reason: `seat '${key}' is not in the roster`, source: 'scope' });
2496
2578
  continue;
2497
2579
  }
2498
- const admission = chatSeatAdmission(seat, seat.auth ?? 'unknown', scoped, admissionKind);
2580
+ const admission = chatSeatAdmission(
2581
+ // IG1-crew-2: the standing seat carries the engine's `governance_class` and the pin
2582
+ // reading, so a scoped ask admits by the seat's governance mode (reason names it).
2583
+ seat, seat.auth ?? 'unknown', scoped, admissionKind);
2499
2584
  if (admission.ok)
2500
2585
  clis.push(key);
2501
2586
  else
@@ -2575,7 +2660,12 @@ runtime = {}) {
2575
2660
  }
2576
2661
  // ASK-C1: the path record — published only now, with the scope committed, so a message
2577
2662
  // cannot launch against a scope that is still being filed (codex on #808 r2, 4).
2578
- askPaths.open(chatId, clis, b.primary);
2663
+ // IG1-crew-1: with the admitted seats' records and the scope kind, so the path's view
2664
+ // names the PA's governance mode.
2665
+ askPaths.open(chatId, clis, b.primary, {
2666
+ seats: standing.filter((s) => clis.includes(String(s.key))),
2667
+ scopeKind: !scoped ? 'unscoped' : admissionKind === 'path-bound' ? 'scoped_bound' : 'scoped',
2668
+ });
2579
2669
  // Register roots for path rewriting (crew#618): absolute host paths in seat replies are
2580
2670
  // rewritten to repo-relative form before being stored in the transcript.
2581
2671
  if (chatTranscripts !== undefined && scope.repos.length > 0) {
@@ -2896,7 +2986,10 @@ runtime = {}) {
2896
2986
  const seat = standingNow.find((s) => String(s.key) === cliKey);
2897
2987
  if (seat === undefined)
2898
2988
  return { cliKey, ok: false, error: `seat '${cliKey}' is not in the roster` };
2899
- const admission = chatSeatAdmission(seat, seat.auth ?? 'unknown', scopedChat, reseatKind);
2989
+ const admission = chatSeatAdmission(
2990
+ // IG1-crew-2: the standing seat carries the engine's `governance_class` and the pin
2991
+ // reading, so a scoped ask admits by the seat's governance mode (reason names it).
2992
+ seat, seat.auth ?? 'unknown', scopedChat, reseatKind);
2900
2993
  return admission.ok ? { cliKey, ok: true } : { cliKey, ok: false, error: admission.reason };
2901
2994
  });
2902
2995
  askPaths.admit(id, seats.filter((s) => s.ok).map((s) => s.cliKey));
@@ -2974,7 +3067,9 @@ runtime = {}) {
2974
3067
  refused: chatScopes.refusedOf(id) ?? null,
2975
3068
  ...(askPaths.view(id) !== undefined ? { path: askPaths.view(id) } : {}),
2976
3069
  // DES-L5 (D-13): the transcript so far, append order — `[]` before the first persisted
2977
- // turn; the file goes with the chat on `chatClosed`, so a reclaimed id answers `[]` too.
3070
+ // turn. The file goes with the chat on `chatClosed` UNLESS a run promoted from it is still
3071
+ // live (crew#619 retention — dropped when that run terminals), so a reclaimed id answers
3072
+ // `[]` only once no promoted run holds the old file.
2978
3073
  ...(chatTranscripts !== undefined ? { messages: chatTranscripts.read(id) } : {}),
2979
3074
  };
2980
3075
  }
@@ -3050,12 +3145,48 @@ runtime = {}) {
3050
3145
  units: unitKeysFor(run),
3051
3146
  });
3052
3147
  }
3148
+ const rawAttempt = req.query.attempt;
3149
+ const attemptArg = Array.isArray(rawAttempt) ? rawAttempt[0] : rawAttempt;
3150
+ const wanted = attemptArg === undefined || attemptArg === '' ? undefined : /^\d{1,9}$/.test(attemptArg) ? Number(attemptArg) : NaN;
3151
+ if (wanted !== undefined && !Number.isSafeInteger(wanted)) {
3152
+ return reply.code(400).send({ error: '`attempt` must be a non-negative integer' });
3153
+ }
3053
3154
  // Keyed off the unit RECORD (the same derivation the evidence bundle uses), not off the
3054
3155
  // caller's path segment — the two disagreeing is what made a resolvable unit unreadable.
3055
3156
  const output = await adapter.workOutput(coreUnitId(id, unit));
3056
- if (output !== null)
3057
- return reply.send({ output });
3058
- return reply.send({ output: null, outputUnavailable: outputUnavailableReason(unit) });
3157
+ // crew#848: which attempt those bytes are. The engine keeps one transcript per unit (the last
3158
+ // attempt that folded ok); the durable log has one `unitOutputCaptured` per attempt.
3159
+ const events = typeof adapter.runEvents === 'function' ? await adapter.runEvents(id).catch(() => null) : null;
3160
+ const attempts = capturedAttempts((events ?? []), unit.ord);
3161
+ const served = output !== null ? storedAttempt(attempts, output) : null;
3162
+ const latest = attempts[attempts.length - 1];
3163
+ const meta = {
3164
+ ...(served !== null ? { attempt: served } : {}),
3165
+ ...(latest !== undefined ? { latestAttempt: latest.attempt } : {}),
3166
+ ...(events !== null ? { attempts } : {}),
3167
+ };
3168
+ if (served !== null)
3169
+ void reply.header('x-wicked-unit-attempt', String(served));
3170
+ if (wanted !== undefined) {
3171
+ const rec = attempts.find((a) => a.attempt === wanted);
3172
+ if (rec === undefined) {
3173
+ return reply.code(404).send({
3174
+ error: `Unit ${unit.ord} of run ${id} has no attempt ${wanted} on record`,
3175
+ attempts: attempts.map((a) => a.attempt),
3176
+ });
3177
+ }
3178
+ if (output !== null && served === wanted)
3179
+ return reply.send({ output, ...meta });
3180
+ return reply.send({ output: null, ...meta, attempt: wanted, outputUnavailable: attemptUnavailableReason(unit, rec, served) });
3181
+ }
3182
+ if (output !== null) {
3183
+ // A later attempt that left no transcript: the stored bytes are SUPERSEDED — say so, and why.
3184
+ if (served !== null && latest !== undefined && latest.attempt > served) {
3185
+ return reply.send({ output, ...meta, superseded: true, latestUnavailable: attemptUnavailableReason(unit, latest, served) });
3186
+ }
3187
+ return reply.send({ output, ...meta });
3188
+ }
3189
+ return reply.send({ output: null, ...meta, outputUnavailable: outputUnavailableReason(unit) });
3059
3190
  });
3060
3191
  // DC-S7 (§4.6): what the seats considered, set aside and cited for one chat turn or one unit
3061
3192
  // attempt. Operator+ (the rules are the project's doctrine). A cited rule is "unchecked", never
@@ -3150,6 +3281,26 @@ runtime = {}) {
3150
3281
  claims: () => adapter.listConformanceClaims(),
3151
3282
  events: (rid) => adapter.runEvents(rid),
3152
3283
  ...(walkthroughs.gates.length > 0 || walkthroughs.ownedByYou ? { walkthroughs } : {}),
3284
+ // IG1-crew-2: what fenced each unit — the run's binding and the roster's seat records.
3285
+ enforcementContext: {
3286
+ bound: run.session.repo_ref !== null,
3287
+ worktree: run.session.workdir,
3288
+ extraWriteRoots: run.session.extra_write_roots ?? [],
3289
+ seatOf: (() => {
3290
+ let seats = null;
3291
+ return (cli) => {
3292
+ if (seats === null) {
3293
+ try {
3294
+ seats = rosterWithStanding();
3295
+ }
3296
+ catch {
3297
+ seats = [];
3298
+ }
3299
+ }
3300
+ return seats.find((seat) => seat.key === cli);
3301
+ };
3302
+ })(),
3303
+ },
3153
3304
  });
3154
3305
  });
3155
3306
  /**
@@ -5201,7 +5352,8 @@ runtime = {}) {
5201
5352
  }
5202
5353
  if (limit !== undefined)
5203
5354
  memories = memories.slice(0, limit);
5204
- const body = { memories };
5355
+ // crew#740: the store the list came from (path, source, the isolation notice when it applies).
5356
+ const body = { memories, store: memoryStoreInfo() };
5205
5357
  return body;
5206
5358
  }
5207
5359
  catch (err) {