🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

@openparachute/agent

Package Overview
Dependencies
Maintainers
1
Versions
36
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@openparachute/agent - npm Package Compare versions

Comparing version
0.2.3-rc.18
to
0.2.3-rc.19
+1
-1
package.json
{
"name": "@openparachute/agent",
"version": "0.2.3-rc.18",
"version": "0.2.3-rc.19",
"description": "Vault-native agents for Claude Code — a #agent/definition note + an inbound message becomes a sandboxed claude turn; the reply is written back as a note. Messaging gateway on :1941.",

@@ -5,0 +5,0 @@ "license": "AGPL-3.0",

@@ -183,6 +183,6 @@ /**

* `metadata.name`, the legacy continuity key — the 4 live def agents inject exactly
* `listGrants(spec.name)`). Renamed from `grantsAgentName` (threads-only Phase B′ —
* the locked lexicon drops the "agent" framing). It is the DORMANT per-thread override
* escape hatch: the PRIMARY grant path is the loadout-pack UNION (see `deliver` /
* resolveInjectedGrantsUnion), NOT this single key. Do NOT set it unless a thread
* `listGrants(spec.name)`). Renamed from `grantsAgentName` (the locked lexicon drops the
* "agent" framing). It is the DORMANT per-thread override escape hatch: the PRIMARY grant
* path is the def + ROLES UNION (see `deliver` / resolveInjectedGrantsUnion), NOT this
* single key. Do NOT set it unless a thread
* genuinely needs creds keyed off something other than its def name (the rare per-thread

@@ -454,4 +454,5 @@ * override + the migration seam) — else you'd fetch the wrong source's grants.

subject?: string,
packKeys?: string[],
roleKeys?: string[],
threadContent?: LoadoutEntry,
roles?: LoadoutEntry[],
): Promise<DeliverResult> {

@@ -539,4 +540,5 @@ const spec = handle.spec;

if (this.deps.grants) {
// GRANT INJECTION = UNION OF SOURCES (threads-only Phase B′ — §4). The injected
// grants for a turn are the union of:
// GRANT INJECTION = UNION OF SOURCES (roles as the capability layer —
// DESIGN-2026-06-29-threads-roles-context.md). The injected grants for a turn are the
// union of:
// 1. the LEGACY def key — `grantsKey ?? spec.name` (UNCHANGED): a

@@ -546,14 +548,13 @@ // `#agent/definition` keeps its grants keyed by its `metadata.name`. For the 4

// source. `grantsKey` (was `grantsAgentName`) is the dormant per-thread override
// escape hatch (§"Activate the dormant hook") — today no caller sets it, so it
// falls through to `spec.name`. Do NOT make it the primary path; the primary is
// this union.
// 2. each loaded PACK's path key (`packKeys`) — the slugged PATH (packPathKey) of
// every loaded note in the thread's loadout that is a `#pack` declaring `wants:`.
// The SECURITY GATE (a non-pack note's `wants:` is ignored) is enforced UPSTREAM
// where packKeys is built (the transport's readThreadPackKeys), so a plain
// content note's `wants:` never reaches here. Empty packKeys (every current
// agent — no thread loads a `#pack` with `wants:` today) → the union is exactly
// `[spec.name]` → BYTE-IDENTICAL to the legacy single-source path. (legacy continuity)
// escape hatch — today no caller sets it, so it falls through to `spec.name`. Do
// NOT make it the primary path; the primary is this union.
// 2. each loaded ROLE's path key (`roleKeys`) — the slugged PATH (rolePathKey) of
// every note in the thread's `metadata.roles` that is an `#agent/role` declaring
// `wants:`. The SECURITY GATE (a non-role note's `wants:` is ignored) is enforced
// UPSTREAM where roleKeys is built (the transport's readThreadRoles), so a plain
// content note's `wants:` never reaches here. Empty roleKeys (every current
// agent — no thread loads a role with `wants:` today) → the union is exactly
// `[spec.name]` → BYTE-IDENTICAL to the legacy single-source path. (no-roles invariant)
const legacyKey = this.deps.grantsKey ?? spec.name;
const sources = [legacyKey, ...(packKeys ?? [])];
const sources = [legacyKey, ...(roleKeys ?? [])];
try {

@@ -569,3 +570,3 @@ const injected = await resolveInjectedGrantsUnion(this.deps.grants, sources);

`parachute-agent: resolving grants for "${legacyKey}"` +
(packKeys && packKeys.length > 0 ? ` (+${packKeys.length} pack source(s))` : "") +
(roleKeys && roleKeys.length > 0 ? ` (+${roleKeys.length} role source(s))` : "") +
` failed (running this turn WITHOUT cross-resource grants — own-vault unaffected): ` +

@@ -626,25 +627,23 @@ `${(err as Error).message}`,

//
// COMPOSED PROMPT (threads-only Phase A — DESIGN-2026-06-29-threads-only.md §1/§9; thread
// content — DESIGN-2026-06-29-thread-content-and-skills.md): the system prompt is an ORDERED
// LIST of loaded notes — a direct arity-N generalization of the rc.13 arity-2 `roleBody
// [+ dossier]` seam. Entry 0 is the thread's SELF entry: the spec's `systemPrompt` (the def
// body), labeled with the def note's human-legible PATH (`spec.definitionPath`, e.g.
// `Agents/steward`; fallback `spec.name`). Entry 1 (when present) is THIS thread's CONTENT —
// the thread note's authored body, its per-thread standing mandate. Entries 2..N are the
// resolved `loadout` notes (read CONTENT-only, never metadata). composeSystemPrompt dedupes
// by path, skips blank entries, renders each as `# <path>\n\n<content>`, joins with
// `\n\n---\n\n`, and enforces the byte budget (truncate the LOADOUT TAIL first, NEVER the
// def or the thread content — the PROTECTED leading prefix).
// COMPOSED PROMPT — three layers, in order (DESIGN-2026-06-29-threads-roles-context.md):
// ① ROLES — `roles` entries, composed FIRST: the reusable "hat(s)" the thread wears.
// ② SELF + THREAD — the SELF entry (the spec's `systemPrompt`, the def body) then THIS
// thread's authored CONTENT (`threadContent`, when non-blank).
// ③ EXTRA CONTEXT — the `loadout` notes (skills, references), read CONTENT-only.
// composeSystemPrompt dedupes by path, skips blank entries, renders each as
// `# <path>\n\n<content>`, joins with `\n\n---\n\n`, and enforces the byte budget — truncating
// the layer-③ EXTRA-CONTEXT TAIL first, NEVER the PROTECTED leading prefix (roles + def +
// thread content). The self entry is labeled with the def note's human-legible PATH
// (`spec.definitionPath`, e.g. `Agents/steward`; fallback `spec.name`).
//
// #169 — the self-entry header uses the def note's PATH, NOT its id. The note ID is a
// timestamp-slug (`2026-06-20-07-30-56-487050`); the legible loadout path is
// `Agents/steward`. `spec.definitionPath` carries the path (set by parseAgentDef from
// `note.path`); when absent (a spec not sourced from a def note, or a reader that didn't
// surface a path) the header falls back to `spec.name`.
// timestamp-slug (`2026-06-20-07-30-56-487050`); the legible path is `Agents/steward`.
// `spec.definitionPath` carries the path (set by parseAgentDef from `note.path`); when
// absent (a spec not sourced from a def note) the header falls back to `spec.name`.
//
// NO-LOADOUT / NO-THREAD-CONTENT INVARIANT (the live 4am steward weave path): a thread with
// NO loadout AND no authored thread content composes to EXACTLY `# <path>\n\n<def body>` —
// `<def body>` byte-identical to `spec.systemPrompt`. The single `# <path>` header is the
// ONLY change to such a prompt (design decision #4, accepted). The run-context preamble
// stays on the MESSAGE.
// NO-ROLES / NO-LOADOUT / NO-THREAD-CONTENT INVARIANT (the live 4am steward weave path): a
// thread with NO roles, NO loadout AND no authored thread content composes to EXACTLY
// `# <path>\n\n<def body>` — `<def body>` byte-identical to `spec.systemPrompt`. The single
// `# <path>` header is the ONLY change to such a prompt. The run-context preamble stays on
// the MESSAGE.
let systemPromptFile: string | undefined;

@@ -659,10 +658,22 @@ if (typeof spec.systemPrompt === "string" && spec.systemPrompt.length > 0) {

: spec.name;
// The thread-content entry sits BETWEEN the self entry and the loadout. Include it only
// when it carries real (non-blank) content — a blank thread note is the no-thread-content
// case. Gate the inclusion AND the protected-prefix count on the SAME non-blank check, so
// composeSystemPrompt's protected prefix (def [+ thread content]) stays exactly aligned
// with the entries actually inserted (a blank entry would be skipped, shifting the prefix).
// Layer ① ROLES — filter to NON-BLANK content + dedupe by path so the protected-prefix
// COUNT below stays exactly aligned with the entries composeSystemPrompt keeps (a blank or
// duplicate role would be dropped, shifting the prefix into the layer-③ tail). They lead,
// so a later loadout note with a colliding path dedupes against the role (the role wins).
const roleEntries: LoadoutEntry[] = [];
const seenRolePaths = new Set<string>();
for (const r of roles ?? []) {
if (typeof r.content !== "string" || r.content.trim().length === 0) continue;
if (seenRolePaths.has(r.path)) continue;
seenRolePaths.add(r.path);
roleEntries.push(r);
}
// Layer ② thread content sits BETWEEN the self entry and the loadout. Include it only when
// it carries real (non-blank) content — a blank thread note is the no-thread-content case.
// Gate the inclusion AND the protected-prefix count on the SAME non-blank check.
const hasThreadContent =
!!threadContent && typeof threadContent.content === "string" && threadContent.content.trim().length > 0;
// Order: ① roles → ② self + thread-content → ③ extra-context loadout.
const entries: LoadoutEntry[] = [
...roleEntries,
{ path: selfPath, content: spec.systemPrompt },

@@ -672,5 +683,6 @@ ...(hasThreadContent ? [threadContent!] : []),

];
// Protect the def (always) and the thread content (when present) from budget truncation —
// both are load-bearing; only the loadout tail (entries beyond the prefix) sheds.
const composed = composeSystemPrompt(entries, { protectedCount: hasThreadContent ? 2 : 1 });
// Protect the WHOLE leading prefix — roles (①) + the def + the thread content (②) — from
// budget truncation; only the layer-③ extra-context tail (entries beyond the prefix) sheds.
const protectedCount = roleEntries.length + 1 + (hasThreadContent ? 1 : 0);
const composed = composeSystemPrompt(entries, { protectedCount });
systemPromptFile = join(workspace, "system-prompt.txt");

@@ -677,0 +689,0 @@ writeFileSync(systemPromptFile, composed, { mode: 0o600 });

@@ -125,12 +125,12 @@ /**

/**
* One entry in a thread's LOADOUT (threads-only Phase A — DESIGN-2026-06-29-threads-only.md
* §1/§9). The composed system prompt is an ORDERED LIST of these — entry 0 is the thread's
* "self" entry (the def body, labeled by the def note's PATH), entries 1..N are the notes the
* thread loads from its `metadata.loadout` (an array of note PATHS). The composer reads the
* note's CONTENT only — NEVER its metadata — and renders each as `# <path>\n\n<content>`.
* One entry in the composed system prompt — a `{ path, content }` pair the composer renders as
* `# <path>\n\n<content>` (CONTENT only — NEVER metadata). The composed prompt is an ORDERED
* LIST of these, three layers (DESIGN-2026-06-29-threads-roles-context.md): ① the thread's ROLES
* (composed FIRST), ② the "self" entry (the def body, labeled by the def note's PATH) then the
* thread's own CONTENT, ③ the EXTRA-CONTEXT loadout (`metadata.loadout` note paths). Each layer
* is a list of these entries; the backend concatenates them in order.
*
* This is the arity-N generalization of the rc.13 arity-2 `roleBody [+ subjectDossier]` seam:
* a thread that loads no notes (every current agent, incl. the 4am steward weave) composes to
* EXACTLY its def body, prefixed by a single `# <path>` header — the only change to a
* no-loadout prompt (the no-loadout invariant; design decision #4, accepted).
* A thread that loads NOTHING (no roles, no thread content, no loadout — every current agent,
* incl. the 4am steward weave) composes to EXACTLY its def body, prefixed by a single
* `# <path>` header — the only change to such a prompt (the no-loadout/no-roles invariant).
*/

@@ -171,8 +171,9 @@ export interface LoadoutEntry {

* drop trailing LOADOUT entries until it fits — the PROTECTED leading entries are NEVER
* truncated. `protectedCount` (default 1) is how many leading entries are load-bearing: 1
* protects just the self entry (index 0, the def body) — the no-loadout default; 2 protects
* the self entry AND the thread-content entry (index 1) once the caller has inserted it
* (DESIGN-2026-06-29-thread-content-and-skills.md — thread content is load-bearing like the
* def). Only entries beyond the protected prefix shed (tail-first). A loud warn fires on
* truncation (via `onWarn`, defaulting to console.warn).
* truncated. `protectedCount` (default 1) is how many leading entries are load-bearing — the
* caller counts its protected PREFIX: the ROLES (layer ①, composed first), the self entry (the
* def body), and the thread-content entry (layer ②) when present
* (DESIGN-2026-06-29-threads-roles-context.md). With no roles + no thread content it is 1 (the
* self entry alone — the no-loadout default); each role adds one, and thread content adds one.
* Only entries beyond the protected prefix (the layer-③ extra-context loadout) shed (tail-first).
* A loud warn fires on truncation (via `onWarn`, defaulting to console.warn).
*

@@ -360,12 +361,13 @@ * The NO-LOADOUT INVARIANT: with exactly one (self) entry whose content is the def body, the

*
* `loadout` (optional, threads-only Phase A — DESIGN-2026-06-29-threads-only.md §9) is the
* thread's ORDERED LIST of loaded notes (entries 1..N — the SELF entry is prepended by the
* backend from `spec.systemPrompt` + the def note path). The programmatic backend folds them
* into the SYSTEM prompt via {@link composeSystemPrompt}: `[self, ...loadout]` deduped by path,
* `loadout` (optional, the layer-③ EXTRA CONTEXT — DESIGN-2026-06-29-threads-roles-context.md)
* is the thread's ORDERED LIST of extra-context notes (skills, references), composed AFTER the
* thread content. The programmatic backend folds them into the SYSTEM prompt via
* {@link composeSystemPrompt}: `[...roles, self, thread-content, ...loadout]` deduped by path,
* blank-skipped, each rendered `# <path>\n\n<content>`, joined by `\n\n---\n\n`, then budgeted
* ({@link LOADOUT_BUDGET_BYTES}, truncating loadout-notes-first, NEVER the self entry). This
* SUPERSEDES the rc.13 arity-2 `subjectDossier` string with an arity-N note list. ADDITIVE —
* omitted/empty → the system prompt is `# <path>\n\n<def body>`, byte-identical to HEAD APART
* FROM the single `# <path>` header (the no-loadout invariant; design decision #4, accepted).
* The run-context preamble is unaffected (it stays on the MESSAGE).
* ({@link LOADOUT_BUDGET_BYTES}, truncating loadout-notes-first, NEVER the protected prefix —
* roles + self + thread content). ADDITIVE — omitted/empty + no roles + no thread content → the
* system prompt is `# <path>\n\n<def body>`, byte-identical to HEAD APART FROM the single
* `# <path>` header (the no-loadout invariant). The run-context preamble is unaffected (it stays
* on the MESSAGE). Distinct from `roles`: loadout is CONTENT-only (never grants); roles carry
* capability and compose FIRST.
*

@@ -380,22 +382,30 @@ * `subject` (optional, roles×threads NEXT slice #120) is the thread SUBJECT — the programmatic

*
* `packKeys` (optional, threads-only Phase B′ — DESIGN-2026-06-29-threads-only.md §4) is the
* list of hub grant-holder KEYS for the loaded PACKS in this thread's loadout — the slugged
* PATH (`packPathKey`) of every loaded note that is a `#pack` declaring `wants:`. The
* programmatic backend UNIONS each pack's APPROVED grants with the def's own (`spec.name`),
* so a thread that loads a pack gains that pack's capabilities. This is SEPARATE from
* `loadout` (prompt content) by design: the SECURITY GATE — a non-pack note's `wants:` is
* IGNORED — is enforced where `packKeys` is built (the transport reads the loaded notes'
* METADATA, never folding it into the prompt). ADDITIVE — omitted/empty (every current agent;
* no thread loads a `#pack` with `wants:` today) → the grant source set is exactly
* `[spec.name]`, BYTE-IDENTICAL to the legacy single-source injection (legacy continuity).
* `roleKeys` (optional, roles as the capability layer — DESIGN-2026-06-29-threads-roles-context.md)
* is the list of hub grant-holder KEYS for the thread's ROLES — the slugged PATH (`rolePathKey`)
* of every note in `metadata.roles` that is an `#agent/role` declaring `wants:`. The programmatic
* backend UNIONS each role's APPROVED grants with the def's own (`spec.name`), so a thread that
* loads a role gains that role's capabilities. The SECURITY GATE — a non-role note's `wants:` is
* IGNORED — is enforced where `roleKeys` is built (the transport reads the role notes' METADATA).
* ADDITIVE — omitted/empty (every current agent; no thread loads a role with `wants:` today) →
* the grant source set is exactly `[spec.name]`, BYTE-IDENTICAL to the legacy single-source
* injection (legacy continuity).
*
* `threadContent` (optional, DESIGN-2026-06-29-thread-content-and-skills.md) is THIS thread's
* own standing context — the thread note's authored BODY (`{ path, content }`, CONTENT only,
* the thread-note path as the header). The programmatic backend composes it as the entry
* BETWEEN the self entry and the loadout: `[self, thread-content, ...loadout]`. It is
* load-bearing like the def — composed with a protected prefix so an over-budget loadout
* sheds its tail but NEVER the def or the thread content. ADDITIVE — omitted, or blank/
* whitespace content → the thread-content entry is skipped and the prompt is `[self, ...loadout]`
* exactly as before (the no-thread-content invariant). The daemon NEVER writes this content;
* a human/agent authors it on the thread note and the backend reads it CONTENT-only each turn.
* `threadContent` (optional, the layer-② THREAD content — DESIGN-2026-06-29-threads-roles-context.md)
* is THIS thread's own standing context — the thread note's authored BODY (`{ path, content }`,
* CONTENT only, the thread-note path as the header). The programmatic backend composes it
* BETWEEN the def (self) and the loadout: `[...roles, self, thread-content, ...loadout]`. It is
* load-bearing like the def — composed inside the protected prefix so an over-budget loadout
* sheds its tail but NEVER the roles, the def, or the thread content. ADDITIVE — omitted, or
* blank/whitespace content → the thread-content entry is skipped (the no-thread-content
* invariant). The daemon NEVER writes this content; a human/agent authors it on the thread note
* and the backend reads it CONTENT-only each turn.
*
* `roles` (optional, the layer-① ROLES — DESIGN-2026-06-29-threads-roles-context.md) is the
* thread's ORDERED LIST of role-note CONTENT entries (`{ path, content }`), composed FIRST —
* before the def (self) — as the reusable "hat(s)" the thread wears. The programmatic backend
* prepends them: `[...roles, self, thread-content, ...loadout]`, and they sit inside the
* PROTECTED prefix (never truncated). Each role's CAPABILITIES ride SEPARATELY on `roleKeys`
* (content here, grants there — one role note, read once). ADDITIVE — omitted/empty → the
* prompt is `[self, thread-content, ...loadout]` exactly as before this layer (the no-roles
* invariant). Blank/whitespace role entries are skipped (the composer skips blank content).
*/

@@ -411,4 +421,5 @@ deliver(

subject?: string,
packKeys?: string[],
roleKeys?: string[],
threadContent?: LoadoutEntry,
roles?: LoadoutEntry[],
): Promise<DeliverResult>;

@@ -415,0 +426,0 @@

@@ -75,9 +75,9 @@ /**

/**
* The bare PACK tag the pack-watch triggers filter on (threads-only Phase B′ —
* DESIGN-2026-06-29-threads-only.md §4). A `#pack` note save fires the pack webhook so the
* daemon reconciles that pack's `wants:` under its path key (register + prune-its-own-partition).
* This is the PER-PACK trigger — analogous to the def-watch triggers, NOT fired on every load
* (load is per-turn). Mirrors {@link PACK_TAG} in grants.ts.
* The bare ROLE tag the role-watch triggers filter on (roles as the capability layer —
* DESIGN-2026-06-29-threads-roles-context.md). An `#agent/role` note save fires the role webhook
* so the daemon reconciles that role's `wants:` under its path key (register + prune-its-own-
* partition). This is the PER-ROLE trigger — analogous to the def-watch triggers, NOT fired on
* every load (load is per-turn). Mirrors {@link ROLE_TAG} in grants.ts.
*/
export const PACK_TAG = "pack";
export const ROLE_TAG = "agent/role";
/**

@@ -145,8 +145,8 @@ * The inbound trigger's `when` predicate. SOURCED from the existing

/**
* The pack-watch trigger name for a (vault, kind) (threads-only Phase B′). Stable so the
* POST upserts in place. ONE pair (create + edit) per def-vault — they fire the pack webhook
* for ANY `#pack` note in the vault, which reconciles that pack's `wants:` under its path key.
* The role-watch trigger name for a (vault, kind) (roles as the capability layer). Stable so the
* POST upserts in place. ONE pair (create + edit) per def-vault — they fire the role webhook
* for ANY `#agent/role` note in the vault, which reconciles that role's `wants:` under its path key.
*/
export function packWatchTriggerName(vault: string, kind: "create" | "edit"): string {
return `conn_packs-${kind}-${vault}`;
export function roleWatchTriggerName(vault: string, kind: "create" | "edit"): string {
return `conn_roles-${kind}-${vault}`;
}

@@ -172,3 +172,3 @@

const inboundWebhook = buildWebhook(hubOrigin, "/api/vault/inbound");
const packWebhook = buildWebhook(hubOrigin, "/api/vault/pack");
const roleWebhook = buildWebhook(hubOrigin, "/api/vault/role");
const auth = { bearer: webhookBearer };

@@ -204,22 +204,22 @@ return [

},
// PACK-watch CREATE — a new `#pack` note reconciles its `wants:` under its path key
// (threads-only Phase B′ §4). This is the PER-PACK reconcile trigger, analogous to the
// ROLE-watch CREATE — a new `#agent/role` note reconciles its `wants:` under its path key
// (roles as the capability layer). This is the PER-ROLE reconcile trigger, analogous to the
// def-watch triggers — fired on SAVE, NOT on every load (load is per-turn). The webhook
// registers the pack's wants + prunes ONLY that pack's own grant partition.
// registers the role's wants + prunes ONLY that role's own grant partition.
{
name: packWatchTriggerName(vault, "create"),
name: roleWatchTriggerName(vault, "create"),
events: ["created"],
when: { tags: [PACK_TAG] },
action: { webhook: packWebhook, send: "json", auth },
when: { tags: [ROLE_TAG] },
action: { webhook: roleWebhook, send: "json", auth },
},
// PACK-watch EDIT — an edited `#pack` note re-reconciles (a removed `wants:` prunes that
// pack's partition to []). The vault has no `deleted` trigger event (validator allows only
// ROLE-watch EDIT — an edited `#agent/role` note re-reconciles (a removed `wants:` prunes that
// role's partition to []). The vault has no `deleted` trigger event (validator allows only
// created/updated), so deletion is handled out-of-band; the create+edit pair covers
// save + drop-wants, which is the live reconcile surface. (Each pack is its OWN partition,
// so a reconcile can never touch a def's grants or another pack's — §4.)
// save + drop-wants, which is the live reconcile surface. (Each role is its OWN partition,
// so a reconcile can never touch a def's grants or another role's.)
{
name: packWatchTriggerName(vault, "edit"),
name: roleWatchTriggerName(vault, "edit"),
events: ["updated"],
when: { tags: [PACK_TAG] },
action: { webhook: packWebhook, send: "json", auth },
when: { tags: [ROLE_TAG] },
action: { webhook: roleWebhook, send: "json", auth },
},

@@ -350,3 +350,3 @@ ];

`parachute-agent: def-vault "${r.vault}" — auto-registered ${r.registered.length} trigger(s) ` +
`(def-watch create/edit + inbound + pack-watch create/edit, bare-keyed).`,
`(def-watch create/edit + inbound + role-watch create/edit, bare-keyed).`,
);

@@ -353,0 +353,0 @@ } else {

@@ -660,22 +660,22 @@ /**

/**
* UNION an agent's injected grants across MULTIPLE grant-source keys (threads-only
* Phase B′ — DESIGN-2026-06-29-threads-only.md §4). The injected grants for a turn are
* the union of `resolveInjectedGrants(client, key)` over `[spec.name, ...packKeys]`:
* UNION an agent's injected grants across MULTIPLE grant-source keys (roles as the
* capability layer — DESIGN-2026-06-29-threads-roles-context.md). The injected grants for a
* turn are the union of `resolveInjectedGrants(client, key)` over `[spec.name, ...roleKeys]`:
*
* - `spec.name` — the LEGACY def path (UNCHANGED): a `#agent/definition` keeps its
* grants keyed by its `metadata.name`. For the 4 live def agents (uni, steward, …)
* this is the ONLY key, so with no loaded packs the union has one source and the
* this is the ONLY key, so with no loaded roles the union has one source and the
* result is byte-identical to {@link resolveInjectedGrants}(client, spec.name).
* - each `packKey` — a slugged pack PATH ({@link packPathKey}) for every loaded note
* that is a `#pack` declaring `wants:`. Loading a pack into a thread unions that
* pack's path-keyed APPROVED grants in.
* - each `roleKey` — a slugged role PATH ({@link rolePathKey}) for every loaded note in
* the thread's `metadata.roles` that is an `#agent/role` declaring `wants:`. Loading a
* role into a thread unions that role's path-keyed APPROVED grants in.
*
* Dedupe rule: MCP entries are deduped by their entry `name` (first source wins);
* env vars are deduped by var name (first source wins). The legacy `spec.name` source
* is conventionally first, so a def's own grant wins a name collision with a pack's.
* is conventionally first, so a def's own grant wins a name collision with a role's.
*
* Keys are deduped + processed in order; a duplicate key (e.g. the same pack twice in
* a loadout, or a pack key that happens to equal `spec.name`) is fetched ONCE. A single
* Keys are deduped + processed in order; a duplicate key (e.g. the same role twice in the
* roles list, or a role key that happens to equal `spec.name`) is fetched ONCE. A single
* source's grant-LIST failure is logged + SKIPPED (its grants are simply absent this
* turn — the others still inject), so a flaky hub on one pack never sinks the whole
* turn — the others still inject), so a flaky hub on one role never sinks the whole
* turn's injection. Material is fetched FRESH per source (never cached), same as the

@@ -720,43 +720,44 @@ * single-source path.

/**
* The hub grant-holder KEY for a PACK (threads-only Phase B′ §4) — the slugged note
* PATH, mirroring the slug discipline the hub's `grantId` uses (`/`→`-`, all non-slug
* chars collapse). Prefixed `pack--` so it reads UNAMBIGUOUSLY as a pack source and is
* practically partitioned from def keys: a def `metadata.name` is an operator-authored
* `[a-zA-Z0-9_-]+` slug (the live cast — `uni`, `steward`, … — have no `--`), so the
* `pack--` prefix keeps the two namespaces apart in normal operation. (The def-name regex
* technically accepts `--`, so a deliberately-crafted def named `pack--<x>` could share a
* pack's partition — but that's an operator-trust corner, not an exploitable boundary: an
* operator who can author such a def can author the pack too.) The hub keys
* `grantId(agent, spec) = ${agent}--${connectionKey}` on this opaque holder string;
* each pack is therefore its OWN prune partition on the hub (`listGrantsForAgent`),
* so a pack's reconcile can never touch a def's grants or another pack's.
* The hub grant-holder KEY for a ROLE (roles as the capability layer —
* DESIGN-2026-06-29-threads-roles-context.md) — the slugged note PATH, mirroring the slug
* discipline the hub's `grantId` uses (`/`→`-`, all non-slug chars collapse). Prefixed
* `role--` so it reads UNAMBIGUOUSLY as a role source and is practically partitioned from
* def keys: a def `metadata.name` is an operator-authored `[a-zA-Z0-9_-]+` slug (the live
* cast — `uni`, `steward`, … — have no `--`), so the `role--` prefix keeps the two
* namespaces apart in normal operation. (The def-name regex technically accepts `--`, so a
* deliberately-crafted def named `role--<x>` could share a role's partition — but that's an
* operator-trust corner, not an exploitable boundary: an operator who can author such a def
* can author the role too.) The hub keys `grantId(agent, spec) = ${agent}--${connectionKey}`
* on this opaque holder string; each role is therefore its OWN prune partition on the hub
* (`listGrantsForAgent`), so a role's reconcile can never touch a def's grants or another role's.
*
* Examples: `Uni/Packs/github` → `pack--Uni-Packs-github`; `Packs/Read.ai` → `pack--Packs-Read-ai`.
* Examples: `Uni/Roles/github` → `role--Uni-Roles-github`; `Roles/Read.ai` → `role--Roles-Read-ai`.
*/
export function packPathKey(notePath: string): string {
export function rolePathKey(notePath: string): string {
// Mirror sandbox/types.ts `slug`: collapse every non-`[a-zA-Z0-9_-]` char to `-`.
const slugged = notePath.replace(/[^a-zA-Z0-9_-]/g, "-");
return `pack--${slugged}`;
return `role--${slugged}`;
}
/** The bare tag (post-canonicalization) that marks a note as a PACK. */
export const PACK_TAG = "pack";
/** The bare tag (post-canonicalization) that marks a note as a ROLE — `#agent/role`. */
export const ROLE_TAG = "agent/role";
/**
* Is this loaded note a PACK that declares `wants:`? — the SECURITY GATE (threads-only
* Phase B′ §"Security refinement — wants only from PACKS"). A note's `wants:` is honored
* ONLY if the note carries the `#pack`/`pack` tag; a plain content note's `wants:` is
* Is this loaded note a ROLE that declares `wants:`? — the SECURITY GATE (roles carry
* capability — DESIGN-2026-06-29-threads-roles-context.md). A note's `wants:` is honored
* ONLY if the note carries the `#agent/role` tag; a plain content note's `wants:` is
* IGNORED (inert) even if present, so loading arbitrary notes for context can NEVER add
* capabilities. PURE — no I/O. Tag matching tolerates a stray leading `#` (pre-/post-
* canonicalization) so a `#pack`-authored note is recognized either way.
* canonicalization) so an `#agent/role`-authored note is recognized either way.
*
* Returns the parsed `wants:` connection specs when the note is a pack WITH a non-empty,
* cleanly-parsing `wants:`; otherwise `null` (not a pack, no `wants:`, or a parse error —
* a parse error is surfaced via {@link packWantsOrError}, this fast-path just returns null).
* Returns the parsed `wants:` connection specs when the note is a role WITH a non-empty,
* cleanly-parsing `wants:`; otherwise `null` (not a role, no `wants:`, or a parse error —
* a parse error stamps the note `status:error` via the reconcile path; this fast-path just
* returns null).
*/
export function packWants(note: {
export function roleWants(note: {
tags?: unknown;
metadata?: Record<string, unknown>;
}): ConnectionSpec[] | null {
if (!isPackNote(note)) return null;
if (!isRoleNote(note)) return null;
const raw = note.metadata?.wants;

@@ -774,7 +775,7 @@ if (raw === undefined || raw === null) return null;

/**
* Does this note carry the `#pack` tag? Tolerates the `tags` field being an array of
* Does this note carry the `#agent/role` tag? Tolerates the `tags` field being an array of
* strings (the vault REST shape) or a comma/space-joined string, and a stray leading
* `#` on the tag value. PURE.
*/
export function isPackNote(note: { tags?: unknown }): boolean {
export function isRoleNote(note: { tags?: unknown }): boolean {
const tags = note.tags;

@@ -789,3 +790,3 @@ let list: string[];

}
return list.some((t) => t.trim().replace(/^#/, "") === PACK_TAG);
return list.some((t) => t.trim().replace(/^#/, "") === ROLE_TAG);
}

@@ -792,0 +793,0 @@

@@ -293,19 +293,20 @@ /**

/**
* Optional: read the thread's loaded-PACK grant KEYS (threads-only Phase B′ —
* DESIGN-2026-06-29-threads-only.md §4). Resolves the same `metadata.loadout` paths as
* {@link readThreadLoadout}, but reads each note's METADATA (not its content) to find the
* loaded notes that are PACKS — i.e. carry the `#pack` tag AND declare a non-empty `wants:`.
* Returns each such pack's hub grant-holder key (the slugged PATH, `packPathKey`). This is
* the SECURITY GATE: a plain content note's `wants:` is IGNORED (only `#pack` notes
* contribute), and the metadata read is DELIBERATELY SEPARATE from the content-only prompt
* read (loading a note for context never adds capabilities). The backend unions these with
* the def's own `spec.name` grants. `subject` resolves the subject-scoped thread note.
* Absent `metadata.loadout` / no pack with `wants:` → an empty array (every current thread).
* Only a durable transport (the VaultTransport) implements it; others omit it.
* Optional: read the thread's ROLES (layer ① — DESIGN-2026-06-29-threads-roles-context.md).
* Resolves the `metadata.roles` array of note PATHS on the thread's `#agent/thread` note in
* ONE pass to BOTH (a) the ordered CONTENT entries (`{ path, content }` — note content only)
* the backend composes as the FIRST prompt layer, and (b) the grant-holder KEYS (the slugged
* PATH, `rolePathKey`) for the loaded notes that are ROLES (carry the `#agent/role` tag AND
* declare a non-empty `wants:`). This is the SECURITY GATE: listing a plain content note in
* `metadata.roles` loads its content as context but its `wants:` is IGNORED (only `#agent/role`
* notes contribute grants) — loading context can never escalate. The backend prepends the
* entries before the def (self) and unions the grant keys with the def's own `spec.name` grants.
* `subject` resolves the subject-scoped thread note. Absent `metadata.roles` / no role → an
* empty `{ entries: [], grantKeys: [] }` (every current thread). Only a durable transport (the
* VaultTransport) implements it; others omit it.
*/
readThreadPackKeys?(
readThreadRoles?(
channel: string,
name: string,
subject?: string,
): Promise<string[]>;
): Promise<{ entries: { path: string; content: string }[]; grantKeys: string[] }>;
/**

@@ -312,0 +313,0 @@ * Optional: write an agent-to-agent CALLBACK as an INBOUND note on THIS channel (the

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display