@bevel-software/ael 0.0.0-stage → 0.1.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.
Files changed (43) hide show
  1. package/LICENSE +13 -0
  2. package/README.md +32 -3
  3. package/bin/bevel-agent.mjs +15 -0
  4. package/executor-entrypoint.sh +41 -0
  5. package/executor.Dockerfile +37 -0
  6. package/package.json +61 -6
  7. package/src/agent/agent-file.ts +242 -0
  8. package/src/cli/main.ts +73 -0
  9. package/src/cli/run-local.ts +224 -0
  10. package/src/cli/run-step.ts +147 -0
  11. package/src/executor/activities.ts +295 -0
  12. package/src/executor/docker-cli.ts +148 -0
  13. package/src/executor/driver.ts +80 -0
  14. package/src/executor/harness-mount.ts +66 -0
  15. package/src/executor/in-place.ts +180 -0
  16. package/src/executor/main.ts +197 -0
  17. package/src/executor/personal.ts +355 -0
  18. package/src/executor/slots.ts +60 -0
  19. package/src/harness/claude-code.ts +397 -0
  20. package/src/harness/pi-in-process.ts +444 -0
  21. package/src/harness/session-state.ts +86 -0
  22. package/src/harness/stall-guard.ts +97 -0
  23. package/src/index.ts +164 -0
  24. package/src/interpreter/contract.ts +403 -0
  25. package/src/interpreter/dispatcher.workflow.ts +412 -0
  26. package/src/interpreter/interpret.workflow.ts +907 -0
  27. package/src/interpreter/scheduled.workflow.ts +28 -0
  28. package/src/interpreter/workflows.ts +8 -0
  29. package/src/run/contract.ts +150 -0
  30. package/src/run/outcome.ts +63 -0
  31. package/src/run/reporter.ts +127 -0
  32. package/src/run/system-prompt.ts +35 -0
  33. package/src/run/verdict.ts +46 -0
  34. package/src/shared/front-matter.ts +27 -0
  35. package/src/shared/redact.ts +78 -0
  36. package/src/shared/schedule.ts +93 -0
  37. package/src/temporal/payload-codec.ts +84 -0
  38. package/src/transcript/render.ts +106 -0
  39. package/src/workflow/definition.ts +196 -0
  40. package/src/workflow/failure-policy.ts +100 -0
  41. package/src/workflow/parameters.ts +175 -0
  42. package/src/workflow/workflow-file.ts +457 -0
  43. package/src/workitem/node.ts +170 -0
