Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion FORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ migrations after upstream migrations.

47. Crew seats ask their Captain, not the person (#325, Jackson's 2026-09-26 decision). `apps/server/src/orchestration-v2/ProviderAdapter.ts` appends an optional `crewSeat` to `ProviderAdapterV2RuntimePolicy` beside `agentPersonaInstructions`. J5-owned `makeAgentPersonaRuntimePolicyResolver` (`j5/agents/agentPersonaRuntime.ts`), already the only resolver behind upstream `RuntimePolicy.ts`, asks J5-owned `makeCrewSeatLookup` (`j5/a2a/crewSeatRuntime.ts`) whether an `agent`/`mcp` thread holds a seat in a live Crew, through the Crew store built over the ambient SQL client (`RuntimePolicy.ts` is untouched and its layers gain no requirement; without a SQL client nothing is a seat, and an unreadable store logs and answers no). A seat's policy gets `crewSeat: true` and the ask-your-Captain rule appended to `agentPersonaInstructions`, so it lands after Codex's Default-mode "ask the user directly" text and Claude's system prompt without editing `CodexDeveloperInstructions.ts`. Two adapter appends consume the flag through J5-owned leaf `j5/a2a/crewSeatQuestions.ts`: `makeClaudeAdapterV2`'s `openQuery` spreads `j5ClaudeCrewSeatQueryOverrides(turnInput.runtimePolicy)` into `makeClaudeQueryOptions` (`disallowedTools: ["AskUserQuestion"]`, the SDK option that already existed but was never set), and `codexThreadRuntimeParams` spreads `j5CodexCrewSeatConfig(input.runtimePolicy)` into the thread `config` beside `mcp_servers` (dotted session overrides `features.default_mode_request_user_input = false` and `tools.experimental_request_user_input.enabled = false`, sent on thread start, resume, and fork only when the t3-code MCP session is attached). Cursor, Grok, OpenCode, Pi, ACP registry agents, and Antigravity are not supported on seats yet: they get only the seat brief's instruction (`spawnFirstTurnText` in `j5/a2a/spawnIds.ts`). Captains and every other thread keep their native question tools. Tests: `j5/a2a/crewSeatRuntime.test.ts` (seat, Captain, person thread, persona seat, archived Crew, no SQL client), `j5/a2a/crewSeatQuestions.test.ts` (Codex config and developer-text order), and one `ClaudeAdapterV2.test.ts` case, "withholds AskUserQuestion only from a Crew seat", on the existing wake harness. On every rebase, verify both spreads survive and that upstream has not renamed the Codex switches or added another native question tool.

48. Cross-server peering's scope (Jackson's 2026-09-16 ruling: the squadron id encapsulates the server and agents never address an environment; since 2026-10-02 agents see which server a participant lives on, by the server's own name, and still never manage one). `packages/contracts/src/auth.ts` appends the `a2a:peer` literal as `AuthA2APeerScope` to `AuthEnvironmentScope`, outside both `AuthStandardClientScopes` and `AuthAdministrativeScopes`, exactly as case 35 did for `a2a:send`: no pairing link and no administrative session carries it. Only the J5 peer credential route (`apps/server/src/j5/a2a/PeerHttp.ts`, behind `access:write`) mints it, bound by the session subject to one `peer:<environment id>`, and the peer routes authorize on that scope plus a registered peer. `packages/contracts/src/environment.ts` adds one optional capability key, `j5PeerPoll`, beside case 34's two, and `apps/server/src/environment/ServerEnvironment.ts` (with its test) reports it as true; it says a server has peering poll mode and the reachability routes, and the peering dialog offers a setup only when both servers publish it. Everything else is add-beside: migrations 23–27 (26 indexes the peer route probes; 27 indexes received sender labels by receipt time for the client identity read) and 30 (poll mode: the peer table rebuilt with its link mode, the store grants, and the delivery rows' hand-out stamp), `PeerRegistryService.ts`, `PeerInboundService.ts`, `PeerDirectory.ts`, `PeerStoreService.ts` (the poll route's long poll), `PeerPoller.ts` (one loop per peer this server polls, started with the A2A runtime), `peerDeliveryBody.ts`, `peerRoster.ts`, `peerReachability.ts` (the origins the `addresses` route offers; it imports upstream's host helpers from `startupAccess.ts` without changing them), `peerProtocol.ts` (the one peer protocol version compare, used in both directions), `PeerHttp.ts` entering through the case 9 aggregate (the J5 route files share `httpSupport.ts` for authentication and error shapes), the `peer` group in J5-owned `apps/server/src/j5/cli/a2a.ts`, and the wire contracts in `packages/contracts/src/j5.ts`. The peer layers are wired once in J5-owned `runtimeLayer.ts`, which gives them their own `FetchHttpClient` and takes `EnvironmentAuth` and `ServerEnvironment` from the server context, so the shared A2A runtime's requirements grow by those two upstream services; `ServerEnvironment`'s descriptor label is the name a server reports to its peers. One upstream-adjacent test moves with it: `apps/server/src/mcp/toolkits/worktree/registration.test.ts` (already J5-modified for `list_participants`) provides the empty `PeerDirectory` and a ledger stub for project titles, and expects `unread_peer_count` and `project_title`. The web side is one mount: `apps/web/src/components/settings/ConnectionsSettings.tsx` renders the J5-owned `PeerServersSettings` (from `apps/web/src/j5/peering/`) after the remote-environments section, gated by the page's existing `access:write` check, and imports nothing else from J5; the client helpers live in `packages/client-runtime/src/j5/http.ts`, `state.ts`, and the new `peering.ts` subpath export, which adds one `./j5/peering` entry to that package's exports map beside the existing J5 subpaths. On every rebase, verify the literal is still outside both bundles, `PAIRING_SCOPE_OPTIONS` in `ConnectionsSettings.tsx` still omits it, `runtimeLayer.ts` still provides the peer layers to the aggregate and the send service, and the single `PeerServersSettings` mount survives. Development databases that ran an earlier cut of this stack, where these migrations carried ids 14–16 and later 18–21, are disposable test state with no upgrade path: recreate them with `pr-env.sh --fresh`.
48. Cross-server peering's scope (Jackson's 2026-09-16 ruling: the squadron id encapsulates the server and agents never address an environment; since 2026-10-02 agents see which server a participant lives on, by the server's own name, and still never manage one). `packages/contracts/src/auth.ts` appends the `a2a:peer` literal as `AuthA2APeerScope` to `AuthEnvironmentScope`, outside both `AuthStandardClientScopes` and `AuthAdministrativeScopes`, exactly as case 35 did for `a2a:send`: no pairing link and no administrative session carries it. Only the J5 peer credential route (`apps/server/src/j5/a2a/PeerHttp.ts`, behind `access:write`) mints it, bound by the session subject to one `peer:<environment id>`, and the peer routes authorize on that scope plus a registered peer. `packages/contracts/src/environment.ts` adds one optional capability key, `j5PeerPoll`, beside case 34's two, and `apps/server/src/environment/ServerEnvironment.ts` (with its test) reports it as true; it says a server has peering poll mode and the reachability routes, and the peering dialog offers a setup only when both servers publish it. Everything else is add-beside: migrations 23–27 (26 indexes the peer route probes; 27 indexes received sender labels by receipt time for the client identity read) 30 (poll mode: the peer table rebuilt with its link mode, the store grants, and the delivery rows' hand-out stamp) and 32 (a stored roster snapshot's fields renamed for peer protocol 2, where the wire names a project and no longer a Squadron), `PeerRegistryService.ts`, `PeerInboundService.ts`, `PeerDirectory.ts`, `PeerStoreService.ts` (the poll route's long poll), `PeerPoller.ts` (one loop per peer this server polls, started with the A2A runtime), `peerDeliveryBody.ts`, `peerRoster.ts`, `peerReachability.ts` (the origins the `addresses` route offers; it imports upstream's host helpers from `startupAccess.ts` without changing them), `peerProtocol.ts` (the one peer protocol version compare, used in both directions; the version is 2 since the wire fields were renamed, so two peered servers update together and an older one is named as the one to update), `PeerHttp.ts` entering through the case 9 aggregate (the J5 route files share `httpSupport.ts` for authentication and error shapes), the `peer` group in J5-owned `apps/server/src/j5/cli/a2a.ts`, and the wire contracts in `packages/contracts/src/j5.ts`. The peer layers are wired once in J5-owned `runtimeLayer.ts`, which gives them their own `FetchHttpClient` and takes `EnvironmentAuth` and `ServerEnvironment` from the server context, so the shared A2A runtime's requirements grow by those two upstream services; `ServerEnvironment`'s descriptor label is the name a server reports to its peers. One upstream-adjacent test moves with it: `apps/server/src/mcp/toolkits/worktree/registration.test.ts` (already J5-modified for `list_participants`) provides the empty `PeerDirectory` and a ledger stub for project titles, and expects `unread_peer_count` and `project_title`. The web side is one mount: `apps/web/src/components/settings/ConnectionsSettings.tsx` renders the J5-owned `PeerServersSettings` (from `apps/web/src/j5/peering/`) after the remote-environments section, gated by the page's existing `access:write` check, and imports nothing else from J5; the client helpers live in `packages/client-runtime/src/j5/http.ts`, `state.ts`, and the new `peering.ts` subpath export, which adds one `./j5/peering` entry to that package's exports map beside the existing J5 subpaths. On every rebase, verify the literal is still outside both bundles, `PAIRING_SCOPE_OPTIONS` in `ConnectionsSettings.tsx` still omits it, `runtimeLayer.ts` still provides the peer layers to the aggregate and the send service, and the single `PeerServersSettings` mount survives. Development databases that ran an earlier cut of this stack, where these migrations carried ids 14–16 and later 18–21, are disposable test state with no upgrade path: recreate them with `pr-env.sh --fresh`.

49. Composer playbook picker (#314, #320). `/playbook ` (at message start) and `@playbook:` (anywhere) open one playbook picker fed by the J5 library route. These are client-side discovery and text-insertion seams; execution follows case 45's ordinary message expansion.
- Detection: shared `composerTrigger.ts` and web `composer-logic.ts` append the `"slash-playbook"` kind and delegate line-start `/playbook <name>` detection to J5-owned `packages/shared/src/j5/playbookTrigger.ts`, exported through `packages/shared/package.json`; it matches the kebab-case name at the start of the message and stops before following text (#314). The J5 `detectAgentMention` hook, already called at token level from both, returns the same kind for `@playbook:` tokens with the raw token after the prefix as the query (`packages/shared/src/j5/playbookMention.ts`; no upstream line).
Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/j5/a2a/Migrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ it.effect("tracks J5 A2A migrations independently from upstream migrations", ()
{ migration_id: 29, name: "CrewPlaybookRuns" },
{ migration_id: 30, name: "PeerPollMode" },
{ migration_id: 31, name: "LedgerRekeysToProjects" },
{ migration_id: 32, name: "PeerRosterSaysProject" },
]);
assert.deepStrictEqual(
migrationEntries.map(([id, name]) => [id, name]),
Expand Down Expand Up @@ -120,6 +121,7 @@ it.effect("tracks J5 A2A migrations independently from upstream migrations", ()
[29, "CrewPlaybookRuns"],
[30, "PeerPollMode"],
[31, "LedgerRekeysToProjects"],
[32, "PeerRosterSaysProject"],
],
);
}).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
Expand Down Expand Up @@ -1366,7 +1368,7 @@ it.effect("recreates earlier-shaped crews tables when 14 runs over them", () =>
`;
assert.deepStrictEqual(
applied.map((row) => row.migration_id),
[13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31],
[13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32],
);
const memberColumns = yield* sql<{ readonly name: string }>`
SELECT name FROM pragma_table_info('j5_agent_crew_member') ORDER BY cid
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/j5/a2a/Migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import Migration0028 from "./migrations/028_CrewPlaybooks.ts";
import Migration0029 from "./migrations/029_CrewPlaybookRuns.ts";
import Migration0030 from "./migrations/030_PeerPollMode.ts";
import Migration0031 from "./migrations/031_LedgerRekeysToProjects.ts";
import Migration0032 from "./migrations/032_PeerRosterSaysProject.ts";

export const J5_A2A_MIGRATIONS_TABLE = "j5_a2a_migrations";

Expand Down Expand Up @@ -73,6 +74,7 @@ export const migrationEntries = [
[29, "CrewPlaybookRuns", Migration0029],
[30, "PeerPollMode", Migration0030],
[31, "LedgerRekeysToProjects", Migration0031],
[32, "PeerRosterSaysProject", Migration0032],
] as const;

const makeMigrationLoader = (throughId?: number) =>
Expand Down
36 changes: 19 additions & 17 deletions apps/server/src/j5/a2a/PeerDirectory.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { ThreadId } from "@t3tools/contracts";
import { J5_PEER_API_PATHS, type PeerRosterResponse } from "@t3tools/contracts/j5";
import {
J5_PEER_API_PATHS,
PEER_PROTOCOL_VERSION,
type PeerRosterResponse,
} from "@t3tools/contracts/j5";
import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
Expand Down Expand Up @@ -47,17 +51,17 @@ const homeRoster: PeerRosterResponse = {
agents: [
{
participantId: "agent:j5:a2a:thread:support-triage",
squadronId: "project:home-support",
squadronName: "L2 Support Rotation",
projectId: "project:home-support",
projectTitle: "L2 Support Rotation",
displayName: "Support triage",
threadId: ThreadId.make("thread:support-triage"),
archived: false,
canReceiveMessage: true,
},
{
participantId: "agent:j5:a2a:thread:retired",
squadronId: "project:home-support",
squadronName: "L2 Support Rotation",
projectId: "project:home-support",
projectTitle: "L2 Support Rotation",
displayName: "Retired",
threadId: ThreadId.make("thread:retired"),
archived: true,
Expand All @@ -69,8 +73,8 @@ const homeRoster: PeerRosterResponse = {
const makeTestLayer = (
peers: ReadonlyArray<PeerConnection>,
seen: Array<{ url: string; authorization: string | undefined; protocol?: string | undefined }>,
/** The protocol header the roster answers state, if any. */
answeredProtocol?: string,
/** The protocol header the roster answers state. */
answeredProtocol: string = String(PEER_PROTOCOL_VERSION),
/** The name the roster answer reports for its server, and where recorded names go. */
naming?: { readonly answered: string; readonly recorded: Array<[string, string | undefined]> },
) => {
Expand Down Expand Up @@ -98,9 +102,7 @@ const makeTestLayer = (
status: 200,
headers: {
"content-type": "application/json",
...(answeredProtocol === undefined
? {}
: { "x-j5-peer-protocol": answeredProtocol }),
"x-j5-peer-protocol": answeredProtocol,
},
},
),
Expand Down Expand Up @@ -207,21 +209,21 @@ it.effect("reads no agents from a peer whose roster answer states another protoc
const seen: Array<{ url: string; authorization: string | undefined; protocol?: string }> = [];
const reading = yield* Effect.flatMap(PeerDirectory, (directory) =>
directory.listAgents(),
).pipe(Effect.provide(makeTestLayer([homePeer], seen, "2")));
).pipe(Effect.provide(makeTestLayer([homePeer], seen, "3")));
assert.deepStrictEqual(reading.agents, [], "a roster read on another protocol is not trusted");
assert.deepStrictEqual(reading.unreadPeers, [
{
environmentId: homePeer.environmentId,
label: "Home",
reason:
"Home runs peer protocol 2 and this server runs 1. Update J5 on this server, then try again.",
"Home runs peer protocol 3 and this server runs 2. Update J5 on this server, then try again.",
},
]);
assert.equal(seen[0]!.protocol, "1", "the read states this server's protocol");
assert.equal(seen[0]!.protocol, "2", "the read states this server's protocol");

const matching = yield* Effect.flatMap(PeerDirectory, (directory) =>
directory.listAgents(),
).pipe(Effect.provide(makeTestLayer([homePeer], [], "1")));
).pipe(Effect.provide(makeTestLayer([homePeer], [], "2")));
assert.equal(matching.agents.length, 2, "a peer that states the same version is read");
}),
);
Expand Down Expand Up @@ -249,9 +251,9 @@ it.effect(
Effect.gen(function* () {
const lastErrors: Array<string | null> = [];
const answers: Array<{ readonly status: number; readonly protocol: string }> = [
{ status: 200, protocol: "3" },
{ status: 500, protocol: "2" },
{ status: 200, protocol: "2" },
{ status: 500, protocol: "1" },
{ status: 200, protocol: "1" },
];
const http = Layer.succeed(
HttpClient.HttpClient,
Expand Down Expand Up @@ -283,7 +285,7 @@ it.effect(
const directory = yield* PeerDirectory;
yield* directory.listAgents();
assert.equal(lastErrors.length, 1);
assert.include(lastErrors[0] ?? "", "runs peer protocol 2");
assert.include(lastErrors[0] ?? "", "runs peer protocol 3");
// The same version answers, but with an error: the request failed, so the mismatch stands.
const failed = yield* directory.listAgents();
assert.equal(failed.unreadPeers.length, 1);
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/j5/a2a/PeerDirectory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,8 @@ const remoteAgents = (
...availability,
environmentId: peer.environmentId,
environmentLabel: label,
projectId: LedgerProjectId.make(entry.squadronId),
projectTitle: entry.squadronName,
projectId: LedgerProjectId.make(entry.projectId),
projectTitle: entry.projectTitle,
participantId: ParticipantId.make(entry.participantId),
threadId: entry.threadId,
displayName: entry.displayName,
Expand Down
Loading
Loading