@tryinget/pi-agent-registry 0.3.0

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.
@@ -0,0 +1,50 @@
1
+ // ---
2
+ // summary: registry-owned ExtraSkillProfileResolver mapping standing-agent names onto materialized skill selections.
3
+ // read_when:
4
+ // - changing how ASC resolves pi-agent-registry agent names as skill profiles.
5
+ // ---
6
+
7
+ import type { ExtraSkillProfileResolver } from "@tryinget/pi-autonomous-session-control/execution";
8
+ import { materializeSkillDirs, planSkillSelection } from "./ec-profiles.ts";
9
+ import type { AgentRegistry } from "./registry.ts";
10
+
11
+ export const AGENT_SKILL_REGISTRY_LABEL = "pi-agent-registry" as const;
12
+
13
+ /**
14
+ * Fleet Phase-2 skill seam: the registry resolves its own agent names for the
15
+ * ASC runtime's extraSkillProfileResolver hook. ASC consults this only after
16
+ * its built-in skill-librarian registry misses, and ASC owns cleanup of the
17
+ * materialized selection once the dispatched child settles.
18
+ *
19
+ * Returning undefined declines the profile so ASC fails closed with its own
20
+ * diagnostics; SubagentSkillSelectionError marks registry-owned fail-closed
21
+ * paths (unknown extra skill, unknown EC profile, filesystem failure).
22
+ */
23
+ export function createAgentSkillProfileResolver(
24
+ registry: AgentRegistry,
25
+ ): ExtraSkillProfileResolver {
26
+ return async (profile) => {
27
+ const manifest = registry.get(profile);
28
+ if (!manifest) {
29
+ return undefined;
30
+ }
31
+ const selection = planSkillSelection({
32
+ ...(manifest.skills?.profile !== undefined ? { profile: manifest.skills.profile } : {}),
33
+ ...(manifest.skills?.extra ? { extra: manifest.skills.extra } : {}),
34
+ ec: registry.ec,
35
+ manifestRoot: manifest.root,
36
+ userSkillsRoot: registry.userSkillsRoot,
37
+ });
38
+ const materialized = await materializeSkillDirs(selection, manifest.name);
39
+ return {
40
+ noSkills: true,
41
+ skillSources: [materialized.dir],
42
+ skillProfile: manifest.name,
43
+ loadedSkills: materialized.skills,
44
+ librarySkills: [],
45
+ skillWarnings: [],
46
+ skillRegistry: AGENT_SKILL_REGISTRY_LABEL,
47
+ cleanup: materialized.cleanup,
48
+ };
49
+ };
50
+ }
@@ -0,0 +1,64 @@
1
+ // ---
2
+ // summary: capability-checked loader for the ASC-owned execution surface used by Fleet Phase-2 dispatch.
3
+ // read_when:
4
+ // - changing how pi-agent-registry consumes ASC runtime/session/model contracts.
5
+ // ---
6
+
7
+ import type {
8
+ AscExecutionRuntime,
9
+ ResolvedSubagentModelSelection,
10
+ ResolvedSubagentSessionsDir,
11
+ } from "@tryinget/pi-autonomous-session-control/execution";
12
+
13
+ export interface AscExecutionSurface {
14
+ createAscExecutionRuntime: (options: unknown) => AscExecutionRuntime;
15
+ resolveSubagentSessionsDir: (options?: {
16
+ explicitDir?: string;
17
+ cwd?: string;
18
+ agentDir?: string;
19
+ sessionDirEnv?: string;
20
+ }) => ResolvedSubagentSessionsDir;
21
+ resolveSubagentModelSelection: (ctx?: {
22
+ model?: { provider?: unknown; id?: unknown };
23
+ }) => ResolvedSubagentModelSelection;
24
+ }
25
+
26
+ let cached: AscExecutionSurface | undefined | "unloaded" = "unloaded";
27
+
28
+ /**
29
+ * Load ASC's exported execution surface. The registry declares the ASC
30
+ * dependency as a workspace link; an installed/published ASC that predates
31
+ * the execution exports resolves to `undefined` and every Phase-2 dispatch
32
+ * fails closed with `asc_execution_unavailable` (confirmed_no_effects).
33
+ * Execution machinery stays ASC-owned; this loader only checks capability.
34
+ */
35
+ export async function loadAscExecutionSurface(): Promise<AscExecutionSurface | undefined> {
36
+ if (cached !== "unloaded") {
37
+ return cached;
38
+ }
39
+ try {
40
+ const mod = (await import("@tryinget/pi-autonomous-session-control/execution")) as Record<
41
+ string,
42
+ unknown
43
+ >;
44
+ const surface = {
45
+ createAscExecutionRuntime: mod.createAscExecutionRuntime,
46
+ resolveSubagentSessionsDir: mod.resolveSubagentSessionsDir,
47
+ resolveSubagentModelSelection: mod.resolveSubagentModelSelection,
48
+ };
49
+ cached =
50
+ typeof surface.createAscExecutionRuntime === "function" &&
51
+ typeof surface.resolveSubagentSessionsDir === "function" &&
52
+ typeof surface.resolveSubagentModelSelection === "function"
53
+ ? (surface as AscExecutionSurface)
54
+ : undefined;
55
+ } catch {
56
+ cached = undefined;
57
+ }
58
+ return cached;
59
+ }
60
+
61
+ /** Test-only surface cache reset. */
62
+ export function resetAscExecutionSurfaceCache(): void {
63
+ cached = "unloaded";
64
+ }
@@ -0,0 +1,237 @@
1
+ // ---
2
+ // summary: exact-task AK authorization reads and dispatch evidence recording through the ak CLI boundary.
3
+ // read_when:
4
+ // - changing task authorization rules or AK evidence semantics for standing-agent dispatch.
5
+ // ---
6
+
7
+ import { execFile } from "node:child_process";
8
+ import { promisify } from "node:util";
9
+ import {
10
+ type AkTaskSnapshot,
11
+ DISPATCH_EVIDENCE_CHECK_TYPE,
12
+ DISPATCH_PHASE,
13
+ DISPATCH_RECEIPT_SCHEMA,
14
+ } from "./dispatch-contract.ts";
15
+
16
+ const execFileAsync = promisify(execFile);
17
+
18
+ const AK_TIMEOUT_MS = 15_000;
19
+
20
+ export type AkAuthorizationFailureCode =
21
+ | "ak_unavailable"
22
+ | "task_not_found"
23
+ | "task_repo_mismatch"
24
+ | "task_not_claimed"
25
+ | "task_lease_expired";
26
+
27
+ export class AkAuthorizationError extends Error {
28
+ readonly code: AkAuthorizationFailureCode;
29
+
30
+ constructor(code: AkAuthorizationFailureCode, message: string) {
31
+ super(message);
32
+ this.name = "AkAuthorizationError";
33
+ this.code = code;
34
+ }
35
+ }
36
+
37
+ /** Read one exact AK task through `ak task show <id> -F json` (read-only). */
38
+ export async function readAkTask(
39
+ taskId: number,
40
+ options?: { akBinary?: string },
41
+ ): Promise<AkTaskSnapshot> {
42
+ const akBinary = options?.akBinary ?? "ak";
43
+ let stdout: string;
44
+ try {
45
+ const result = await execFileAsync(akBinary, ["task", "show", String(taskId), "-F", "json"], {
46
+ timeout: AK_TIMEOUT_MS,
47
+ maxBuffer: 1024 * 1024,
48
+ windowsHide: true,
49
+ });
50
+ stdout = result.stdout;
51
+ } catch {
52
+ throw new AkAuthorizationError(
53
+ "ak_unavailable",
54
+ `AK task ${taskId} could not be read (ak task show failed); dispatch authorization is unverifiable`,
55
+ );
56
+ }
57
+ let parsed: unknown;
58
+ try {
59
+ parsed = JSON.parse(stdout);
60
+ } catch {
61
+ throw new AkAuthorizationError(
62
+ "ak_unavailable",
63
+ `AK task ${taskId} produced unparseable output; dispatch authorization is unverifiable`,
64
+ );
65
+ }
66
+ if (!parsed || typeof parsed !== "object") {
67
+ throw new AkAuthorizationError("task_not_found", `AK task ${taskId} returned no task object`);
68
+ }
69
+ const row = parsed as Record<string, unknown>;
70
+ const id = row.id;
71
+ const repo = row.repo;
72
+ const title = row.title;
73
+ if (typeof id !== "number" || id !== taskId || typeof repo !== "string" || !repo) {
74
+ throw new AkAuthorizationError(
75
+ "task_not_found",
76
+ `AK task ${taskId} returned an unusable task identity`,
77
+ );
78
+ }
79
+ return {
80
+ id,
81
+ repo,
82
+ title: typeof title === "string" ? title : "",
83
+ status: typeof row.status === "string" ? row.status : "",
84
+ claimed_by: typeof row.claimed_by === "string" && row.claimed_by ? row.claimed_by : null,
85
+ lease_expires_at:
86
+ typeof row.lease_expires_at === "string" && row.lease_expires_at
87
+ ? row.lease_expires_at
88
+ : null,
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Phase-2 exact-task authorization: the task must exist, be registered to the
94
+ * dispatch-origin repository, and carry a live claim. Readiness never becomes
95
+ * authorization — only an in-flight claim authorizes one read-only dispatch.
96
+ */
97
+ export function authorizeExactTask(
98
+ task: AkTaskSnapshot,
99
+ expectedRepoRoot: string,
100
+ ): { ok: true } | { ok: false; code: AkAuthorizationFailureCode; message: string } {
101
+ if (normalizeRepoPath(task.repo) !== normalizeRepoPath(expectedRepoRoot)) {
102
+ return {
103
+ ok: false,
104
+ code: "task_repo_mismatch",
105
+ message: `AK task ${task.id} belongs to repo ${task.repo}; dispatch origin is ${expectedRepoRoot}`,
106
+ };
107
+ }
108
+ if (task.status !== "claimed") {
109
+ return {
110
+ ok: false,
111
+ code: "task_not_claimed",
112
+ message: `AK task ${task.id} status is "${task.status}"; only a claimed task authorizes one read-only standing-agent dispatch`,
113
+ };
114
+ }
115
+ if (!task.claimed_by) {
116
+ return {
117
+ ok: false,
118
+ code: "task_not_claimed",
119
+ message: `AK task ${task.id} is claimed without a claimant; authorization is unverifiable`,
120
+ };
121
+ }
122
+ if (task.lease_expires_at) {
123
+ const expiry = Date.parse(task.lease_expires_at);
124
+ if (!Number.isFinite(expiry) || expiry <= Date.now()) {
125
+ return {
126
+ ok: false,
127
+ code: "task_lease_expired",
128
+ message: `AK task ${task.id} claim lease is expired or unparseable; re-claim before dispatch`,
129
+ };
130
+ }
131
+ } else {
132
+ return {
133
+ ok: false,
134
+ code: "task_lease_expired",
135
+ message: `AK task ${task.id} carries no lease expiry; authorization is unverifiable`,
136
+ };
137
+ }
138
+ return { ok: true };
139
+ }
140
+
141
+ function normalizeRepoPath(value: string): string {
142
+ return value.replace(/\/+$/u, "");
143
+ }
144
+
145
+ /** Typed AK evidence details for one settled Phase-2 dispatch. */
146
+ export function buildDispatchEvidenceDetails(facts: {
147
+ agent: string;
148
+ agentRepoCommit: string;
149
+ manifestSha256: string;
150
+ task: number;
151
+ attemptIndex: number;
152
+ dispatchId: string;
153
+ attemptId: string;
154
+ sessionName: string;
155
+ effectDisposition: string;
156
+ effectCorrelationId: string;
157
+ effectCorrelationEchoVerified: boolean;
158
+ noMutationObserved: boolean;
159
+ outputSha256: string;
160
+ receiptSha256: string;
161
+ receiptName?: string;
162
+ }): Record<string, unknown> {
163
+ return {
164
+ schema: DISPATCH_RECEIPT_SCHEMA,
165
+ phase: DISPATCH_PHASE,
166
+ agent: facts.agent,
167
+ agentRepoCommit: facts.agentRepoCommit,
168
+ manifestSha256: facts.manifestSha256,
169
+ task: facts.task,
170
+ attemptIndex: facts.attemptIndex,
171
+ dispatchId: facts.dispatchId,
172
+ attemptId: facts.attemptId,
173
+ sessionName: facts.sessionName,
174
+ effectDisposition: facts.effectDisposition,
175
+ effectCorrelationId: facts.effectCorrelationId,
176
+ effectCorrelationEchoVerified: facts.effectCorrelationEchoVerified,
177
+ noMutationObserved: facts.noMutationObserved,
178
+ outputSha256: facts.outputSha256,
179
+ receiptSha256: facts.receiptSha256,
180
+ ...(facts.receiptName ? { receiptName: facts.receiptName } : {}),
181
+ };
182
+ }
183
+
184
+ export interface AkEvidenceRecordResult {
185
+ evidenceId: number;
186
+ }
187
+
188
+ export interface AkEvidenceRecordFailure {
189
+ error: Error;
190
+ }
191
+
192
+ /**
193
+ * Append one typed AK evidence row for a completed dispatch. Evidence is
194
+ * recorded only for a settled, no-mutation-observed dispatch; failures keep
195
+ * the immutable receipt as truth and leave AK recording to the parent task.
196
+ */
197
+ export async function recordDispatchEvidence(
198
+ params: {
199
+ taskId: number;
200
+ details: Record<string, unknown>;
201
+ },
202
+ options?: { akBinary?: string },
203
+ ): Promise<AkEvidenceRecordResult> {
204
+ const akBinary = options?.akBinary ?? "ak";
205
+ const detailsJson = JSON.stringify(params.details);
206
+ let stdout: string;
207
+ try {
208
+ const result = await execFileAsync(
209
+ akBinary,
210
+ [
211
+ "evidence",
212
+ "record",
213
+ "--task",
214
+ String(params.taskId),
215
+ "--check-type",
216
+ DISPATCH_EVIDENCE_CHECK_TYPE,
217
+ "--result",
218
+ "pass",
219
+ "--details",
220
+ detailsJson,
221
+ ],
222
+ { timeout: AK_TIMEOUT_MS, maxBuffer: 1024 * 1024, windowsHide: true },
223
+ );
224
+ stdout = result.stdout;
225
+ } catch (error) {
226
+ throw new Error(
227
+ `AK evidence recording failed: ${error instanceof Error ? error.message : String(error)}`,
228
+ );
229
+ }
230
+ const id = Number(
231
+ /Recorded evidence\s+#?(\d+)/iu.exec(stdout)?.[1] ?? /#(\d+)/u.exec(stdout)?.[1],
232
+ );
233
+ if (!Number.isInteger(id) || id <= 0) {
234
+ throw new Error(`AK evidence recording returned no evidence id (output: ${stdout.trim()})`);
235
+ }
236
+ return { evidenceId: id };
237
+ }
@@ -0,0 +1,89 @@
1
+ // ---
2
+ // summary: Fleet Phase-2 exact-task read-only standing-agent dispatch contract constants and types.
3
+ // read_when:
4
+ // - changing the dispatch_agent Phase-2 gate, eligibility rules, or receipt/evidence semantics.
5
+ // ---
6
+
7
+ /**
8
+ * Fleet Phase 2 enables exactly one bounded execution shape:
9
+ * one registered standing agent, bound to one exact claimed AK task,
10
+ * read-only, executed through the ASC-owned runtime, recorded as one
11
+ * immutable receipt plus one AK evidence row. Everything else stays
12
+ * fail-closed exactly as in Phase 0/1.
13
+ */
14
+ export const DISPATCH_PHASE = "fleet_phase_2" as const;
15
+
16
+ export const DISPATCH_RECEIPT_SCHEMA = "pi-agent-registry.dispatch-receipt/1" as const;
17
+
18
+ export const DISPATCH_EVIDENCE_CHECK_TYPE = "standing-agent-dispatch" as const;
19
+
20
+ /**
21
+ * Tool allowlist for Phase-2 read-only dispatch. `bash` is admitted only as
22
+ * the fleet's established read-only exploration instrument (profile parity
23
+ * with ASC explorer/reviewer/tester/researcher); mutation tools are excluded
24
+ * and the child task contract plus parent-side observation bound the posture.
25
+ */
26
+ export const READ_ONLY_DISPATCH_TOOLS: readonly string[] = ["read", "bash"];
27
+
28
+ /**
29
+ * Provenance marker injected into every dispatched standing-agent child.
30
+ * `dispatch_agent` refuses to run inside a session that already carries it,
31
+ * keeping standing-agent dispatch one level deep.
32
+ */
33
+ export const DISPATCH_CHILD_PROVENANCE_ENV = "PI_PROVENANCE_STANDING_AGENT_DISPATCH" as const;
34
+
35
+ /** Default bounded child execution timeout (seconds) for Phase-2 dispatch. */
36
+ export const DISPATCH_EXECUTION_TIMEOUT_SECONDS = 900;
37
+
38
+ /** Default bounded child startup timeout (seconds) for Phase-2 dispatch. */
39
+ export const DISPATCH_STARTUP_TIMEOUT_SECONDS = 120;
40
+
41
+ /** Bounded retry posture: a failed attempt never burns the pair, but at most
42
+ * this many receipts may exist per (agent, exact task) pair, and only ONE may
43
+ * be settled. */
44
+ export const MAX_DISPATCH_ATTEMPTS_PER_PAIR = 3;
45
+
46
+ export type DispatchFailureReason =
47
+ | "invalid_request"
48
+ | "recursive_dispatch"
49
+ | "unknown_agent"
50
+ | "agent_not_read_only"
51
+ | "agent_repo_dirty"
52
+ | "agent_repo_drift"
53
+ | "agent_resolution_failed"
54
+ | "dispatch_already_recorded"
55
+ | "dispatch_attempts_exhausted"
56
+ | "ak_unavailable"
57
+ | "asc_execution_unavailable"
58
+ | "task_not_found"
59
+ | "task_repo_mismatch"
60
+ | "task_not_claimed"
61
+ | "task_lease_expired"
62
+ | "parent_repo_unobservable"
63
+ | "dispatch_failed"
64
+ | "read_only_violation_observed"
65
+ | "receipt_write_failed"
66
+ | "evidence_record_failed";
67
+
68
+ export interface AkTaskSnapshot {
69
+ id: number;
70
+ repo: string;
71
+ title: string;
72
+ status: string;
73
+ claimed_by: string | null;
74
+ lease_expires_at: string | null;
75
+ }
76
+
77
+ export interface DispatchAgentRequest {
78
+ /** Registered standing-agent name (agent.json `name`). */
79
+ agent: string;
80
+ /** Exact AK task id that authorizes this one read-only dispatch. */
81
+ task: number;
82
+ /** Bounded read-only objective for the dispatched agent. */
83
+ objective: string;
84
+ }
85
+
86
+ export interface DispatchEffectFact {
87
+ spawnAttempted: boolean;
88
+ effectDisposition: "confirmed_no_effects" | "settled" | "effect_indeterminate";
89
+ }