@@ -0,0 +1,84 @@
1
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
2
+ import type { DataConverter, Payload, PayloadCodec } from '@temporalio/common';
3
+
4
+ /**
5
+ * What crosses Temporal is stored in its history: a container step's input
6
+ * carries the actor's vault values and the run key, and the server keeps
7
+ * every input for the namespace's retention. This codec encrypts every
8
+ * payload the platform and the executors exchange (AES-256-GCM, a key
9
+ * derived from `TEMPORAL_PAYLOAD_KEY`) before it leaves the process and
10
+ * decrypts it on arrival; the history holds ciphertext, the UI shows none of
11
+ * it. Both sides hold the same key; a side without it cannot read a step.
12
+ */
13
+ export const ENCRYPTED_ENCODING = 'binary/encrypted';
14
+ const ENCODING_KEY = 'encoding';
15
+ const IV_BYTES = 12;
16
+ const TAG_BYTES = 16;
17
+
18
+ const utf8 = (s: string) => new TextEncoder().encode(s);
19
+
20
+ export class EncryptingPayloadCodec implements PayloadCodec {
21
+ private readonly key: Buffer;
22
+
23
+ constructor(secret: string) {
24
+ if (!secret.trim()) throw new Error('the payload key is empty');
25
+ this.key = createHash('sha256').update(secret).digest();
26
+ }
27
+
28
+ async encode(payloads: Payload[]): Promise<Payload[]> {
29
+ return payloads.map((p) => this.encrypt(p));
30
+ }
31
+
32
+ async decode(payloads: Payload[]): Promise<Payload[]> {
33
+ return payloads.map((p) => (isEncrypted(p) ? this.decrypt(p) : p));
34
+ }
35
+
36
+ private encrypt(payload: Payload): Payload {
37
+ const plain = Buffer.from(JSON.stringify(serializable(payload)), 'utf8');
38
+ const iv = randomBytes(IV_BYTES);
39
+ const cipher = createCipheriv('aes-256-gcm', this.key, iv);
40
+ const body = Buffer.concat([cipher.update(plain), cipher.final()]);
41
+ return { metadata: { [ENCODING_KEY]: utf8(ENCRYPTED_ENCODING) }, data: Buffer.concat([iv, cipher.getAuthTag(), body]) };
42
+ }
43
+
44
+ private decrypt(payload: Payload): Payload {
45
+ const data = Buffer.from(payload.data ?? new Uint8Array());
46
+ if (data.length < IV_BYTES + TAG_BYTES) throw new Error('encrypted payload is too short');
47
+ const decipher = createDecipheriv('aes-256-gcm', this.key, data.subarray(0, IV_BYTES));
48
+ decipher.setAuthTag(data.subarray(IV_BYTES, IV_BYTES + TAG_BYTES));
49
+ let plain: Buffer;
50
+ try {
51
+ plain = Buffer.concat([decipher.update(data.subarray(IV_BYTES + TAG_BYTES)), decipher.final()]);
52
+ } catch {
53
+ throw new Error('encrypted payload could not be decrypted: the other side holds a different TEMPORAL_PAYLOAD_KEY');
54
+ }
55
+ return fromSerializable(JSON.parse(plain.toString('utf8')) as SerializablePayload);
56
+ }
57
+ }
58
+
59
+ /** The data converter both the platform and the executors run with when a key is set: every payload through the codec. */
60
+ export function dataConverterFor(payloadKey: string | undefined): DataConverter | undefined {
61
+ return payloadKey?.trim() ? { payloadCodecs: [new EncryptingPayloadCodec(payloadKey)] } : undefined;
62
+ }
63
+
64
+ export function isEncrypted(payload: Payload): boolean {
65
+ const encoding = payload.metadata?.[ENCODING_KEY];
66
+ return encoding !== undefined && encoding !== null && Buffer.from(encoding).toString('utf8') === ENCRYPTED_ENCODING;
67
+ }
68
+
69
+ interface SerializablePayload {
70
+ metadata: Record<string, string>;
71
+ data: string | null;
72
+ }
73
+
74
+ function serializable(payload: Payload): SerializablePayload {
75
+ const metadata: Record<string, string> = {};
76
+ for (const [k, v] of Object.entries(payload.metadata ?? {})) if (v) metadata[k] = Buffer.from(v).toString('base64');
77
+ return { metadata, data: payload.data ? Buffer.from(payload.data).toString('base64') : null };
78
+ }
79
+
80
+ function fromSerializable(s: SerializablePayload): Payload {
81
+ const metadata: Record<string, Uint8Array> = {};
82
+ for (const [k, v] of Object.entries(s.metadata)) metadata[k] = Buffer.from(v, 'base64');
83
+ return { metadata, data: s.data === null ? null : Buffer.from(s.data, 'base64') };
84
+ }
@@ -0,0 +1,106 @@
1
+ import type { RunEvent } from '../run/contract.js';
2
+
3
+ /**
4
+ * Renders a run's events as a readable markdown transcript. Every access is
5
+ * defensive: the events were stored by an earlier version of this library or
6
+ * redacted on the way in, so an unexpected shape is skipped, never thrown on.
7
+ */
8
+
9
+ const TOOL_INPUT_CAP = 4_000;
10
+ const TOOL_RESULT_CAP = 20_000;
11
+
12
+ export interface TranscriptMeta {
13
+ /** The run's title: a work item's path, a workflow name, a step. */
14
+ title: string;
15
+ step?: string;
16
+ attempt?: number;
17
+ startedAt?: string;
18
+ }
19
+
20
+ function capped(text: string, max: number): string {
21
+ if (text.length <= max) return text;
22
+ return `${text.slice(0, max)}\n…truncated (${text.length} chars total)`;
23
+ }
24
+
25
+ function safeJson(v: unknown): string {
26
+ try {
27
+ return JSON.stringify(v, null, 2) ?? String(v);
28
+ } catch {
29
+ return String(v);
30
+ }
31
+ }
32
+
33
+ /** The text of a tool output: a string, MCP-style content blocks, or anything else as JSON. */
34
+ function outputText(output: unknown): string {
35
+ if (typeof output === 'string') return output;
36
+ if (output && typeof output === 'object' && Array.isArray((output as { content?: unknown }).content)) {
37
+ const blocks = (output as { content: unknown[] }).content;
38
+ const texts = blocks
39
+ .map((b) => (b && typeof b === 'object' && (b as { type?: unknown }).type === 'text' ? String((b as { text?: unknown }).text ?? '') : ''))
40
+ .filter(Boolean);
41
+ if (texts.length > 0) return texts.join('\n');
42
+ }
43
+ return output == null ? '' : safeJson(output);
44
+ }
45
+
46
+ export function renderTranscript(events: readonly RunEvent[], meta: TranscriptMeta): string {
47
+ const heading = meta.step ? `${meta.step}${meta.attempt ? ` (attempt ${meta.attempt})` : ''}` : meta.title;
48
+ const lines: string[] = [`# Transcript — ${heading}`, ''];
49
+
50
+ const started = events.find((e) => e.type === 'session_started');
51
+ const headerBits = [`Run: \`${meta.title}\``];
52
+ if (started && started.type === 'session_started') {
53
+ headerBits.push(`Harness: \`${started.harness}\``);
54
+ if (started.model) headerBits.push(`Model: \`${started.model}\``);
55
+ if (started.sessionId) headerBits.push(`Session: \`${started.sessionId}\``);
56
+ }
57
+ if (meta.startedAt) headerBits.push(`Started: ${meta.startedAt}`);
58
+ lines.push(headerBits.join(' · '), '');
59
+
60
+ for (const ev of events) {
61
+ try {
62
+ switch (ev.type) {
63
+ case 'user':
64
+ lines.push(`**User:** ${ev.text}`, '');
65
+ break;
66
+ case 'assistant':
67
+ if (ev.text.trim()) lines.push(ev.text, '');
68
+ break;
69
+ case 'reasoning':
70
+ if (ev.text.trim()) lines.push(...ev.text.split('\n').map((l) => `> ${l}`), '');
71
+ break;
72
+ case 'tool_call':
73
+ lines.push(`**→ ${ev.name}**`, '```json', capped(safeJson(ev.input), TOOL_INPUT_CAP), '```', '');
74
+ break;
75
+ case 'tool_result':
76
+ lines.push(ev.isError ? '```text (error)' : '```', capped(outputText(ev.output), TOOL_RESULT_CAP), '```', '');
77
+ break;
78
+ break;
79
+ case 'verdict':
80
+ lines.push(`**Verdict:** ${ev.verdict}`, '');
81
+ break;
82
+ case 'outputs':
83
+ lines.push('**Outputs:**', '```json', capped(safeJson(ev.values), TOOL_INPUT_CAP), '```', '');
84
+ break;
85
+ case 'error':
86
+ lines.push(`**Error:** ${ev.message}`, '');
87
+ break;
88
+ case 'session_ended': {
89
+ lines.push('---', '');
90
+ const bits: string[] = [ev.ok ? 'ended ok' : 'ended with failure'];
91
+ if (ev.usage && (ev.usage.input !== undefined || ev.usage.output !== undefined)) {
92
+ bits.push(`tokens ${ev.usage.input ?? '?'} in / ${ev.usage.output ?? '?'} out`);
93
+ }
94
+ lines.push(bits.join(' · '), '');
95
+ break;
96
+ }
97
+ default:
98
+ break;
99
+ }
100
+ } catch {
101
+ continue;
102
+ }
103
+ }
104
+
105
+ return lines.join('\n').trimEnd() + '\n';
106
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * The parsed shape of a `.workflow` and the pure helpers over it. This file
3
+ * has NO imports: it is loaded inside Temporal's workflow sandbox by the
4
+ * interpreter, where `node:*` and third-party modules are not available. The
5
+ * parser that produces these values lives in `workflow-file.ts`.
6
+ */
7
+
8
+ /** How a step executes. `tool_chain` runs a tool chain as the actor, without a model. */
9
+ export type StepKind = 'agent' | 'tool_chain' | 'wait' | 'choice';
10
+
11
+ /** Terminal routing targets, reserved and unavailable as step names. */
12
+ export const TERMINAL_TARGETS = ['done', 'failed', 'blocked'] as const;
13
+ export type TerminalTarget = (typeof TERMINAL_TARGETS)[number];
14
+
15
+ /** The error vocabulary: a closed set the file can match on, plus `ALL` as the wildcard matched last. */
16
+ export const ERROR_NAMES = ['Fail', 'Blocked', 'Timeout', 'HarnessError', 'NoVerdict', 'ProbeError', 'ALL'] as const;
17
+ export type ErrorName = (typeof ERROR_NAMES)[number];
18
+
19
+ /**
20
+ * Where a run executes, one string in the file: `none` (in the platform
21
+ * process), an image reference, or the path of a Dockerfile in the knowledge
22
+ * base (built on the executor).
23
+ */
24
+ export type Environment = { kind: 'none' } | { kind: 'image'; ref: string } | { kind: 'dockerfile'; path: string };
25
+
26
+ /** Retriers re-run a step IN PLACE. Matched before any catcher. */
27
+ export interface RetryRule {
28
+ errors: ErrorName[];
29
+ max: number;
30
+ backoff?: number;
31
+ }
32
+
33
+ /** Catchers ROUTE somewhere else. Matched only once retries are exhausted. */
34
+ export interface CatchRule {
35
+ errors: ErrorName[];
36
+ then: string;
37
+ max?: number;
38
+ onExhausted?: string;
39
+ }
40
+
41
+ export interface SystemPromptSpec {
42
+ skill?: string;
43
+ extend?: string;
44
+ }
45
+
46
+ export interface Comparator {
47
+ op: 'stringEquals' | 'numericEquals' | 'numericGreaterThan' | 'numericLessThan' | 'isPresent';
48
+ state: string;
49
+ value?: unknown;
50
+ }
51
+
52
+ export interface ChoiceRule {
53
+ when: Comparator;
54
+ then: string;
55
+ }
56
+
57
+ export interface EntryBudget {
58
+ max: number;
59
+ then: string;
60
+ }
61
+
62
+ export interface StepDef {
63
+ name: string;
64
+ /** 1-based position in file order. */
65
+ index: number;
66
+ /** Entered only via another step's routing; has no implicit successor. */
67
+ offPath: boolean;
68
+ kind: StepKind;
69
+ /** agent steps: repo-relative path of the `.agent` to run as; absent means the deployment's default configuration. */
70
+ agent?: string;
71
+ systemPrompt?: SystemPromptSpec;
72
+ /** agent and wait steps: the prompt; tool_chain steps: the chain's source. */
73
+ instructions: string;
74
+ verdict?: string;
75
+ /** agent and tool_chain steps: `continue`, a terminal target, or an outcome→target map. */
76
+ then?: string | Record<string, string>;
77
+ /** agent steps: the STATE keys this step is expected to write in OUTPUTS. */
78
+ outputs?: string[];
79
+ /** wait steps: the probe's legal outcomes → routing target. */
80
+ outcomes?: Record<string, string>;
81
+ /** wait steps: a tool chain that answers one of `outcomes` or `wait`; absent means the gate waits for a signal. */
82
+ probe?: string;
83
+ choices?: ChoiceRule[];
84
+ default?: string;
85
+ retry?: RetryRule[];
86
+ catch?: CatchRule[];
87
+ entries?: EntryBudget;
88
+ }
89
+
90
+ export type ParamSource = { kind: 'ticket'; field: string } | { kind: 'follow'; link: string; field: string };
91
+
92
+ export interface RequiredParam {
93
+ name: string;
94
+ from?: { link: string; field: string };
95
+ }
96
+
97
+ export interface ParametersDef {
98
+ sources: ParamSource[];
99
+ require: RequiredParam[];
100
+ optional: string[];
101
+ }
102
+
103
+ /**
104
+ * A personal executor's name, as a run's start or its trigger names it: a
105
+ * person runs it on their own machine (behind their VPN, say) and only
106
+ * their own runs reach it.
107
+ */
108
+ export const PERSONAL_EXECUTOR_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
109
+
110
+ export interface WorkflowDefinition {
111
+ id: string;
112
+ name: string;
113
+ description?: string;
114
+ environment: Environment;
115
+ /** True when the file was the short form (`prompt:`); the one step is then named `Run`. */
116
+ shortForm: boolean;
117
+ parameters: ParametersDef;
118
+ /** STATE keys the interpreter owns; a step's outputs may never write them. */
119
+ reservedStateKeys: string[];
120
+ defaults: { retry: RetryRule[]; catch: CatchRule[] };
121
+ steps: StepDef[];
122
+ /** Step names in execution order, off-path steps excluded. */
123
+ happyPath: string[];
124
+ /**
125
+ * Content hash of the steps and their routing. A run records it at start; a
126
+ * mismatch at the next step blocks rather than silently restarting.
127
+ */
128
+ version: string;
129
+ }
130
+
131
+ export function stepByName(def: WorkflowDefinition, name: string): StepDef | undefined {
132
+ return def.steps.find((s) => s.name === name);
133
+ }
134
+
135
+ /** Next happy-path step after `name`; null when `name` is the last one. */
136
+ export function nextAfter(def: WorkflowDefinition, name: string): string | null {
137
+ const i = def.happyPath.indexOf(name);
138
+ if (i === -1) throw new Error(`nextAfter: "${name}" is not on the happy path`);
139
+ return def.happyPath[i + 1] ?? null;
140
+ }
141
+
142
+ export function isTerminalTarget(target: string): target is TerminalTarget {
143
+ return (TERMINAL_TARGETS as readonly string[]).includes(target);
144
+ }
145
+
146
+ /** Step name → slug, used for statuses and transcript names. */
147
+ export function stepSlug(name: string): string {
148
+ return name.toLowerCase().trim().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
149
+ }
150
+
151
+ /** One comparison of a `choice` step against the run's STATE. */
152
+ export function evaluate(cmp: Comparator, state: Record<string, unknown>): boolean {
153
+ const value = state[cmp.state];
154
+ switch (cmp.op) {
155
+ case 'isPresent':
156
+ return value !== undefined && value !== null && value !== '';
157
+ case 'stringEquals':
158
+ return String(value) === String(cmp.value);
159
+ case 'numericEquals':
160
+ return Number(value) === Number(cmp.value);
161
+ case 'numericGreaterThan':
162
+ return Number(value) > Number(cmp.value);
163
+ case 'numericLessThan':
164
+ return Number(value) < Number(cmp.value);
165
+ }
166
+ }
167
+
168
+ /**
169
+ * A step's OUTPUTS with the interpreter's own keys removed. A step cannot move
170
+ * its run, reset its budget or drop its claim by writing `step` or `attempts`
171
+ * into its outputs, by accident or otherwise.
172
+ */
173
+ export function stripReservedKeys(
174
+ outputs: Record<string, unknown>,
175
+ reserved: readonly string[],
176
+ onDropped?: (key: string) => void,
177
+ ): Record<string, unknown> {
178
+ const out: Record<string, unknown> = {};
179
+ for (const [key, value] of Object.entries(outputs)) {
180
+ if (reserved.includes(key)) onDropped?.(key);
181
+ else out[key] = value;
182
+ }
183
+ return out;
184
+ }
185
+
186
+ /**
187
+ * Substitute `${name}` references in step text with resolved parameter and
188
+ * state values. An unresolved reference is LEFT AS WRITTEN: a session reading
189
+ * `${featurePrUrl}` can say the value is missing, an empty string cannot.
190
+ */
191
+ export function interpolate(text: string, values: Record<string, unknown>): string {
192
+ return text.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (whole, name: string) => {
193
+ const value = values[name];
194
+ return value === undefined || value === null || value === '' ? whole : String(value);
195
+ });
196
+ }
@@ -0,0 +1,100 @@
1
+ import type { CatchRule, ErrorName, RetryRule, StepDef, WorkflowDefinition } from './workflow-file.js';
2
+ import type { Verdict } from '../run/contract.js';
3
+
4
+ /**
5
+ * Typed failure handling, on Amazon States Language's model.
6
+ *
7
+ * 1. **Retriers run IN PLACE, catchers ROUTE.** Retriers are matched first, in
8
+ * array order, first match wins; only when they are exhausted (or none
9
+ * matched) do catchers run, also in order.
10
+ * 2. **Counters count per CATCHER TARGET, per work item.** "This item has been
11
+ * sent back to Coding N times" is the question worth capping.
12
+ *
13
+ * Per-step rules override the workflow defaults entirely rather than merging:
14
+ * a step that declares its own policy means the default did not fit.
15
+ */
16
+
17
+ /** STATE key holding the per-target loop counters. */
18
+ export const ATTEMPTS_KEY = 'attempts';
19
+ /** STATE key holding the in-place retry count for the current step. */
20
+ export const RETRIES_KEY = 'retries';
21
+ /** STATE key holding how many times the item has entered each budgeted step. */
22
+ export const ENTRIES_KEY = 'entries';
23
+
24
+ export interface FailureContext {
25
+ step: StepDef;
26
+ error: ErrorName;
27
+ /** Per-target loop counters from STATE. */
28
+ attempts: Record<string, number>;
29
+ /** In-place retry counts, keyed by step AND retrier (see `retryKey`). */
30
+ retries: Record<string, number>;
31
+ }
32
+
33
+ /** The counter a retrier spends, one per step per retrier, named after the errors the rule lists. */
34
+ export function retryKey(stepName: string, rule: RetryRule): string {
35
+ return `${stepName} · ${rule.errors.join('+')}`;
36
+ }
37
+
38
+ /** Every retry counter that belongs to `stepName`, under either spelling. */
39
+ export function isRetryKeyOf(stepName: string, key: string): boolean {
40
+ return key === stepName || key.startsWith(`${stepName} · `);
41
+ }
42
+
43
+ export type FailureDecision =
44
+ /** Re-run the same step. `delayMs` is 0 when the rule declares no backoff. */
45
+ | { action: 'retry'; delayMs: number; attempt: number; max: number; counterKey: string }
46
+ /** Route to another step or a terminal target. */
47
+ | { action: 'route'; target: string; counterKey?: string; count?: number; max?: number }
48
+ /** Nothing matched — the workflow declared no handling for this error. */
49
+ | { action: 'unhandled' };
50
+
51
+ function matches(errors: ErrorName[], error: ErrorName): boolean {
52
+ return errors.includes(error) || errors.includes('ALL');
53
+ }
54
+
55
+ /** A step's own policy replaces the defaults when the step writes one — an empty one included: `retry: []` means no retries, not the defaults'. */
56
+ function rulesFor(def: WorkflowDefinition, step: StepDef): { retry: RetryRule[]; catch: CatchRule[] } {
57
+ return {
58
+ retry: step.retry ?? def.defaults.retry,
59
+ catch: step.catch ?? def.defaults.catch,
60
+ };
61
+ }
62
+
63
+ export function decide(def: WorkflowDefinition, ctx: FailureContext): FailureDecision {
64
+ const rules = rulesFor(def, ctx.step);
65
+
66
+ for (const rule of rules.retry) {
67
+ if (!matches(rule.errors, ctx.error)) continue;
68
+ const counterKey = retryKey(ctx.step.name, rule);
69
+ const used = ctx.retries[counterKey] ?? 0;
70
+ if (used < rule.max) {
71
+ const delayMs = rule.backoff ? Math.round(1000 * Math.pow(rule.backoff, used)) : 0;
72
+ return { action: 'retry', delayMs, attempt: used + 1, max: rule.max, counterKey };
73
+ }
74
+ // First match wins, and it is spent: fall through to the catchers.
75
+ break;
76
+ }
77
+
78
+ for (const rule of rules.catch) {
79
+ if (!matches(rule.errors, ctx.error)) continue;
80
+ if (rule.max === undefined) return { action: 'route', target: rule.then };
81
+ const count = (ctx.attempts[rule.then] ?? 0) + 1;
82
+ if (count > rule.max) {
83
+ return { action: 'route', target: rule.onExhausted ?? 'failed', counterKey: rule.then, count, max: rule.max };
84
+ }
85
+ return { action: 'route', target: rule.then, counterKey: rule.then, count, max: rule.max };
86
+ }
87
+
88
+ return { action: 'unhandled' };
89
+ }
90
+
91
+ /**
92
+ * Classify a session outcome into an error name. `BLOCKED` is the one an agent
93
+ * declares about itself: the cause is external, re-running cannot fix it, and
94
+ * no attempt is charged.
95
+ */
96
+ export function errorForVerdict(verdict: Verdict | undefined): ErrorName {
97
+ if (verdict === 'BLOCKED') return 'Blocked';
98
+ if (verdict === 'FAIL') return 'Fail';
99
+ return 'NoVerdict';
100
+ }
@@ -0,0 +1,175 @@
1
+ import type { ParametersDef, ParamSource } from './workflow-file.js';
2
+
3
+ /**
4
+ * Resolves a run's parameters from the graph, using only the four primitives
5
+ * the interpreter is allowed to know:
6
+ *
7
+ * 1. read a field of the work item,
8
+ * 2. follow a markdown link in one of its fields,
9
+ * 3. read a field of the node that lands on,
10
+ * 4. parse a field as a key/value list.
11
+ *
12
+ * Every NAME involved — `Project`, `Delivery Parameters`, `repoUrl` — comes
13
+ * from the `.workflow` file. Nothing here requires a node type, a field name
14
+ * or a graph shape.
15
+ */
16
+
17
+ export interface ResolvedParameters {
18
+ values: Record<string, string>;
19
+ /** Required parameters that nothing supplied. */
20
+ missing: string[];
21
+ }
22
+
23
+ export class ParameterResolutionError extends Error {}
24
+
25
+ /**
26
+ * The body of a `# Heading` section of a knowledge-base node: the work item
27
+ * reader's own, so a parameter read and a work item write agree on what a
28
+ * heading is (`# Project (link parent)` answers `Project`).
29
+ */
30
+ import { getSection } from '../workitem/node.js';
31
+
32
+ export { getSection };
33
+
34
+ /**
35
+ * Parse a field body as a key/value list: markdown bullets, `key: value` or
36
+ * `key = value`, backticks around either side, several pairs separated by `·`
37
+ * on one line. A line without a separator is prose and is skipped. The FIRST
38
+ * occurrence of a key wins.
39
+ */
40
+ export function parseKeyValueField(body: string): Record<string, string> {
41
+ const out: Record<string, string> = {};
42
+ for (const rawLine of body.split(/\r?\n/)) {
43
+ const line = rawLine.replace(/^\s*[-*+]\s*/, '').trim();
44
+ if (!line) continue;
45
+ for (const part of line.split('·')) {
46
+ const m = /^\s*`?([A-Za-z_][A-Za-z0-9_]*)`?\s*[:=]\s*(.+?)\s*$/.exec(part);
47
+ if (!m) continue;
48
+ const key = m[1];
49
+ const value = stripDecoration(m[2]);
50
+ if (value && !(key in out)) out[key] = value;
51
+ }
52
+ }
53
+ return out;
54
+ }
55
+
56
+ function stripDecoration(raw: string): string {
57
+ const v = raw.trim();
58
+ const fenced = /^`([^`]*)`/.exec(v);
59
+ if (fenced) return fenced[1].trim();
60
+ return v.replace(/^["']|["']$/g, '').trim();
61
+ }
62
+
63
+ /** The first markdown link target in a field body, or null. */
64
+ export function firstLinkTarget(body: string): string | null {
65
+ const m = /\[[^\]]*\]\(([^)]+)\)/.exec(body);
66
+ return m ? m[1].trim() : null;
67
+ }
68
+
69
+ /** The first URL in a field body — how a `Repository` field is read. */
70
+ export function firstUrl(body: string): string | null {
71
+ const m = /https?:\/\/\S+/.exec(body);
72
+ return m ? m[0].replace(/[.,;)]+$/, '') : null;
73
+ }
74
+
75
+ /** What the resolver needs to reach the graph. Narrow on purpose — easy to fake. */
76
+ export interface ParameterGraph {
77
+ itemField(field: string): string | null;
78
+ linkedField(link: string, field: string): Promise<string | null>;
79
+ }
80
+
81
+ /**
82
+ * The two reads the resolver makes of the knowledge base. The platform
83
+ * implements them over its own services. `readFile` answers null for a file
84
+ * that is not there or not readable; any other failure is the platform's
85
+ * and propagates, never read as "no such node".
86
+ */
87
+ export interface GraphReader {
88
+ readFile(path: string): Promise<string | null>;
89
+ /** Paths and line numbers of lines matching a regular expression. */
90
+ grep(pattern: string): Promise<Array<{ path: string; line: number }>>;
91
+ }
92
+
93
+ /**
94
+ * Build a graph reader for one work item. Link targets are resolved relative
95
+ * to the item's own path when they look like a path, and treated as node ids
96
+ * otherwise — both spellings appear in real items.
97
+ */
98
+ export function graphFor(kb: GraphReader, itemPath: string, itemBody: string): ParameterGraph {
99
+ const cache = new Map<string, string | null>();
100
+
101
+ const readLinked = async (link: string): Promise<string | null> => {
102
+ if (cache.has(link)) return cache.get(link) ?? null;
103
+ const body = getSection(itemBody, link);
104
+ const target = body ? firstLinkTarget(body) : null;
105
+ let content: string | null = null;
106
+ if (target) {
107
+ const resolved = target.includes('/') || target.endsWith('.md') ? resolvePath(itemPath, target) : null;
108
+ if (resolved) {
109
+ content = await kb.readFile(resolved);
110
+ } else {
111
+ const matches = await kb.grep(`^id:\\s*${escapeRe(target)}\\s*$`);
112
+ const hit = matches.find((m) => m.line <= 6);
113
+ content = hit ? await kb.readFile(hit.path) : null;
114
+ }
115
+ }
116
+ cache.set(link, content);
117
+ return content;
118
+ };
119
+
120
+ return {
121
+ itemField: (field) => getSection(itemBody, field),
122
+ async linkedField(link, field) {
123
+ const content = await readLinked(link);
124
+ return content ? getSection(content, field) : null;
125
+ },
126
+ };
127
+ }
128
+
129
+ function escapeRe(s: string): string {
130
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
131
+ }
132
+
133
+ /** Resolve `target` relative to the directory of `fromPath`, normalising `..`. */
134
+ function resolvePath(fromPath: string, target: string): string {
135
+ if (!target.startsWith('.')) return target;
136
+ const segments = fromPath.split('/').slice(0, -1);
137
+ for (const part of target.split('/')) {
138
+ if (part === '.' || part === '') continue;
139
+ if (part === '..') segments.pop();
140
+ else segments.push(part);
141
+ }
142
+ return segments.join('/');
143
+ }
144
+
145
+ async function pairsFrom(graph: ParameterGraph, source: ParamSource): Promise<Record<string, string>> {
146
+ const body = source.kind === 'ticket' ? graph.itemField(source.field) : await graph.linkedField(source.link, source.field);
147
+ return body ? parseKeyValueField(body) : {};
148
+ }
149
+
150
+ /**
151
+ * Resolve the workflow's declared parameters for one work item. Sources are
152
+ * tried in order and the FIRST hit wins; a required parameter nothing supplies
153
+ * is reported by name rather than defaulted.
154
+ */
155
+ export async function resolveParameters(def: ParametersDef, graph: ParameterGraph): Promise<ResolvedParameters> {
156
+ const values: Record<string, string> = {};
157
+ for (const source of def.sources) {
158
+ for (const [key, value] of Object.entries(await pairsFrom(graph, source))) {
159
+ if (!(key in values)) values[key] = value;
160
+ }
161
+ }
162
+ for (const param of def.require) {
163
+ if (param.name in values || !param.from) continue;
164
+ const body = await graph.linkedField(param.from.link, param.from.field);
165
+ if (!body) continue;
166
+ const value = firstUrl(body) ?? body.split(/\r?\n/).map((l) => l.trim()).find(Boolean);
167
+ if (value) values[param.name] = value;
168
+ }
169
+ const missing = def.require.map((p) => p.name).filter((name) => !values[name]);
170
+ return { values, missing };
171
+ }
172
+
173
+ // `interpolate` lives in definition.ts (the sandbox-safe module); re-exported
174
+ // here because it belongs to the parameters story for a reader.
175
+ export { interpolate } from './definition.js';