@bevel-software/ael 0.0.0-stage → 0.2.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 +81 -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 +230 -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,457 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { optionalStr, plainObject, yamlDocumentOf } from '../shared/front-matter.js';
3
+
4
+ /**
5
+ * Parses a `.workflow` file into the definition the interpreter runs.
6
+ *
7
+ * One `---` fenced YAML document with the access verbs inside it, prose after
8
+ * the fence. THE INTERPRETER ENCODES NO STEP BEHAVIOUR: everything a step does
9
+ * comes from this file. Nor does the file say WHO runs it or WHEN: the actor
10
+ * and the triggers are rows the platform keeps, and a comment in the file may
11
+ * recommend how to set them up. What used to be the pipeline's `queue:` block
12
+ * is now the `kb` trigger's settings.
13
+ *
14
+ * Two forms. The short form is one `prompt:` (and an optional `agent:`), the
15
+ * whole of a reminder or a nightly summary; it parses as a one-step workflow.
16
+ * The full form lists `steps:` with routing, retries and catchers.
17
+ */
18
+
19
+ export const WORKFLOW_FILE_EXTENSION = ".workflow";
20
+
21
+ // The parsed shape and its pure helpers live in definition.ts (no imports, so
22
+ // the interpreter can load them inside Temporal's workflow sandbox); this file
23
+ // only produces them. Re-exported so callers import one module.
24
+ export {
25
+ ERROR_NAMES,
26
+ PERSONAL_EXECUTOR_NAME,
27
+ TERMINAL_TARGETS,
28
+ evaluate,
29
+ interpolate,
30
+ isTerminalTarget,
31
+ nextAfter,
32
+ stepByName,
33
+ stepSlug,
34
+ stripReservedKeys,
35
+ } from './definition.js';
36
+ export type {
37
+ CatchRule,
38
+ ChoiceRule,
39
+ Comparator,
40
+ EntryBudget,
41
+ Environment,
42
+ ErrorName,
43
+ ParamSource,
44
+ ParametersDef,
45
+ RequiredParam,
46
+ RetryRule,
47
+ StepDef,
48
+ StepKind,
49
+ SystemPromptSpec,
50
+ TerminalTarget,
51
+ WorkflowDefinition,
52
+ } from './definition.js';
53
+ import {
54
+ ERROR_NAMES,
55
+ isTerminalTarget,
56
+ type CatchRule,
57
+ type Comparator,
58
+ type Environment,
59
+ type ErrorName,
60
+ type ParamSource,
61
+ type ParametersDef,
62
+ type RequiredParam,
63
+ type RetryRule,
64
+ type StepDef,
65
+ type StepKind,
66
+ type SystemPromptSpec,
67
+ type WorkflowDefinition,
68
+ } from './definition.js';
69
+
70
+ export class WorkflowParseError extends Error {}
71
+
72
+ /**
73
+ * Keys the design moved out of the file. Each is refused with the place it
74
+ * went, so an author porting a `.pipeline` learns where in one message.
75
+ */
76
+ const RETIRED_KEYS: Record<string, string> = {
77
+ queue: 'the `kb` trigger\'s settings (who runs it, which nodes, which fields)',
78
+ claimAs: 'the trigger\'s owner (the actor)',
79
+ actor: 'the trigger\'s owner (the actor)',
80
+ triggers: 'trigger rows on the Triggers screen',
81
+ trigger: 'trigger rows on the Triggers screen',
82
+ branch: 'the step prompt (say which branch to write to)',
83
+ targetNodeTypes: 'the step prompt',
84
+ image: '`environment: <image reference>`',
85
+ setup: 'a Dockerfile named by `environment:`',
86
+ services: 'a Dockerfile named by `environment:` (install the service there, start it in the step)',
87
+ limits: 'the executor\'s settings',
88
+ executor: 'the run\'s start or its trigger (`executor` on Run now, `schedule_workflow` or the Triggers screen), so one workflow runs on the deployment or on a person\'s own executor',
89
+ };
90
+
91
+ /**
92
+ * Read the `environment` string. `none` is the in-process run; a path whose
93
+ * last segment is `Dockerfile` (or ends in `.Dockerfile`) is built on the
94
+ * executor; anything else is an image reference.
95
+ */
96
+ export function environmentOf(raw: string | undefined): Environment {
97
+ const value = (raw ?? 'none').trim();
98
+ if (!value || value.toLowerCase() === 'none') return { kind: 'none' };
99
+ const last = value.split('/').pop() ?? value;
100
+ if (last === 'Dockerfile' || /\.Dockerfile$/i.test(last) || /^Dockerfile\./.test(last)) return { kind: 'dockerfile', path: value };
101
+ if (/\s/.test(value)) throw new WorkflowParseError(`\`environment\` "${value}" is neither \`none\`, an image reference nor a Dockerfile path`);
102
+ return { kind: 'image', ref: value };
103
+ }
104
+
105
+ // --- parsing ------------------------------------------------------------------
106
+
107
+ function obj(value: unknown, what: string): Record<string, unknown> {
108
+ return plainObject(value, what, (message) => new WorkflowParseError(message));
109
+ }
110
+
111
+ function str(value: unknown, what: string): string {
112
+ if (typeof value !== 'string' || !value.trim()) throw new WorkflowParseError(`${what} must be a non-empty string`);
113
+ return value.trim();
114
+ }
115
+
116
+ function strArray(value: unknown, what: string): string[] {
117
+ if (!Array.isArray(value) || !value.every((v) => typeof v === 'string')) {
118
+ throw new WorkflowParseError(`${what} must be an array of strings`);
119
+ }
120
+ return value.map((v) => (v as string).trim()).filter(Boolean);
121
+ }
122
+
123
+ function errorNames(value: unknown, what: string): ErrorName[] {
124
+ const names = strArray(value, `${what}.errors`);
125
+ for (const n of names) {
126
+ if (!(ERROR_NAMES as readonly string[]).includes(n)) {
127
+ throw new WorkflowParseError(`${what}.errors names unknown error "${n}" (known: ${ERROR_NAMES.join(', ')})`);
128
+ }
129
+ }
130
+ if (names.length === 0) throw new WorkflowParseError(`${what}.errors must name at least one error`);
131
+ return names as ErrorName[];
132
+ }
133
+
134
+ function parseRetry(value: unknown, what: string): RetryRule[] | undefined {
135
+ if (value === undefined) return undefined;
136
+ if (!Array.isArray(value)) throw new WorkflowParseError(`${what} must be an array`);
137
+ return value.map((raw, i) => {
138
+ const r = obj(raw, `${what}[${i}]`);
139
+ const max = typeof r.max === 'number' ? r.max : NaN;
140
+ if (!Number.isInteger(max) || max < 0) throw new WorkflowParseError(`${what}[${i}].max must be a non-negative integer`);
141
+ return { errors: errorNames(r.errors, `${what}[${i}]`), max, ...(typeof r.backoff === 'number' ? { backoff: r.backoff } : {}) };
142
+ });
143
+ }
144
+
145
+ function parseCatch(value: unknown, what: string): CatchRule[] | undefined {
146
+ if (value === undefined) return undefined;
147
+ if (!Array.isArray(value)) throw new WorkflowParseError(`${what} must be an array`);
148
+ return value.map((raw, i) => {
149
+ const c = obj(raw, `${what}[${i}]`);
150
+ const rule: CatchRule = { errors: errorNames(c.errors, `${what}[${i}]`), then: str(c.then, `${what}[${i}].then`) };
151
+ if (c.max !== undefined) {
152
+ if (!Number.isInteger(c.max) || (c.max as number) < 1) throw new WorkflowParseError(`${what}[${i}].max must be a positive integer`);
153
+ rule.max = c.max as number;
154
+ }
155
+ if (c.onExhausted !== undefined) rule.onExhausted = str(c.onExhausted, `${what}[${i}].onExhausted`);
156
+ // The pipeline's `reauthor` budget has no reader here: a file naming it would expect a limit nothing enforces.
157
+ if (c.reauthor !== undefined) throw new WorkflowParseError(`${what}[${i}].reauthor is not supported; \`max\` bounds how often a catcher fires`);
158
+ return rule;
159
+ });
160
+ }
161
+
162
+ function parseParameters(value: unknown): ParametersDef {
163
+ if (value === undefined) return { sources: [], require: [], optional: [] };
164
+ const p = obj(value, '`parameters`');
165
+ const rawSources = p.sources ?? [];
166
+ if (!Array.isArray(rawSources)) throw new WorkflowParseError('`parameters.sources` must be an array');
167
+ const sources: ParamSource[] = rawSources.map((raw, i) => {
168
+ const s = obj(raw, `\`parameters.sources[${i}]\``);
169
+ if (s.ticket !== undefined) return { kind: 'ticket', field: str(s.ticket, `sources[${i}].ticket`) };
170
+ if (s.follow !== undefined) return { kind: 'follow', link: str(s.follow, `sources[${i}].follow`), field: str(s.field, `sources[${i}].field`) };
171
+ throw new WorkflowParseError(`\`parameters.sources[${i}]\` needs either \`ticket:\` or \`follow:\``);
172
+ });
173
+ const rawRequire = p.require ?? [];
174
+ if (!Array.isArray(rawRequire)) throw new WorkflowParseError('`parameters.require` must be an array');
175
+ const require: RequiredParam[] = rawRequire.map((raw, i) => {
176
+ const r = obj(raw, `\`parameters.require[${i}]\``);
177
+ const param: RequiredParam = { name: str(r.name, `require[${i}].name`) };
178
+ if (r.from !== undefined) {
179
+ const f = obj(r.from, `\`parameters.require[${i}].from\``);
180
+ param.from = { link: str(f.follow, `require[${i}].from.follow`), field: str(f.field, `require[${i}].from.field`) };
181
+ }
182
+ return param;
183
+ });
184
+ if (require.length > 0 && sources.length === 0) {
185
+ throw new WorkflowParseError('`parameters.require` names parameters but `parameters.sources` says nowhere to read them from');
186
+ }
187
+ return { sources, require, optional: p.optional === undefined ? [] : strArray(p.optional, '`parameters.optional`') };
188
+ }
189
+
190
+ const COMPARATORS = ['stringEquals', 'numericEquals', 'numericGreaterThan', 'numericLessThan', 'isPresent'] as const;
191
+
192
+ function parseComparator(value: unknown, what: string): Comparator {
193
+ const c = obj(value, what);
194
+ const keys = Object.keys(c);
195
+ if (keys.length !== 1) throw new WorkflowParseError(`${what} must name exactly one comparator (${COMPARATORS.join(', ')})`);
196
+ const op = keys[0];
197
+ if (!(COMPARATORS as readonly string[]).includes(op)) {
198
+ throw new WorkflowParseError(`${what} names unknown comparator "${op}" (known: ${COMPARATORS.join(', ')})`);
199
+ }
200
+ const body = obj(c[op], `${what}.${op}`);
201
+ return { op: op as Comparator['op'], state: str(body.state, `${what}.${op}.state`), ...(body.value !== undefined ? { value: body.value } : {}) };
202
+ }
203
+
204
+ function parseThen(value: unknown, name: string): string | Record<string, string> | undefined {
205
+ if (value === undefined) return undefined;
206
+ if (typeof value === 'string') return value.trim();
207
+ const t = obj(value, `step "${name}" \`then\``);
208
+ const map: Record<string, string> = {};
209
+ for (const [token, target] of Object.entries(t)) map[token] = str(target, `step "${name}" \`then.${token}\``);
210
+ return map;
211
+ }
212
+
213
+ function parseSystemPrompt(value: unknown, name: string): SystemPromptSpec | undefined {
214
+ if (value === undefined) return undefined;
215
+ const sp = obj(value, `step "${name}" \`systemPrompt\``);
216
+ const skill = optionalStr(sp.skill);
217
+ const extend = optionalStr(sp.extend);
218
+ return { ...(skill ? { skill } : {}), ...(extend ? { extend } : {}) };
219
+ }
220
+
221
+ function parseSteps(value: unknown, reservedStateKeys: string[]): StepDef[] {
222
+ if (!Array.isArray(value) || value.length === 0) throw new WorkflowParseError('`steps` must be a non-empty array');
223
+ return value.map((raw, i) => {
224
+ const s = obj(raw, `\`steps[${i}]\``);
225
+ const name = str(s.name, `\`steps[${i}].name\``);
226
+ const kindRaw = optionalStr(s.kind) ?? 'agent';
227
+ if (kindRaw !== 'agent' && kindRaw !== 'tool_chain' && kindRaw !== 'wait' && kindRaw !== 'choice') {
228
+ throw new WorkflowParseError(`step "${name}" has unknown kind "${kindRaw}" (expected agent | tool_chain | wait | choice)`);
229
+ }
230
+ const kind: StepKind = kindRaw;
231
+ for (const key of ['claimAs', 'actor', 'branch']) {
232
+ if (s[key] !== undefined) throw new WorkflowParseError(`step "${name}" sets \`${key}\`, which now lives in ${RETIRED_KEYS[key]}`);
233
+ }
234
+
235
+ const instructionsKey = kind === 'tool_chain' ? 'chain' : 'instructions';
236
+ const instructions =
237
+ kind === 'choice'
238
+ ? (optionalStr(s.instructions) ?? '')
239
+ : str(s[instructionsKey] ?? (kind === 'agent' ? s.prompt : undefined), `step "${name}" \`${instructionsKey}\``);
240
+
241
+ const step: StepDef = {
242
+ name,
243
+ index: i + 1,
244
+ offPath: s.offPath === true,
245
+ kind,
246
+ instructions,
247
+ retry: parseRetry(s.retry, `step "${name}" \`retry\``),
248
+ catch: parseCatch(s.catch, `step "${name}" \`catch\``),
249
+ };
250
+
251
+ if (s.entries !== undefined) {
252
+ const e = obj(s.entries, `step "${name}" \`entries\``);
253
+ if (typeof e.max !== 'number' || !Number.isInteger(e.max) || e.max < 1) {
254
+ throw new WorkflowParseError(`step "${name}" \`entries.max\` must be a positive integer`);
255
+ }
256
+ step.entries = { max: e.max, then: optionalStr(e.then) ?? 'blocked' };
257
+ }
258
+
259
+ if (kind === 'agent') {
260
+ // The configuration is optional now: absent means the deployment's
261
+ // default (`Agents/default.agent`), resolved by the platform at run time.
262
+ const agent = optionalStr(s.agent);
263
+ if (agent) step.agent = agent;
264
+ step.systemPrompt = parseSystemPrompt(s.systemPrompt, name);
265
+ if (!step.systemPrompt) delete step.systemPrompt;
266
+ step.verdict = optionalStr(s.verdict);
267
+ if (s.outputs !== undefined) {
268
+ const outputs = strArray(s.outputs, `agent step "${name}" \`outputs\``);
269
+ const reserved = outputs.filter((k) => reservedStateKeys.includes(k));
270
+ if (reserved.length > 0) {
271
+ throw new WorkflowParseError(`agent step "${name}" declares reserved STATE key(s) in \`outputs\`: ${reserved.join(', ')}`);
272
+ }
273
+ step.outputs = outputs;
274
+ }
275
+ step.then = parseThen(s.then, name);
276
+ } else if (kind === 'tool_chain') {
277
+ step.then = parseThen(s.then, name);
278
+ } else if (kind === 'wait') {
279
+ const outcomes = obj(s.outcomes, `wait step "${name}" \`outcomes\``);
280
+ const map: Record<string, string> = {};
281
+ for (const [token, target] of Object.entries(outcomes)) {
282
+ if (token === 'wait') {
283
+ // `wait` is the probe's way of saying "nothing yet". Declaring it as
284
+ // an outcome would make "keep waiting" routable.
285
+ throw new WorkflowParseError(`wait step "${name}" may not declare an outcome named "wait" — it is reserved`);
286
+ }
287
+ map[token] = str(target, `step "${name}" \`outcomes.${token}\``);
288
+ }
289
+ if (Object.keys(map).length === 0) throw new WorkflowParseError(`wait step "${name}" must declare at least one outcome`);
290
+ step.outcomes = map;
291
+ step.probe = optionalStr(s.probe);
292
+ } else {
293
+ const rawChoices = s.choices;
294
+ if (!Array.isArray(rawChoices) || rawChoices.length === 0) {
295
+ throw new WorkflowParseError(`choice step "${name}" must declare a non-empty \`choices\` array`);
296
+ }
297
+ step.choices = rawChoices.map((c, j) => {
298
+ const rule = obj(c, `step "${name}" \`choices[${j}]\``);
299
+ return { when: parseComparator(rule.when, `step "${name}" \`choices[${j}].when\``), then: str(rule.then, `step "${name}" \`choices[${j}].then\``) };
300
+ });
301
+ step.default = optionalStr(s.default);
302
+ }
303
+
304
+ if (step.then === undefined) delete step.then;
305
+ if (step.verdict === undefined) delete step.verdict;
306
+ if (step.probe === undefined) delete step.probe;
307
+ if (step.default === undefined) delete step.default;
308
+ return step;
309
+ });
310
+ }
311
+
312
+ /**
313
+ * The version a run records, and the structure it is checked against before
314
+ * every step: every place the
315
+ * file can send a run — the step order and kinds, every `then`, outcome,
316
+ * choice and default, every catcher's target and `onExhausted`, every entry
317
+ * budget's target — and nothing of the prose, since editing prose is the
318
+ * ordinary way to improve a workflow.
319
+ */
320
+ function versionOf(steps: StepDef[], defaults: WorkflowDefinition['defaults']): string {
321
+ const catchers = (rules: CatchRule[] | undefined) => rules?.map((c) => ({ errors: c.errors, then: c.then, onExhausted: c.onExhausted ?? null, max: c.max ?? null })) ?? null;
322
+ const shape = {
323
+ defaults: { catch: catchers(defaults.catch) },
324
+ steps: steps.map((s) => ({
325
+ name: s.name,
326
+ kind: s.kind,
327
+ offPath: s.offPath,
328
+ then: s.then ?? null,
329
+ outcomes: s.outcomes ?? null,
330
+ choices: s.choices?.map((c) => c.then) ?? null,
331
+ default: s.default ?? null,
332
+ catch: catchers(s.catch),
333
+ entries: s.entries ? { max: s.entries.max, then: s.entries.then } : null,
334
+ })),
335
+ };
336
+ return createHash('sha256').update(JSON.stringify(shape)).digest('hex').slice(0, 12);
337
+ }
338
+
339
+ function idFromPath(path: string | undefined): string | undefined {
340
+ if (!path) return undefined;
341
+ const file = path.slice(path.lastIndexOf('/') + 1);
342
+ const base = file.endsWith(WORKFLOW_FILE_EXTENSION) ? file.slice(0, -WORKFLOW_FILE_EXTENSION.length) : file;
343
+ const slug = base.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
344
+ return slug || undefined;
345
+ }
346
+
347
+ /**
348
+ * Parse a `.workflow` file. `path`, when given, supplies the fallback id and
349
+ * name and appears in error messages.
350
+ */
351
+ export function parseWorkflowFile(content: string, path?: string): WorkflowDefinition {
352
+ const where = path ? `\`.workflow\` ${path}` : '`.workflow`';
353
+ let document: unknown;
354
+ try {
355
+ document = yamlDocumentOf(content);
356
+ } catch (err) {
357
+ throw new WorkflowParseError(`${where} is not valid YAML: ${err instanceof Error ? err.message : String(err)}`);
358
+ }
359
+ const root = obj(document, where);
360
+
361
+ for (const [key, movedTo] of Object.entries(RETIRED_KEYS)) {
362
+ if (root[key] !== undefined) throw new WorkflowParseError(`${where} sets \`${key}\`, which now lives in ${movedTo}`);
363
+ }
364
+ if (root.do !== undefined && root.steps !== undefined) throw new WorkflowParseError(`${where} has both \`do\` and \`steps\`; keep \`steps\``);
365
+ if (root.prompt !== undefined && (root.steps !== undefined || root.do !== undefined)) {
366
+ throw new WorkflowParseError(`${where} has both \`prompt\` (the short form) and \`steps\`; keep one`);
367
+ }
368
+
369
+ const id = optionalStr(root.id) ?? idFromPath(path);
370
+ if (!id) throw new WorkflowParseError(`${where} needs an \`id\` (or a file name to derive one from)`);
371
+ const name = optionalStr(root.name) ?? id;
372
+ const description = optionalStr(root.description);
373
+ const environment = environmentOf(optionalStr(root.environment));
374
+ const parameters = parseParameters(root.parameters);
375
+ const reservedStateKeys = root.state === undefined ? [] : strArray(obj(root.state, '`state`').reserved, '`state.reserved`');
376
+
377
+ const shortForm = root.prompt !== undefined;
378
+ const rawSteps = shortForm
379
+ ? [
380
+ {
381
+ name: 'Run',
382
+ kind: 'agent',
383
+ ...(root.agent !== undefined ? { agent: root.agent } : {}),
384
+ instructions: str(root.prompt, `${where} \`prompt\``),
385
+ ...(root.verdict !== undefined ? { verdict: root.verdict } : {}),
386
+ ...(root.systemPrompt !== undefined ? { systemPrompt: root.systemPrompt } : {}),
387
+ then: 'done',
388
+ },
389
+ ]
390
+ : (root.steps ?? root.do);
391
+ if (!shortForm && root.agent !== undefined) {
392
+ throw new WorkflowParseError(`${where} sets a top-level \`agent\` without \`prompt\`; in the full form every agent step names its own`);
393
+ }
394
+ const steps = parseSteps(rawSteps, reservedStateKeys);
395
+
396
+ const rawDefaults = root.defaults === undefined ? {} : obj(root.defaults, '`defaults`');
397
+ const defaults = { retry: parseRetry(rawDefaults.retry, '`defaults.retry`') ?? [], catch: parseCatch(rawDefaults.catch, '`defaults.catch`') ?? [] };
398
+
399
+ const names = new Set<string>();
400
+ for (const s of steps) {
401
+ if (names.has(s.name)) throw new WorkflowParseError(`duplicate step name "${s.name}"`);
402
+ if (isTerminalTarget(s.name)) throw new WorkflowParseError(`step name "${s.name}" is a reserved terminal target`);
403
+ names.add(s.name);
404
+ }
405
+
406
+ // Every routing target must exist: a typo in a `then:` surfaces at load
407
+ // time, not on the one run that takes that branch. `continue` is the step
408
+ // after the one routing on the happy path, so an off-path step — which has
409
+ // no place on it — may not say it anywhere in its own routing.
410
+ const checkTarget = (target: string, at: string, from?: StepDef): void => {
411
+ if (target === 'continue') {
412
+ if (from?.offPath) throw new WorkflowParseError(`${at} routes to \`continue\`, which names no step off the happy path — name the step`);
413
+ return;
414
+ }
415
+ if (isTerminalTarget(target) || names.has(target)) return;
416
+ throw new WorkflowParseError(`${at} routes to unknown step "${target}"`);
417
+ };
418
+ for (const s of steps) {
419
+ if (typeof s.then === 'string') checkTarget(s.then, `step "${s.name}" \`then\``, s);
420
+ else if (s.then) for (const [t, target] of Object.entries(s.then)) checkTarget(target, `step "${s.name}" \`then.${t}\``, s);
421
+ for (const [t, target] of Object.entries(s.outcomes ?? {})) checkTarget(target, `step "${s.name}" \`outcomes.${t}\``, s);
422
+ for (const c of s.choices ?? []) checkTarget(c.then, `step "${s.name}" choice`, s);
423
+ if (s.default) checkTarget(s.default, `step "${s.name}" \`default\``, s);
424
+ for (const c of s.catch ?? []) {
425
+ checkTarget(c.then, `step "${s.name}" catcher`, s);
426
+ if (c.onExhausted) checkTarget(c.onExhausted, `step "${s.name}" catcher \`onExhausted\``, s);
427
+ }
428
+ if (s.entries) {
429
+ checkTarget(s.entries.then, `step "${s.name}" \`entries.then\``, s);
430
+ if (s.entries.then === s.name) throw new WorkflowParseError(`step "${s.name}" \`entries.then\` routes back into itself`);
431
+ }
432
+ if (s.offPath && (s.kind === 'agent' || s.kind === 'tool_chain') && s.then === undefined) {
433
+ throw new WorkflowParseError(`off-path step "${s.name}" must declare \`then\` — it has no implicit successor`);
434
+ }
435
+ }
436
+ for (const c of defaults.catch) {
437
+ checkTarget(c.then, '`defaults.catch`');
438
+ if (c.onExhausted) checkTarget(c.onExhausted, '`defaults.catch.onExhausted`');
439
+ }
440
+
441
+ const happyPath = steps.filter((s) => !s.offPath).map((s) => s.name);
442
+ if (happyPath.length === 0) throw new WorkflowParseError('every step is off-path — the happy path is empty');
443
+
444
+ return {
445
+ id,
446
+ name,
447
+ ...(description ? { description } : {}),
448
+ environment,
449
+ shortForm,
450
+ parameters,
451
+ reservedStateKeys,
452
+ defaults,
453
+ steps,
454
+ happyPath,
455
+ version: versionOf(steps, defaults),
456
+ };
457
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Reading and editing a work item: a knowledge-base node whose `# Heading`
3
+ * sections the interpreter keeps up to date (Status, Assignee, Log, Delivery
4
+ * Progress, STATE, Transcripts). Pure string functions, ported from the
5
+ * execution layer's board client; the platform's activities and an executor
6
+ * use them the same way.
7
+ */
8
+
9
+ export type WorkItemState = Record<string, unknown>;
10
+
11
+ export interface FieldNames {
12
+ statusField: string;
13
+ ownerField: string;
14
+ priorityField?: string;
15
+ }
16
+
17
+ export const DEFAULT_FIELDS: FieldNames = { statusField: 'Status', ownerField: 'Assignee' };
18
+
19
+ export interface ParsedWorkItem {
20
+ path: string;
21
+ name: string;
22
+ /** The status field's first word, lowercased, backticks removed. */
23
+ status: string;
24
+ assignee: string;
25
+ priority: string;
26
+ /** The earliest date in the Log, `YYYY-MM-DD`, or empty. */
27
+ filedAt: string;
28
+ state: WorkItemState;
29
+ raw: string;
30
+ }
31
+
32
+ const SECTION_RE = /^# (.+?)\s*$/gm;
33
+
34
+ /** `Project (link parent)` → `Project`. */
35
+ export function headingName(heading: string): string {
36
+ return heading.replace(/\s*\([^)]*\)\s*$/, '').trim();
37
+ }
38
+
39
+ function sectionBounds(markdown: string, header: string): { start: number; bodyStart: number; bodyEnd: number; heading: string } | null {
40
+ SECTION_RE.lastIndex = 0;
41
+ const wanted = headingName(header);
42
+ let start = -1;
43
+ let bodyStart = -1;
44
+ let heading = header;
45
+ for (let m = SECTION_RE.exec(markdown); m; m = SECTION_RE.exec(markdown)) {
46
+ if (start !== -1) return { start, bodyStart, bodyEnd: m.index, heading };
47
+ if (headingName(m[1]) === wanted) {
48
+ start = m.index;
49
+ bodyStart = m.index + m[0].length + 1;
50
+ heading = m[1].trim();
51
+ }
52
+ }
53
+ if (start === -1) return null;
54
+ return { start, bodyStart: Math.min(bodyStart, markdown.length), bodyEnd: markdown.length, heading };
55
+ }
56
+
57
+ export function getSection(markdown: string, header: string): string | null {
58
+ const bounds = sectionBounds(markdown, header);
59
+ return bounds ? markdown.slice(bounds.bodyStart, bounds.bodyEnd).trim() : null;
60
+ }
61
+
62
+ export function setSection(markdown: string, header: string, body: string): string {
63
+ const bounds = sectionBounds(markdown, header);
64
+ const block = `# ${bounds?.heading ?? header}\n${body.trim()}\n`;
65
+ if (!bounds) return `${markdown.trimEnd()}\n\n${block}`;
66
+ return `${markdown.slice(0, bounds.start)}${block}${bounds.bodyEnd < markdown.length ? '\n' + markdown.slice(bounds.bodyEnd) : ''}`;
67
+ }
68
+
69
+ export function appendToSection(markdown: string, header: string, line: string): string {
70
+ const existing = getSection(markdown, header);
71
+ return setSection(markdown, header, existing ? `${existing}\n${line}` : line);
72
+ }
73
+
74
+ export function filedAtOf(markdown: string): string {
75
+ const dates = (getSection(markdown, 'Log') ?? '')
76
+ .split('\n')
77
+ .map((line) => /^\s*-\s*(\d{4}-\d{2}-\d{2})\b/.exec(line)?.[1])
78
+ .filter((d): d is string => d !== undefined);
79
+ return dates.length ? dates.reduce((a, b) => (a < b ? a : b)) : '';
80
+ }
81
+
82
+ export function parseState(markdown: string): WorkItemState {
83
+ const raw = getSection(markdown, 'STATE');
84
+ if (!raw) return {};
85
+ const m = /\{[\s\S]*\}/.exec(raw);
86
+ if (!m) return {};
87
+ try {
88
+ const parsed = JSON.parse(m[0]) as unknown;
89
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as WorkItemState) : {};
90
+ } catch {
91
+ return {};
92
+ }
93
+ }
94
+
95
+ export function serializeState(state: WorkItemState): string {
96
+ return '```json\n' + JSON.stringify(state, null, 2) + '\n```';
97
+ }
98
+
99
+ export function parseWorkItem(path: string, markdown: string, fields: FieldNames = DEFAULT_FIELDS): ParsedWorkItem {
100
+ return {
101
+ path,
102
+ name: getSection(markdown, 'Name') ?? path.split('/').pop()?.replace(/\.md$/, '') ?? path,
103
+ status: (getSection(markdown, fields.statusField) ?? '').split(/\s/)[0].replace(/`/g, '').toLowerCase(),
104
+ assignee: (getSection(markdown, fields.ownerField) ?? '').trim(),
105
+ priority: fields.priorityField ? (getSection(markdown, fields.priorityField) ?? '').trim() : '',
106
+ filedAt: filedAtOf(markdown),
107
+ state: parseState(markdown),
108
+ raw: markdown,
109
+ };
110
+ }
111
+
112
+ /**
113
+ * The human-readable checklist: one line per happy-path step, the current one
114
+ * marked, with the failure, the blocker and the links STATE carries.
115
+ */
116
+ export function renderProgress(happyPath: string[], current: string, state: WorkItemState): string {
117
+ const attempt = typeof state.attempt === 'number' ? state.attempt : undefined;
118
+ const lines: string[] = [];
119
+ const currentIdx = happyPath.indexOf(current);
120
+ const done = current === 'done';
121
+ for (let i = 0; i < happyPath.length; i++) {
122
+ const mark = done || i < currentIdx ? '✅' : i === currentIdx ? '▶️' : '⬜';
123
+ lines.push(`${mark} ${i + 1}. ${happyPath[i]}`);
124
+ }
125
+ const extras: string[] = [];
126
+ if (current === 'failed') extras.push(`❌ Failed${state.lastError ? ` — ${String(state.lastError)}` : ''}`);
127
+ if (current === 'blocked') extras.push(`⛔ Blocked${state.blocker ? ` — ${String(state.blocker)}` : ''}`);
128
+ if (current === 'cancelled') extras.push('🚫 Cancelled');
129
+ if (attempt !== undefined && attempt > 1) extras.push(`attempt ${attempt}`);
130
+ // What the run produced that a person opens: every STATE value that is a
131
+ // link, labelled by its key. The workflow owns the keys; nothing here knows
132
+ // what a delivery, a review or a release calls its links.
133
+ for (const [key, value] of Object.entries(state)) {
134
+ if (typeof value === 'string' && /^https?:\/\/\S+$/.test(value)) extras.push(`[${key}](${value})`);
135
+ }
136
+ return lines.join('\n') + (extras.length ? `\n\n${extras.join(' · ')}` : '');
137
+ }
138
+
139
+ export interface WorkItemUpdate {
140
+ status?: string;
141
+ assignee?: string;
142
+ state?: WorkItemState;
143
+ progress?: { happyPath: string[]; current: string };
144
+ logLine?: string;
145
+ /** Links to add to the Transcripts section, each once. */
146
+ transcriptLinks?: Array<{ label: string; href: string }>;
147
+ fields?: FieldNames;
148
+ }
149
+
150
+ export function applyWorkItemUpdate(markdown: string, update: WorkItemUpdate, now = new Date()): string {
151
+ const fields = update.fields ?? DEFAULT_FIELDS;
152
+ let out = markdown;
153
+ if (update.status !== undefined) out = setSection(out, fields.statusField, update.status);
154
+ if (update.assignee !== undefined) out = setSection(out, fields.ownerField, update.assignee);
155
+ if (update.state !== undefined) out = setSection(out, 'STATE', serializeState(update.state));
156
+ if (update.progress) {
157
+ const st = update.state ?? parseState(out);
158
+ out = setSection(out, 'Delivery Progress', renderProgress(update.progress.happyPath, update.progress.current, st));
159
+ }
160
+ if (update.logLine) {
161
+ const stamp = now.toISOString().slice(0, 16).replace('T', ' ');
162
+ out = appendToSection(out, 'Log', `- ${stamp} — ${update.logLine}`);
163
+ }
164
+ for (const link of update.transcriptLinks ?? []) {
165
+ const line = `- [${link.label}](${link.href})`;
166
+ const existing = getSection(out, 'Transcripts') ?? '';
167
+ if (!existing.includes(`](${link.href})`)) out = appendToSection(out, 'Transcripts', line);
168
+ }
169
+ return out;
170
+ }