@anhlp/alp-runtime 0.0.0-stage → 0.0.1-beta.4
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.
- package/LICENSE +202 -0
- package/NOTICE +7 -0
- package/README.md +6 -2
- package/dist/acp-transport.d.ts +130 -0
- package/dist/acp-transport.js +511 -0
- package/dist/antigravity-transport.d.ts +82 -0
- package/dist/antigravity-transport.js +619 -0
- package/dist/assignment-results.d.ts +18 -0
- package/dist/assignment-results.js +77 -0
- package/dist/attachments.d.ts +84 -0
- package/dist/attachments.js +213 -0
- package/dist/board-domain.d.ts +56 -0
- package/dist/board-domain.js +157 -0
- package/dist/board.d.ts +21 -0
- package/dist/board.js +69 -0
- package/dist/catalog.d.ts +31 -0
- package/dist/catalog.js +38 -0
- package/dist/claude-transport.d.ts +202 -0
- package/dist/claude-transport.js +817 -0
- package/dist/configure.d.ts +44 -0
- package/dist/configure.js +64 -0
- package/dist/contracts.d.ts +327 -0
- package/dist/contracts.js +16 -0
- package/dist/conversation.d.ts +45 -0
- package/dist/conversation.js +62 -0
- package/dist/events.d.ts +1 -0
- package/dist/events.js +0 -0
- package/dist/github.d.ts +10 -0
- package/dist/github.js +39 -0
- package/dist/handoff.d.ts +36 -0
- package/dist/handoff.js +73 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +87 -0
- package/dist/language.d.ts +41 -0
- package/dist/language.js +68 -0
- package/dist/live.d.ts +48 -0
- package/dist/live.js +45 -0
- package/dist/mailbox.d.ts +26 -0
- package/dist/mailbox.js +38 -0
- package/dist/mcp-tools.d.ts +115 -0
- package/dist/mcp-tools.js +71 -0
- package/dist/permission-path.d.ts +2 -0
- package/dist/permission-path.js +44 -0
- package/dist/process-info.d.ts +14 -0
- package/dist/process-info.js +32 -0
- package/dist/providers.d.ts +28 -0
- package/dist/providers.js +71 -0
- package/dist/recall.d.ts +42 -0
- package/dist/recall.js +60 -0
- package/dist/resolve.d.ts +121 -0
- package/dist/resolve.js +238 -0
- package/dist/runtime-context.d.ts +43 -0
- package/dist/runtime-context.js +61 -0
- package/dist/runtime.d.ts +11 -0
- package/dist/runtime.js +3976 -0
- package/dist/session-config.d.ts +2102 -0
- package/dist/session-config.js +205 -0
- package/dist/session-state.d.ts +166 -0
- package/dist/session-state.js +0 -0
- package/dist/tool-detail.d.ts +81 -0
- package/dist/tool-detail.js +332 -0
- package/dist/tool-results.d.ts +12 -0
- package/dist/tool-results.js +25 -0
- package/dist/tools.d.ts +719 -0
- package/dist/tools.js +396 -0
- package/dist/transport.d.ts +47 -0
- package/dist/transport.js +169 -0
- package/dist/usage.d.ts +25 -0
- package/dist/usage.js +60 -0
- package/dist/workspace.d.ts +101 -0
- package/dist/workspace.js +259 -0
- package/dist/worktree-domain.d.ts +89 -0
- package/dist/worktree-domain.js +199 -0
- package/package.json +262 -4
- package/src/acp-transport.ts +508 -0
- package/src/antigravity-transport.ts +667 -0
- package/src/assignment-results.ts +64 -0
- package/src/attachments.ts +254 -0
- package/src/board-domain.ts +185 -0
- package/src/board.ts +80 -0
- package/src/catalog.ts +32 -0
- package/src/claude-transport.ts +938 -0
- package/src/configure.ts +93 -0
- package/src/contracts.ts +318 -0
- package/src/conversation.ts +81 -0
- package/src/events.ts +1 -0
- package/src/github.ts +37 -0
- package/src/handoff.ts +86 -0
- package/src/index.ts +32 -0
- package/src/language.ts +102 -0
- package/src/live.ts +86 -0
- package/src/mailbox.ts +49 -0
- package/src/mcp-tools.ts +73 -0
- package/src/permission-path.ts +37 -0
- package/src/process-info.ts +40 -0
- package/src/providers.ts +84 -0
- package/src/recall.ts +85 -0
- package/src/resolve.ts +230 -0
- package/src/runtime-context.ts +49 -0
- package/src/runtime.ts +4691 -0
- package/src/session-config.ts +304 -0
- package/src/session-state.ts +164 -0
- package/src/tool-detail.ts +397 -0
- package/src/tool-results.ts +26 -0
- package/src/tools.ts +397 -0
- package/src/transport.ts +116 -0
- package/src/usage.ts +79 -0
- package/src/workspace.ts +318 -0
- package/src/worktree-domain.ts +220 -0
package/src/configure.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live configure of a root (docs/protocol/alp-json-rpc-v1.md §12.9, issue #121 branch D): the pure
|
|
3
|
+
* parts. What a model a client names means for a session's runtime, which models the host offers,
|
|
4
|
+
* the effective configuration a resolved session runs with, and what of it can run commands.
|
|
5
|
+
*/
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { MODELS } from '@anhlp/alp-core/models';
|
|
8
|
+
import { profileModel } from '@anhlp/alp-core/profiles';
|
|
9
|
+
import type { ConfigureChange, EffectiveConfig } from '@anhlp/alp-protocol/session';
|
|
10
|
+
import type { ResolvedSession, RuntimeKind } from './resolve.js';
|
|
11
|
+
|
|
12
|
+
export type { ConfigureChange, EffectiveConfig } from '@anhlp/alp-protocol/session';
|
|
13
|
+
|
|
14
|
+
/** What a client may change on a live root; each absent field keeps the session's own. */
|
|
15
|
+
export type ConfigureChanges = { model?: string; thinking?: string; team?: string; mode?: string };
|
|
16
|
+
|
|
17
|
+
/** Runtimes whose roots alpd reconfigures live (macOS and Linux; Codex and Claude, issue #121 closure scope). */
|
|
18
|
+
export const CONFIGURABLE: ReadonlySet<RuntimeKind> = new Set(['codex', 'claude'] as RuntimeKind[]);
|
|
19
|
+
|
|
20
|
+
const PREFIXED = /^(codex|claude|antigravity|acp)[:/](.*)$/s;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The native model `model` names for a session of `runtime`: unprefixed, or prefixed with that runtime
|
|
24
|
+
* (`codex:`, `claude:`, or with a slash). A prefix of another runtime is a migration, never taken.
|
|
25
|
+
*/
|
|
26
|
+
export function nativeModelOf(runtime: RuntimeKind, model: string): { model: string } | { runtime: string } {
|
|
27
|
+
const match = PREFIXED.exec(model);
|
|
28
|
+
if (!match) return { model };
|
|
29
|
+
return match[1] === runtime ? { model: match[2] } : { runtime: match[1] };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Whether the host offers `model` on `runtime`: ALP's catalog (core MODELS, what library.models and
|
|
34
|
+
* catalog.get list), a model the project's settings give an agent of the team, or the model the session
|
|
35
|
+
* runs already. Never an arbitrary string.
|
|
36
|
+
*/
|
|
37
|
+
export function offered(runtime: RuntimeKind, model: string, mapping: ResolvedSession) {
|
|
38
|
+
if (model === mapping.model) return true;
|
|
39
|
+
if (MODELS.some(entry => entry.runtime === runtime && entry.id === model)) return true;
|
|
40
|
+
return Object.values(mapping.modelProfiles ?? {}).flat().some(profile => profileModel(profile) === `${runtime}:${model}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** MCP servers of a resolved session by name and kind: a stdio one runs a command on the host. */
|
|
44
|
+
export function mcpOf(mapping: ResolvedSession): EffectiveConfig['mcpServers'] {
|
|
45
|
+
return Object.entries(mapping.mcp ?? {}).map(([name, server]) => ({ name, kind: server && typeof server === 'object' && 'command' in (server as object) ? 'stdio' as const : 'http' as const }));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const digest = (value: unknown) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* What of a resolved session runs commands on the daemon host: its stdio MCP servers (command, args,
|
|
52
|
+
* env names) and its hooks (event and command), each as a digest. A change may keep or drop these,
|
|
53
|
+
* never add one (a team change that brings an executable resource is refused).
|
|
54
|
+
*/
|
|
55
|
+
export function executablesOf(mapping: ResolvedSession): Set<string> {
|
|
56
|
+
const found = new Set<string>();
|
|
57
|
+
for (const [name, server] of Object.entries(mapping.mcp ?? {}) as Array<[string, any]>) {
|
|
58
|
+
if (server && typeof server === 'object' && 'command' in server) found.add(`mcp:${name}:${digest([server.command, server.args ?? [], Object.keys(server.env ?? {}).sort()])}`);
|
|
59
|
+
}
|
|
60
|
+
for (const hook of (mapping.hooks ?? []) as Array<{ name?: string; event?: unknown; command?: unknown }>) found.add(`hook:${hook.name ?? ''}:${digest([hook.event, hook.command])}`);
|
|
61
|
+
return found;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The effective configuration of a resolved root, with its delegation targets, ALP tools and instructions digest. */
|
|
65
|
+
export function effectiveOf(runtime: RuntimeKind, mapping: ResolvedSession, extra: { delegates: string[]; tools: string[]; supervisor: boolean; instructionsSha?: string }): EffectiveConfig {
|
|
66
|
+
return {
|
|
67
|
+
team: mapping.workflow.mode,
|
|
68
|
+
...(mapping.team?.label ? { teamLabel: mapping.team.label } : {}),
|
|
69
|
+
agent: mapping.agent.name,
|
|
70
|
+
runtime,
|
|
71
|
+
model: mapping.model,
|
|
72
|
+
thinking: mapping.thinking,
|
|
73
|
+
mode: mapping.mode,
|
|
74
|
+
...(mapping.permissions?.name ? { permissionProfile: mapping.permissions.name } : {}),
|
|
75
|
+
supervisor: extra.supervisor,
|
|
76
|
+
maxPeers: mapping.workflow.maxPeers,
|
|
77
|
+
delegates: [...extra.delegates],
|
|
78
|
+
tools: [...extra.tools],
|
|
79
|
+
mcpServers: mcpOf(mapping),
|
|
80
|
+
hooks: (mapping.hooks ?? []).length,
|
|
81
|
+
...(extra.instructionsSha ? { instructionsSha: extra.instructionsSha } : {}),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Which of model, thinking, team and mode differ between two effective configurations. */
|
|
86
|
+
export function changesOf(current: EffectiveConfig, proposed: EffectiveConfig): ConfigureChange[] {
|
|
87
|
+
const changes: ConfigureChange[] = [];
|
|
88
|
+
if (current.model !== proposed.model) changes.push('model');
|
|
89
|
+
if (current.thinking !== proposed.thinking) changes.push('thinking');
|
|
90
|
+
if (current.team !== proposed.team) changes.push('team');
|
|
91
|
+
if (current.mode !== proposed.mode) changes.push('mode');
|
|
92
|
+
return changes;
|
|
93
|
+
}
|
package/src/contracts.ts
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import type { ProviderFactories, ProviderRegistry } from './providers.js';
|
|
2
|
+
import type { RuntimeKind, SessionSpec } from './resolve.js';
|
|
3
|
+
import type { Envelope, SessionSnapshot, TreeStatus, UserQuestion } from './events.js';
|
|
4
|
+
import type { Pin } from './board.js';
|
|
5
|
+
import type { LiveEntry } from './live.js';
|
|
6
|
+
import type { ProviderUsage } from './usage.js';
|
|
7
|
+
import type { AcpProvider } from './acp-transport.js';
|
|
8
|
+
import type { AntigravityProvider } from './antigravity-transport.js';
|
|
9
|
+
import type { GitHubRunner } from './github.js';
|
|
10
|
+
import type { AttachmentReader } from './attachments.js';
|
|
11
|
+
|
|
12
|
+
export type RuntimeTransport = {
|
|
13
|
+
request(method: string, params: any): Promise<any>;
|
|
14
|
+
initialize(): Promise<void>;
|
|
15
|
+
orchestrationContext?: () => Promise<unknown>;
|
|
16
|
+
onNotification(listener: (method: string, params: any) => void): void;
|
|
17
|
+
onFailure(listener: (error: unknown) => void): void;
|
|
18
|
+
close(): Promise<void>;
|
|
19
|
+
onRequest?: (listener: (method: string, params: any) => Promise<unknown>) => void;
|
|
20
|
+
/** Whether turn input may hold images (`{ type: 'image', url: 'data:…' }`); omitted, Codex and Claude do. */
|
|
21
|
+
takesImages?: () => boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type RuntimeOptions = {
|
|
25
|
+
/** Native adapter overrides, isolated to this runtime; the legacy transport hook takes precedence. */
|
|
26
|
+
providers?: ProviderFactories;
|
|
27
|
+
/** An explicit registry, including custom adapter factories registered by the host. */
|
|
28
|
+
providerRegistry?: ProviderRegistry;
|
|
29
|
+
codexCommand?: string;
|
|
30
|
+
claudeCommand?: string;
|
|
31
|
+
antigravityCommand?: string;
|
|
32
|
+
environment?: NodeJS.ProcessEnv;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Optional transport factory for tests/custom runtimes.
|
|
36
|
+
* When omitted, ALP creates CodexTransport, ClaudeTransport, or AntigravityTransport itself.
|
|
37
|
+
*/
|
|
38
|
+
transport?: (
|
|
39
|
+
cwd: string,
|
|
40
|
+
env: NodeJS.ProcessEnv,
|
|
41
|
+
runtime?: RuntimeKind,
|
|
42
|
+
/** The ACP provider, when runtime is acp. */
|
|
43
|
+
acp?: AcpProvider,
|
|
44
|
+
/** The Antigravity provider, when runtime is antigravity. */
|
|
45
|
+
antigravity?: AntigravityProvider,
|
|
46
|
+
) => RuntimeTransport;
|
|
47
|
+
|
|
48
|
+
/** An assignment with no activity this long is reported stalled; twice this long, it fails. */
|
|
49
|
+
silentForMs?: number;
|
|
50
|
+
/** How often main gets a check-in on its running assignments. Default 10 minutes; 0 turns it off. */
|
|
51
|
+
checkInMs?: number;
|
|
52
|
+
/** How often the watchdog looks at running assignments; derived from silentForMs and checkInMs when omitted. */
|
|
53
|
+
watchMs?: number;
|
|
54
|
+
/** How long alp_ask waits for the requester before returning unanswered. */
|
|
55
|
+
askTimeoutMs?: number;
|
|
56
|
+
/** How long alp_ask waits for the user before returning unanswered. Default 30 minutes. */
|
|
57
|
+
userAskTimeoutMs?: number;
|
|
58
|
+
|
|
59
|
+
/** Directory for per-root-session assignment logs (JSONL). Omitted disables logging. */
|
|
60
|
+
runLogDir?: string;
|
|
61
|
+
|
|
62
|
+
/** Starter files for projects without ALP; omitted reads the repository templates. */
|
|
63
|
+
templates?: Record<string, string>;
|
|
64
|
+
|
|
65
|
+
/** Where isolated assignments get their git worktrees. Default: a directory in the temp directory. */
|
|
66
|
+
worktreeDir?: string;
|
|
67
|
+
|
|
68
|
+
/** Where assignments whose profile has workdir copy get their disposable copies. Default: a directory in the temp directory. */
|
|
69
|
+
copyDir?: string;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Where the runtime stages read-only copies of attached files for its sessions' tools (§12.6):
|
|
73
|
+
* alpd's `<ALP_HOME>/state/staged`. Omitted, no session takes attachment parts.
|
|
74
|
+
*/
|
|
75
|
+
attachmentStageDir?: string;
|
|
76
|
+
|
|
77
|
+
/** Where project boards are kept (JSONL per project). Omitted keeps them in memory only. */
|
|
78
|
+
boardDir?: string;
|
|
79
|
+
|
|
80
|
+
/** The user's skill library and lessons (ALP_HOME). Omitted: only skills inside the project, and project lessons. */
|
|
81
|
+
libraryDir?: string;
|
|
82
|
+
|
|
83
|
+
/** The language the user reads; omitted, their settings.json's language, else Vietnamese (ALPD §54). */
|
|
84
|
+
language?: string;
|
|
85
|
+
|
|
86
|
+
/** Where finished assignments' native threads are recorded for alp_recall. Omitted keeps the record in memory. */
|
|
87
|
+
recallFile?: string;
|
|
88
|
+
/** Permanent assignment ownership; unlike recall, results never expire. */
|
|
89
|
+
assignmentResultFile?: string;
|
|
90
|
+
|
|
91
|
+
/** Where pauses are kept across restarts. Omitted keeps them in memory. */
|
|
92
|
+
pauseFile?: string;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Resume a runtime a usage limit paused as soon as the runtime says the limit lifted
|
|
96
|
+
* (ALPD §58). Default true; false tells the user instead, who resumes it.
|
|
97
|
+
*/
|
|
98
|
+
autoResume?: boolean;
|
|
99
|
+
/** How often alpd asks a runtime a usage limit paused whether the limit lifted. Default LIMIT_CHECK_MS. */
|
|
100
|
+
limitCheckMs?: number;
|
|
101
|
+
|
|
102
|
+
/** Where running assignments are kept, so the next alpd continues them (ALPD §31). Omitted keeps them in memory. */
|
|
103
|
+
liveFile?: string;
|
|
104
|
+
|
|
105
|
+
/** False parks assignments an earlier alpd left running until the user runs alp resume. Default true: they continue. */
|
|
106
|
+
recoveryResume?: boolean;
|
|
107
|
+
|
|
108
|
+
/** How the previous alpd ended, for what recovered sessions are told. */
|
|
109
|
+
previousExit?: { kind: 'clean' | 'crash'; at?: string };
|
|
110
|
+
|
|
111
|
+
/** False starts no supervisors, for hosts and tests that do not want them. Default true. */
|
|
112
|
+
supervisor?: boolean;
|
|
113
|
+
|
|
114
|
+
/** Runs the GitHub CLI for alp_issue. Default: `gh` (or ALP_GH_BIN) on PATH. */
|
|
115
|
+
github?: GitHubRunner;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
import type { NativeBase, OpenOptions, PromptContent, PromptInput } from '@anhlp/alp-protocol/session';
|
|
119
|
+
export type { NativeBase, OpenOptions, PromptContent, PromptInput } from '@anhlp/alp-protocol/session';
|
|
120
|
+
|
|
121
|
+
export type ScheduleHandler = (action: 'create' | 'list' | 'get' | 'cancel', params: Record<string, unknown>) => Promise<unknown>;
|
|
122
|
+
|
|
123
|
+
export type AlpRuntime = {
|
|
124
|
+
/** The daemon supplies its persistent schedule operations; standalone runtimes have none. */
|
|
125
|
+
bindSchedules?: (handler: ScheduleHandler | undefined) => void;
|
|
126
|
+
/** alpd hands over its attachment reader (§12.6) once, at start; with a staging directory, Codex and Claude sessions then take attachment parts. */
|
|
127
|
+
bindAttachments?: (reader: AttachmentReader | undefined) => void;
|
|
128
|
+
/** Whether this live session's runtime takes `{ type: 'attachment' }` parts (RuntimeAttachment) now. */
|
|
129
|
+
takesAttachments?: (sessionId: string) => boolean;
|
|
130
|
+
/** Removes the staged copy of an attachment alpd no longer keeps. */
|
|
131
|
+
dropAttachment?: (attachmentId: string) => Promise<void>;
|
|
132
|
+
onEvent(listener: (envelope: Envelope) => void): () => void;
|
|
133
|
+
/** Certifies a brand-new generated checkout; never use to adopt an existing workspace. */
|
|
134
|
+
registerWorkspace(directory: string): Promise<void>;
|
|
135
|
+
/** Reserves runtime admission while an external checkout mutation runs. */
|
|
136
|
+
withWorkspaceMutation<T>(directory: string, run: () => Promise<T>): Promise<T>;
|
|
137
|
+
/** Opens a root session under a client-chosen id. */
|
|
138
|
+
open(sessionId: string, spec: SessionSpec, options?: OpenOptions): Promise<SessionSnapshot>;
|
|
139
|
+
/** What a new root would run as, before it opens, and the teams it may choose (ALPD §60). */
|
|
140
|
+
preview(spec: SessionSpec): Promise<SessionPreview>;
|
|
141
|
+
/** Failures are reported as prompt.failed, once per clientMessageId. */
|
|
142
|
+
prompt(sessionId: string, input: PromptInput): Promise<void>;
|
|
143
|
+
/**
|
|
144
|
+
* Atomically admits an idle scheduled prompt in the runtime queue. Busy leaves all preparation
|
|
145
|
+
* untouched. The callback may persist its attempt/receipt, then use its single-use sender;
|
|
146
|
+
* the sender is valid only during the callback. Do not enqueue runtime operations in the callback.
|
|
147
|
+
*/
|
|
148
|
+
withIdlePrompt?<T>(sessionId: string, run: (send: (input: PromptInput) => Promise<void>) => Promise<T>): Promise<{ busy: true } | { busy: false; value: T }>;
|
|
149
|
+
/** Cancels the running turn and closes the whole subtree. */
|
|
150
|
+
interrupt(sessionId: string): Promise<void>;
|
|
151
|
+
/** Atomically cancels only this active turn; false leaves the session and subtree untouched. */
|
|
152
|
+
interruptTurn?(sessionId: string, turnId: string): Promise<boolean>;
|
|
153
|
+
/** Changes permission mode while idle; undefined keeps the current mode.
|
|
154
|
+
* onApplied is awaited after apply and before session.updated; failure does not roll back permissions. */
|
|
155
|
+
configure(sessionId: string, changes: { mode?: string }, options?: { onApplied?: (session: SessionSnapshot) => Promise<void> }): Promise<SessionSnapshot>;
|
|
156
|
+
close(sessionId: string): Promise<void>;
|
|
157
|
+
snapshot(sessionId: string): SessionSnapshot | undefined;
|
|
158
|
+
/** Live sessions, roots before their children. */
|
|
159
|
+
list(): SessionSnapshot[];
|
|
160
|
+
/** The live tree containing a session: sessions, assignments, questions, worktrees and leases. */
|
|
161
|
+
status(sessionId: string): TreeStatus | undefined;
|
|
162
|
+
/** Questions agents asked the user that wait for an answer. */
|
|
163
|
+
questions(): UserQuestion[];
|
|
164
|
+
/** Answers or dismisses a question to the user. */
|
|
165
|
+
answer(questionId: string, reply: { text?: string; dismiss?: boolean; reason?: string }): void;
|
|
166
|
+
/** Mails a note from the user to any live session, such as an assignment the user wants to redirect. */
|
|
167
|
+
message(sessionId: string, text: string): void;
|
|
168
|
+
/** The project board: live claims, then decisions and findings, oldest first. */
|
|
169
|
+
board(projectRoot: string): Promise<Pin[]>;
|
|
170
|
+
/** Holds delegation and wakes on one runtime, or all; now also parks running assignments there. */
|
|
171
|
+
pause(input?: { runtime?: RuntimeKind; now?: boolean; reason?: string }): PauseState;
|
|
172
|
+
/** Lifts a pause and continues the assignments it parked. Without a runtime, lifts every pause. */
|
|
173
|
+
resume(input?: { runtime?: RuntimeKind }): PauseState;
|
|
174
|
+
/** Current pauses and parked assignments. */
|
|
175
|
+
pauses(): PauseState;
|
|
176
|
+
/** Read-only, cached account usage for the web and desktop viewers. */
|
|
177
|
+
usage(refresh?: boolean): Promise<{ providers: Array<ProviderUsage & { pause: Pause | null; parked: number }> }>;
|
|
178
|
+
/** The user asks a finished assignment, or the last one on a task of the project, about its work. */
|
|
179
|
+
recall(target: { assignmentId?: string; taskId?: string; projectRoot?: string }, question: string): Promise<RecallAnswer>;
|
|
180
|
+
/** Assignments an earlier alpd left running, which recover() can continue once their root is open again. */
|
|
181
|
+
recoverable(): LiveEntry[];
|
|
182
|
+
/** Continues work in a reopened tree. continueRoot resumes its running turn; parkedRoot restores its held continuation intent. */
|
|
183
|
+
recover(rootId: string, options?: { continueRoot?: boolean; parkedRoot?: string }): Promise<RecoveryOutcome[]>;
|
|
184
|
+
/** Gives up the assignments of a tree whose root cannot be opened again: their tasks go back to open. */
|
|
185
|
+
abandon(rootId: string, reason: string): Promise<RecoveryOutcome[]>;
|
|
186
|
+
/** Closes everything; assignments still running stay in the live file, so the next alpd continues them. */
|
|
187
|
+
shutdown(): Promise<void>;
|
|
188
|
+
/**
|
|
189
|
+
* Makes the native conversation a fork or rewind continues (docs/protocol/alp-json-rpc-v1.md §12.7): the
|
|
190
|
+
* source's conversation cut at a completed turn, persisted by the runtime and leaving the source
|
|
191
|
+
* unchanged. Touches no live session. Codex forks natively now (thread/fork); Claude validates the
|
|
192
|
+
* cut against the transcript and answers a binding the first prompt forks (NativeBinding.base).
|
|
193
|
+
* Refusals throw a ConversationError; one the runtime may or may not have acted on carries
|
|
194
|
+
* `outcomeUnknown: true`. Absent: the host cannot fork or rewind.
|
|
195
|
+
*/
|
|
196
|
+
branch?(request: BranchRequest): Promise<NativeBinding>;
|
|
197
|
+
/**
|
|
198
|
+
* Moves an idle live root to another native conversation (a rewind), or reopens its own (a reload):
|
|
199
|
+
* its native process restarts and resumes `binding`, keeping the session id, its configuration and
|
|
200
|
+
* attached viewers. Refused (ConversationError session_busy) unless the whole tree is idle: no turn,
|
|
201
|
+
* pending prompt, child, assignment, question, mail or parked work. Emits session.updated.
|
|
202
|
+
* When the new binding cannot be opened, the old one is reopened and the error thrown; when neither
|
|
203
|
+
* opens, the session fails as a provider failure does.
|
|
204
|
+
*/
|
|
205
|
+
rebind?(sessionId: string, binding: NativeBinding): Promise<SessionSnapshot>;
|
|
206
|
+
/**
|
|
207
|
+
* Restarts an idle live root's native process on the conversation it runs now (a reload, §12.8):
|
|
208
|
+
* the same binding, the session's own configuration, id and viewers. Refused as rebind is. The new
|
|
209
|
+
* process must resume that very conversation (a Claude one is refused unless its transcript, or its
|
|
210
|
+
* base, exists), never an empty one in its place; when it cannot, the session closes with its
|
|
211
|
+
* conversation kept (a later open resumes it) and a ConversationError native_refused is thrown.
|
|
212
|
+
* Emits session.updated.
|
|
213
|
+
*/
|
|
214
|
+
reload?(sessionId: string): Promise<SessionSnapshot>;
|
|
215
|
+
/**
|
|
216
|
+
* Holds the turns the runtime would start by itself on this session (mail wakes) while a lifecycle
|
|
217
|
+
* operation changes its conversation (a rewind, later a reload or live configure): mail stays queued
|
|
218
|
+
* and is delivered when the last hold is released, to whatever conversation the session runs then.
|
|
219
|
+
* rebind does not refuse for mail a hold keeps. Client prompts are not held here; alpd holds them.
|
|
220
|
+
* Undefined when the session is not open.
|
|
221
|
+
*/
|
|
222
|
+
hold?(sessionId: string): (() => void) | undefined;
|
|
223
|
+
/**
|
|
224
|
+
* Whether an open session may begin a turn it has no id for yet: its turn/start (a prompt or a mail
|
|
225
|
+
* wake) is reserved, queued or on its way to the native runtime, or its process is opening or restarting. Such a
|
|
226
|
+
* turn shows in `activeTurnId` only once the runtime answers, and a hold taken meanwhile does not
|
|
227
|
+
* stop it. False when the session is not open.
|
|
228
|
+
*/
|
|
229
|
+
starting?(sessionId: string): boolean;
|
|
230
|
+
/**
|
|
231
|
+
* What a live root would run with after a change of model, thinking, team or mode (§12.9), and whether
|
|
232
|
+
* reconfigure would take it now; reads only. `teams` are the teams the host defines for its project.
|
|
233
|
+
*/
|
|
234
|
+
previewConfigure?(sessionId: string, changes: ConfigureChanges): Promise<ConfigurePreview>;
|
|
235
|
+
/**
|
|
236
|
+
* Changes an idle live root's model, thinking, team or mode (§12.9): resolves the new configuration
|
|
237
|
+
* as session.create would (same runtime, agent and conversation; never more permissions or new
|
|
238
|
+
* executable resources), then restarts its native process with it on the same conversation (`fresh`:
|
|
239
|
+
* a root with no turn yet starts a new, empty one). The session takes the new configuration only
|
|
240
|
+
* once the runtime took it (Codex echoes the model it resumed with); otherwise the old one is
|
|
241
|
+
* restarted and a ConversationError thrown (`outcomeUnknown` when the runtime may have acted). A
|
|
242
|
+
* new team also replaces the root's supervisor. Refused as rebind is while the tree is not idle.
|
|
243
|
+
* `onApplied` lets a host persist the configuration the runtime took before session.updated is
|
|
244
|
+
* emitted. It is awaited only for an applied change; its failure leaves the runtime configured
|
|
245
|
+
* and reports outcomeUnknown (never a rollback or a known native refusal).
|
|
246
|
+
* Emits session.updated after that persistence succeeds.
|
|
247
|
+
*/
|
|
248
|
+
reconfigure?(sessionId: string, changes: ConfigureChanges, options?: { fresh?: boolean; onApplied?: (result: ConfigureResult) => Promise<void> }): Promise<ConfigureResult>;
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
import type { ConfigureChanges, EffectiveConfig } from './configure.js';
|
|
252
|
+
export type { ConfigureChanges, EffectiveConfig } from './configure.js';
|
|
253
|
+
import type { ConfigurePreview } from '@anhlp/alp-protocol/session';
|
|
254
|
+
export type { ConfigurePreview } from '@anhlp/alp-protocol/session';
|
|
255
|
+
/** What reconfigure did: the session as it runs now, its effective configuration, and whether its process restarted. */
|
|
256
|
+
export type ConfigureResult = {
|
|
257
|
+
session: SessionSnapshot;
|
|
258
|
+
effective: EffectiveConfig;
|
|
259
|
+
status: 'applied' | 'unchanged';
|
|
260
|
+
restarted: boolean;
|
|
261
|
+
/** What the native runtime answered it runs with (Codex: the resumed thread's model and effort). */
|
|
262
|
+
native?: { model?: string; reasoningEffort?: string };
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/** The native conversation a session continues: its thread, or for Claude one its first prompt makes from `base`. */
|
|
266
|
+
export type NativeBinding = { threadId: string; base?: NativeBase };
|
|
267
|
+
|
|
268
|
+
export type BranchRequest = {
|
|
269
|
+
runtime: RuntimeKind;
|
|
270
|
+
/** The conversation to branch: the source session's binding. */
|
|
271
|
+
source: NativeBinding;
|
|
272
|
+
/** Where the source session runs (Claude keeps its transcripts by this directory). */
|
|
273
|
+
cwd: string;
|
|
274
|
+
/** Where the branch will run; the source's cwd when absent. */
|
|
275
|
+
targetCwd?: string;
|
|
276
|
+
/** The configuration the branch keeps: Codex otherwise forks with its default model and settings. */
|
|
277
|
+
model: string;
|
|
278
|
+
thinking: string;
|
|
279
|
+
mode: string;
|
|
280
|
+
/**
|
|
281
|
+
* The cut, by ALP turn id (a Codex turn id; a Claude prompt uuid): keep through a completed turn, or
|
|
282
|
+
* everything before a turn. Absent: the whole conversation.
|
|
283
|
+
*/
|
|
284
|
+
cutoff?: { throughTurnId: string } | { beforeTurnId: string };
|
|
285
|
+
/** The source root's turns in order, as ALP's timeline has them, for the cut's validation. */
|
|
286
|
+
turns: string[];
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
export type ConversationErrorReason = 'runtime_unsupported' | 'boundary_unmapped' | 'empty_prefix' | 'native_refused' | 'session_busy'
|
|
290
|
+
// A live configure (§12.9).
|
|
291
|
+
| 'runtime_mismatch' | 'agent_mismatch' | 'permission_escalation' | 'team_not_found' | 'invalid_params' | 'not_root';
|
|
292
|
+
/** A fork, rewind or rebind the runtime refused; `outcomeUnknown` when the native runtime may have acted. */
|
|
293
|
+
export class ConversationError extends Error {
|
|
294
|
+
readonly data: { reason: ConversationErrorReason };
|
|
295
|
+
readonly outcomeUnknown: boolean;
|
|
296
|
+
constructor(reason: ConversationErrorReason, message: string, { outcomeUnknown = false } = {}) {
|
|
297
|
+
super(message);
|
|
298
|
+
this.name = 'ConversationError';
|
|
299
|
+
this.data = { reason };
|
|
300
|
+
this.outcomeUnknown = outcomeUnknown;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
import type { SessionPreview } from '@anhlp/alp-protocol/session';
|
|
305
|
+
export type { SessionPreview } from '@anhlp/alp-protocol/session';
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The team `alp init` names in a new project's settings (core INIT_TEAM). Kept for compatibility;
|
|
309
|
+
* it is not the default of a session: preview and create both resolve that through core
|
|
310
|
+
* `defaultTeam` (settings without workflow.mode run `custom`).
|
|
311
|
+
*/
|
|
312
|
+
export { INIT_TEAM as DEFAULT_TEAM } from '@anhlp/alp-core/workflow';
|
|
313
|
+
|
|
314
|
+
import type { RecoveryOutcome, Pause, PauseState } from '@anhlp/alp-protocol/session';
|
|
315
|
+
export type { RecoveryOutcome, Pause, PauseState } from '@anhlp/alp-protocol/session';
|
|
316
|
+
|
|
317
|
+
import type { RecallAnswer } from '@anhlp/alp-protocol/session';
|
|
318
|
+
export type { RecallAnswer } from '@anhlp/alp-protocol/session';
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { ConversationError, type BranchRequest } from './contracts.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Conversation cuts for fork and rewind (docs/protocol/alp-json-rpc-v1.md §12.7). Pure helpers of
|
|
5
|
+
* runtime.ts's branch(): where a Claude transcript is cut, which Codex turn a fork must end at, and
|
|
6
|
+
* what a Codex refusal means.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** One entry of a Claude session's main chain, as the Agent SDK reads it from the transcript. */
|
|
10
|
+
export type ChainEntry = { uuid: string; type: 'user' | 'assistant'; toolResult?: boolean };
|
|
11
|
+
|
|
12
|
+
const refuse = (reason: ConstructorParameters<typeof ConversationError>[0], message: string, outcomeUnknown = false) =>
|
|
13
|
+
new ConversationError(reason, message, { outcomeUnknown });
|
|
14
|
+
|
|
15
|
+
/** The turn a Codex fork must end at, after ALP's own validation of the cut against its timeline. */
|
|
16
|
+
export function codexForkEnd(request: Pick<BranchRequest, 'cutoff' | 'turns'>) {
|
|
17
|
+
const { cutoff, turns } = request;
|
|
18
|
+
if (!cutoff) {
|
|
19
|
+
if (!turns.length) throw refuse('empty_prefix', 'The conversation has no turn to fork');
|
|
20
|
+
return { last: turns.at(-1)!, params: {} };
|
|
21
|
+
}
|
|
22
|
+
const id = 'beforeTurnId' in cutoff ? cutoff.beforeTurnId : cutoff.throughTurnId;
|
|
23
|
+
const index = turns.indexOf(id);
|
|
24
|
+
if (index < 0) throw refuse('boundary_unmapped', `Turn ${id} is not a turn of this conversation`);
|
|
25
|
+
if ('beforeTurnId' in cutoff) {
|
|
26
|
+
if (index === 0) throw refuse('empty_prefix', 'Nothing comes before the first turn');
|
|
27
|
+
return { last: turns[index - 1], params: { beforeTurnId: id } };
|
|
28
|
+
}
|
|
29
|
+
return { last: id, params: { lastTurnId: id } };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** What a refused thread/fork means; one written but unanswered may have forked. */
|
|
33
|
+
export function codexForkRefusal(error: unknown) {
|
|
34
|
+
if (error instanceof ConversationError) return error;
|
|
35
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
36
|
+
if ((error as { outcomeUnknown?: boolean })?.outcomeUnknown) return refuse('native_refused', `Codex did not answer the fork: ${message}`, true);
|
|
37
|
+
if (/^turn not found\b/i.test(message)) return refuse('boundary_unmapped', `Codex does not know that turn: ${message}`);
|
|
38
|
+
if (/identifies an in-progress turn/i.test(message)) return refuse('session_busy', `The turn is still running: ${message}`);
|
|
39
|
+
return refuse('native_refused', `Codex refused the fork: ${message}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The chain uuid a Claude fork resumes at (`resumeSessionAt`): the last entry kept. ALP turn ids are
|
|
44
|
+
* the prompt uuids it gave Claude, so a turn starts at its prompt entry and runs to the next ALP turn's
|
|
45
|
+
* prompt; entries of a turn Claude started itself belong to the turn before it. Always a pinned entry,
|
|
46
|
+
* never "everything", so turns the source adds later never reach a branch made before them.
|
|
47
|
+
*/
|
|
48
|
+
export function claudeCut(chain: ChainEntry[], request: Pick<BranchRequest, 'cutoff' | 'turns'>): string {
|
|
49
|
+
const { cutoff, turns } = request;
|
|
50
|
+
const position = new Map(chain.map((entry, index) => [entry.uuid, index]));
|
|
51
|
+
const prompt = (id: string) => {
|
|
52
|
+
const index = position.get(id);
|
|
53
|
+
return index !== undefined && chain[index].type === 'user' && !chain[index].toolResult ? index : undefined;
|
|
54
|
+
};
|
|
55
|
+
if (!cutoff) {
|
|
56
|
+
if (!chain.length) throw refuse('empty_prefix', 'The conversation has no turn to fork');
|
|
57
|
+
return chain.at(-1)!.uuid;
|
|
58
|
+
}
|
|
59
|
+
const id = 'beforeTurnId' in cutoff ? cutoff.beforeTurnId : cutoff.throughTurnId;
|
|
60
|
+
const turn = turns.indexOf(id);
|
|
61
|
+
const at = prompt(id);
|
|
62
|
+
if (turn < 0 || at === undefined) throw refuse('boundary_unmapped', `Turn ${id} is not a turn of this conversation's transcript`);
|
|
63
|
+
// ALP's turns found in the transcript must come in ALP's order, or the cut would not be what ALP shows.
|
|
64
|
+
const found = turns.map(prompt).filter((index): index is number => index !== undefined);
|
|
65
|
+
if (found.some((index, i) => i > 0 && index <= found[i - 1])) throw refuse('boundary_unmapped', 'The transcript orders turns differently from ALP');
|
|
66
|
+
if ('beforeTurnId' in cutoff) {
|
|
67
|
+
if (turn === 0 || at === 0) throw refuse('empty_prefix', 'Nothing comes before the first turn');
|
|
68
|
+
return chain[at - 1].uuid;
|
|
69
|
+
}
|
|
70
|
+
// Through a turn: up to the prompt of the next ALP turn the transcript has, else its end.
|
|
71
|
+
const next = turns.slice(turn + 1).map(prompt).find(index => index !== undefined);
|
|
72
|
+
return chain[(next ?? chain.length) - 1].uuid;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The chain a binding stands for: a pending fork is its base cut at `at`, until its first prompt makes it. */
|
|
76
|
+
export function pendingChain(base: { at?: string }, chain: ChainEntry[]) {
|
|
77
|
+
if (!base.at) return chain;
|
|
78
|
+
const index = chain.findIndex(entry => entry.uuid === base.at);
|
|
79
|
+
if (index < 0) throw refuse('native_refused', `The conversation no longer has the entry ${base.at} it was cut at`);
|
|
80
|
+
return chain.slice(0, index + 1);
|
|
81
|
+
}
|
package/src/events.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type * from '@anhlp/alp-protocol/events';
|
package/src/github.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { gitEnvironment } from './workspace.js';
|
|
3
|
+
|
|
4
|
+
/** Where agents send feedback about ALP itself: its process, tools and agent instructions. */
|
|
5
|
+
export const ALP_REPO = 'phucanh08/alp';
|
|
6
|
+
|
|
7
|
+
/** Runs the GitHub CLI with optional standard input and returns its standard output. Tests replace it. */
|
|
8
|
+
export type GitHubRunner = (args: string[], options: { cwd: string; input?: string }) => Promise<string>;
|
|
9
|
+
|
|
10
|
+
function run(command: string, args: string[], { cwd, input }: { cwd: string; input?: string }) {
|
|
11
|
+
return new Promise<string>((resolve, reject) => {
|
|
12
|
+
const child = spawn(command, args, { cwd, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], env: gitEnvironment() });
|
|
13
|
+
let stdout = '';
|
|
14
|
+
let stderr = '';
|
|
15
|
+
child.stdout.on('data', chunk => { stdout += chunk; });
|
|
16
|
+
child.stderr.on('data', chunk => { stderr += chunk; });
|
|
17
|
+
child.stdin.on('error', () => {});
|
|
18
|
+
child.on('error', error => reject((error as NodeJS.ErrnoException).code === 'ENOENT' ? new Error(`${command} is not installed or not on PATH`) : error));
|
|
19
|
+
child.on('close', code => code === 0 ? resolve(stdout) : reject(new Error((stderr || stdout).trim() || `${command} exited with ${code}`)));
|
|
20
|
+
child.stdin.end(input ?? '');
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const gh: GitHubRunner = (args, options) => run(process.env.ALP_GH_BIN ?? 'gh', args, options);
|
|
25
|
+
|
|
26
|
+
/** The owner/name of the GitHub repository a project's origin remote points to. */
|
|
27
|
+
export async function projectRepo(projectRoot: string) {
|
|
28
|
+
let url: string;
|
|
29
|
+
try {
|
|
30
|
+
url = (await run('git', ['remote', 'get-url', 'origin'], { cwd: projectRoot })).trim();
|
|
31
|
+
} catch {
|
|
32
|
+
throw new Error('This project has no git remote named origin; use target "alp" for feedback about ALP, or tell the user');
|
|
33
|
+
}
|
|
34
|
+
const match = /github\.com[:/]([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/.exec(url);
|
|
35
|
+
if (!match) throw new Error(`The origin remote is not on GitHub (${url}); issues are filed with the GitHub CLI only`);
|
|
36
|
+
return `${match[1]}/${match[2]}`;
|
|
37
|
+
}
|
package/src/handoff.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export const HANDOFF_OUTCOMES = ['complete', 'partial', 'blocked', 'reconsider'] as const;
|
|
2
|
+
export const HANDOFF_LISTS = ['candidate', 'scope', 'verification', 'risks', 'discovered'] as const;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A review's verdict (ALPD §38): each criterion judged with its evidence, findings
|
|
6
|
+
* by severity, and one result that follows from them, so requesters, tasks and
|
|
7
|
+
* later a review quorum read every review the same way.
|
|
8
|
+
*/
|
|
9
|
+
export const VERDICT_RESULTS = ['pass', 'pass_with_findings', 'fail', 'blocked'] as const;
|
|
10
|
+
export const CRITERION_RESULTS = ['pass', 'fail', 'not_checked'] as const;
|
|
11
|
+
export const SEVERITIES = ['critical', 'high', 'medium', 'low'] as const;
|
|
12
|
+
export type Verdict = {
|
|
13
|
+
result: typeof VERDICT_RESULTS[number];
|
|
14
|
+
criteria: Array<{ criterion: string; result: typeof CRITERION_RESULTS[number]; evidence: string }>;
|
|
15
|
+
findings?: Array<{ severity: typeof SEVERITIES[number]; where: string; problem: string; fix?: string }>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type Handoff = {
|
|
19
|
+
outcome: typeof HANDOFF_OUTCOMES[number];
|
|
20
|
+
summary: string;
|
|
21
|
+
ownership?: string;
|
|
22
|
+
verdict?: Verdict;
|
|
23
|
+
} & Partial<Record<typeof HANDOFF_LISTS[number], string[]>>;
|
|
24
|
+
|
|
25
|
+
/** Returns the normalized handoff, or an error message for the child. */
|
|
26
|
+
/** Returns the verdict, or what is wrong with it; its result must follow from its criteria and findings. */
|
|
27
|
+
export function parseVerdict(value: any): Verdict | string {
|
|
28
|
+
const text = (item: unknown) => typeof item === 'string' && !!item.trim();
|
|
29
|
+
const only = (item: any, keys: string[]) => !!item && typeof item === 'object' && !Array.isArray(item) && Object.keys(item).every(key => keys.includes(key));
|
|
30
|
+
if (!only(value, ['result', 'criteria', 'findings'])) return 'verdict must be an object with result, criteria and findings';
|
|
31
|
+
if (!VERDICT_RESULTS.includes(value.result)) return `verdict.result must be one of ${VERDICT_RESULTS.join(', ')}`;
|
|
32
|
+
if (!Array.isArray(value.criteria) || !value.criteria.length || value.criteria.length > 50) return 'verdict.criteria must list 1 to 50 criteria';
|
|
33
|
+
for (const item of value.criteria) {
|
|
34
|
+
if (!only(item, ['criterion', 'result', 'evidence']) || !text(item.criterion) || !text(item.evidence) || !CRITERION_RESULTS.includes(item.result)) {
|
|
35
|
+
return `each verdict criterion needs criterion, result (${CRITERION_RESULTS.join(', ')}) and evidence`;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const findings = value.findings ?? [];
|
|
39
|
+
if (!Array.isArray(findings) || findings.length > 100) return 'verdict.findings must be a list of at most 100 findings';
|
|
40
|
+
for (const item of findings) {
|
|
41
|
+
if (!only(item, ['severity', 'where', 'problem', 'fix']) || !SEVERITIES.includes(item.severity) || !text(item.where) || !text(item.problem) || (item.fix !== undefined && !text(item.fix))) {
|
|
42
|
+
return `each verdict finding needs severity (${SEVERITIES.join(', ')}), where and problem, and optionally fix`;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const failed = value.criteria.filter((item: any) => item.result === 'fail').length;
|
|
46
|
+
const serious = findings.filter((item: any) => item.severity === 'critical' || item.severity === 'high').length;
|
|
47
|
+
if (value.result === 'fail' && !failed && !serious) return 'verdict fail needs a failed criterion or a critical or high finding';
|
|
48
|
+
if ((value.result === 'pass' || value.result === 'pass_with_findings') && (failed || serious)) {
|
|
49
|
+
return `verdict ${value.result} cannot have ${failed ? 'a failed criterion' : 'a critical or high finding'}; the result is fail`;
|
|
50
|
+
}
|
|
51
|
+
if (value.result === 'pass' && findings.length) return 'verdict pass cannot have findings; use pass_with_findings';
|
|
52
|
+
if (value.result === 'pass_with_findings' && !findings.length) return 'verdict pass_with_findings needs findings';
|
|
53
|
+
return { result: value.result, criteria: value.criteria, ...(findings.length ? { findings } : {}) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A verdict in one line: its result and the criteria counts. */
|
|
57
|
+
export function verdictLine(verdict: Verdict) {
|
|
58
|
+
const count = (result: string) => verdict.criteria.filter(item => item.result === result).length;
|
|
59
|
+
const parts = [`${count('pass')} passed`, count('fail') ? `${count('fail')} failed` : '', count('not_checked') ? `${count('not_checked')} not checked` : '', verdict.findings?.length ? `${verdict.findings.length} findings` : ''];
|
|
60
|
+
return `verdict ${verdict.result.toUpperCase()} (${parts.filter(Boolean).join(', ')})`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function parseHandoff(args: any): Handoff | string {
|
|
64
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) return 'Handoff must be an object';
|
|
65
|
+
const allowed = ['outcome', 'summary', 'ownership', 'verdict', ...HANDOFF_LISTS];
|
|
66
|
+
if (Object.keys(args).some(key => !allowed.includes(key))) return 'Unknown handoff field';
|
|
67
|
+
if (!HANDOFF_OUTCOMES.includes(args.outcome)) return `outcome must be one of ${HANDOFF_OUTCOMES.join(', ')}`;
|
|
68
|
+
if (typeof args.summary !== 'string' || !args.summary.trim()) return 'summary is required';
|
|
69
|
+
if (args.ownership !== undefined && typeof args.ownership !== 'string') return 'ownership must be a string';
|
|
70
|
+
const handoff: Handoff = { outcome: args.outcome, summary: args.summary };
|
|
71
|
+
for (const key of HANDOFF_LISTS) {
|
|
72
|
+
if (args[key] === undefined) continue;
|
|
73
|
+
if (!Array.isArray(args[key]) || args[key].length > 100 || !args[key].every((item: unknown) => typeof item === 'string' && item.trim())) {
|
|
74
|
+
return `${key} must be a list of at most 100 nonempty strings`;
|
|
75
|
+
}
|
|
76
|
+
handoff[key] = args[key];
|
|
77
|
+
}
|
|
78
|
+
if (args.ownership?.trim()) handoff.ownership = args.ownership;
|
|
79
|
+
if (args.verdict !== undefined) {
|
|
80
|
+
const verdict = parseVerdict(args.verdict);
|
|
81
|
+
if (typeof verdict === 'string') return verdict;
|
|
82
|
+
handoff.verdict = verdict;
|
|
83
|
+
}
|
|
84
|
+
if (JSON.stringify(handoff).length > 32_000) return 'Handoff exceeds 32000 characters; summarize and point to files instead';
|
|
85
|
+
return handoff;
|
|
86
|
+
}
|