@lchase/yak 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +44 -61
  2. package/dist/index.js +311 -124
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -1,3 +1,7 @@
1
+ <p align="center">
2
+ <img src="docs-site/static/img/yak-mascot.png" alt="yak mascot" width="320" />
3
+ </p>
4
+
1
5
  # yak
2
6
 
3
7
  An agentic workflow engine. *It shaves the yak so you don't.*
@@ -6,77 +10,56 @@ Reads a YAML DAG, runs steps (shell commands, pure transforms, coding-agent
6
10
  invocations, human gates), and journals everything to disk so runs resume
7
11
  exactly where they stopped.
8
12
 
9
- Status: M0–M5 all done (spec.md §8). The `agent → agent → command →
10
- transform` reference shape runs deterministically against fixtures
11
- (`mock` adapter) and for real against an actual bug
12
- (`claude-code` adapter, `fixtures/todo-app/` — see the tutorial at
13
- `docs-site/docs/tutorial.md`, live at
14
- `https://lchase.github.io/yak/docs/tutorial`). `loop`, `map`, `gate`,
15
- and worktree isolation are all implemented and executable, not just IR
16
- types. See `spec.md` §8.
13
+ ## Install
14
+
15
+ ```bash
16
+ npm install -g @lchase/yak
17
+ ```
18
+
19
+ ## Quickstart
20
+
21
+ ```bash
22
+ yak run path/to/workflow.yaml
23
+ ```
17
24
 
18
- ## Current capabilities (M0–M5)
25
+ See `docs-site/docs/quickstart.md` (free, no API key, uses a mock adapter)
26
+ and `docs-site/docs/tutorial.md` (a real run that finds and fixes an actual
27
+ bug) for a guided walkthrough — also live at
28
+ [lchase.github.io/yak/docs/tutorial](https://lchase.github.io/yak/docs/tutorial).
19
29
 
20
- M0 is zero-AI by design — a boring shell pipeline has to cache, resume, and
21
- journal correctly before nondeterministic steps go anywhere near it.
30
+ ## Capabilities
22
31
 
23
32
  - **Step kinds:** `command` (streamed shell exec, capture stdout/stderr/
24
33
  exitCode, optional `idleTimeoutMs` — SIGTERM then SIGKILL after a grace
25
- period, `StepFailure.reason: 'timeout'`), `transform` (named pure
26
- function from `.yak/transforms.ts`), `agent`
27
- (prompt from `{ file }`/`{ inline }` with `{{placeholder}}` interpolation,
28
- `context: 'fresh' | { inherit } | { session }`, Zod schema validation via
34
+ period), `transform` (named pure function from `.yak/transforms.ts`),
35
+ `agent` (prompt from `{ file }`/`{ inline }`, Zod schema validation via
29
36
  `.yak/schemas.ts`, schema repair loop with retries), `loop` (bounded by
30
- `maxIterations`, plus a `noProgress` signal/rounds exhaustion check),
31
- `map` (concurrency cap, per-item failure policy, optional
32
- `isolation: 'worktree'` — each item forks its own worktree off the
33
- run's `HEAD`, engine auto-commits before fan-out so items see prior
34
- steps' output), and `gate` (human decision point, `schema`-validated
35
- answer, optional `skipIf`).
37
+ `maxIterations`, plus a no-progress exhaustion check), `map`
38
+ (concurrency cap, per-item failure policy, optional
39
+ `isolation: 'worktree'`), and `gate` (human decision point,
40
+ schema-validated answer, optional `skipIf`).
36
41
  - **Suspend/resume:** a `gate` or an exhausted budget suspends the run
37
- (`run.suspended`, reason `gate` | `budget`, `tripped: maxIterations |
38
- maxTokens | noProgress`) rather than failing it. `yak pending` lists
39
- every run across the repo awaiting a human answer; `yak resume
40
- <run-id>` replays and continues; `--interactive` prompts through open
41
- gates inline instead of exiting to resume later.
42
+ rather than failing it. `yak pending` lists every run across the repo
43
+ awaiting a human answer; `yak resume <run-id>` replays and continues;
44
+ `--interactive` prompts through open gates inline instead of exiting to
45
+ resume later.
42
46
  - **Worktree isolation:** `yak run --isolation worktree` runs the whole
43
- workflow inside a fresh git worktree (`.yak/worktrees/<run-id>/`);
44
- `map` steps can additionally isolate each item into its own sibling
45
- worktree.
47
+ workflow inside a fresh git worktree; `map` steps can additionally
48
+ isolate each item into its own sibling worktree.
46
49
  - **Introspection:** `yak graph <workflow>` emits a workflow's DAG as
47
50
  Mermaid; `yak watch [<run-id>]` live-tails a run's step statuses in a
48
51
  terminal UI; `yak artifacts [<run-id>]` lists a `map` step's per-item
49
- artifact files.
50
- - **Scheduling:** topological sort, concurrent execution of independent
51
- steps up to a concurrency cap, artifact-driven eligibility (`needs`/
52
- `produces`).
53
- - **Validation:** rejects unknown step kinds, duplicate ids, `needs`
54
- referencing an artifact nothing produces, cycles, and reading
55
- `<step>.exitCode` downstream of a step with `failOn: 'exitCode'`.
56
- - **Caching:** two-tier content-addressed cache key (`semanticKey` over
57
- step id + input artifact hashes, `definitionKey` over the step's own
58
- definition). `cache: strict` (default) re-runs on any mismatch;
59
- `cache: loose` reuses the artifact and marks the step `stale` when only
60
- the definition moved.
61
- - **Journal:** append-only JSONL event log of every step's lifecycle.
62
- - **Resume:** `yak resume <run-id>` replays a run's journal to find what
63
- already completed (a step left `step.started` with no matching
64
- `step.completed`/`step.failed` is treated as interrupted), recomputes each
65
- completed step's cache keys against the frozen workflow, reuses the
66
- on-disk artifact on a match, and re-runs everything from the first
67
- mismatch downward.
68
- - **Status:** `yak status [<run-id>]` replays a run's journal into per-step
69
- state (`completed`/`cached`/`stale`/`failed`/`pending`), with no db or
70
- extra durable state beyond the journal. Defaults to the most recent run
71
- when `<run-id>` is omitted.
52
+ artifact files; `yak status [<run-id>]` reports per-step state
53
+ (`completed`/`cached`/`stale`/`failed`/`pending`).
54
+ - **Caching:** content-addressed — re-runs only what a workflow or
55
+ artifact change actually invalidates, everything else is reused from a
56
+ prior run.
57
+ - **Journal:** append-only event log of every step's lifecycle is the
58
+ only durable state — no database.
72
59
  - **Adapters:** `mock` (fixture-driven, deterministic, free) and
73
- `claude-code` (real, wraps `@anthropic-ai/claude-agent-sdk`). Selected
74
- with `yak run --adapter mock|claude-code` (default `claude-code`);
75
- `yak resume` replays the run's original choice and rejects a conflicting
76
- `--adapter` override. `claude-code.ts`'s own tests stub `query()` at the
77
- module boundary — CI never calls a real model, per CLAUDE.md.
60
+ `claude-code` (real, wraps `@anthropic-ai/claude-agent-sdk`, also works
61
+ against any Anthropic-compatible local model server via
62
+ `ANTHROPIC_BASE_URL`). Selected with `yak run --adapter mock|claude-code`.
78
63
 
79
- Not yet implemented: eval-corpus export (`yak export --evals`,
80
- descoped from M4, spec.md §9 decision 10), an API/CLI/YAML reference
81
- beyond spec.md, and npm publish (`docs-site/`'s quickstart still targets
82
- a git-clone install for that reason).
64
+ Full behavior reference: `spec.md`. Release process and commit
65
+ conventions: `CONTRIBUTING.md`.
package/dist/index.js CHANGED
@@ -70,6 +70,7 @@ async function writeRejectedOutput(runDir, stepId, attempt, content) {
70
70
  import { randomBytes as randomBytes2 } from "crypto";
71
71
  import { mkdir as mkdir6, readdir as readdir2, readFile as readFile9, writeFile as writeFile4 } from "fs/promises";
72
72
  import path15 from "path";
73
+ import { z as z9 } from "zod";
73
74
 
74
75
  // src/ir/graph.ts
75
76
  function buildProducerMap(steps) {
@@ -206,7 +207,7 @@ var rawStepSchema = z2.lazy(
206
207
  var rawWorkflowSchema = z2.object({
207
208
  name: z2.string(),
208
209
  version: z2.string(),
209
- inputSchema: z2.string().optional(),
210
+ inputSchema: rawSchemaSpecSchema.optional(),
210
211
  steps: z2.array(rawStepSchema)
211
212
  });
212
213
  function normalizeStep(raw) {
@@ -318,6 +319,9 @@ function normalizeWorkflow(raw) {
318
319
  };
319
320
  }
320
321
 
322
+ // src/ir/types.ts
323
+ var INPUT_ARTIFACT = "input";
324
+
321
325
  // src/ir/validate.ts
322
326
  import { execFile } from "child_process";
323
327
  import { readFile as readFile3 } from "fs/promises";
@@ -481,6 +485,7 @@ async function validateWorkflow(workflow, cwd = process.cwd(), runIsolation = "n
481
485
  checkKnownKinds(flatSteps);
482
486
  checkDuplicateIds(flatSteps);
483
487
  const producerOf = buildProducerMap(workflow.steps);
488
+ checkReservedArtifactNames(flatSteps);
484
489
  checkNeedsSatisfied(workflow.steps, producerOf);
485
490
  checkNoCycles(workflow.steps, producerOf);
486
491
  checkExitCodeReads(workflow.steps, producerOf);
@@ -569,9 +574,19 @@ function checkDuplicateIds(steps) {
569
574
  seen.add(step.id);
570
575
  }
571
576
  }
577
+ function checkReservedArtifactNames(steps) {
578
+ for (const step of steps) {
579
+ if (step.produces === INPUT_ARTIFACT) {
580
+ throw new WorkflowValidationError(
581
+ `step "${step.id}": "${INPUT_ARTIFACT}" is a reserved artifact name (the run input) \u2014 a step cannot produce it`
582
+ );
583
+ }
584
+ }
585
+ }
572
586
  function checkNeedsSatisfied(steps, producerOf) {
573
587
  for (const step of steps) {
574
588
  for (const need of step.needs ?? []) {
589
+ if (need === INPUT_ARTIFACT) continue;
575
590
  if (!producerOf.has(need)) {
576
591
  throw new WorkflowValidationError(
577
592
  `step "${step.id}": needs artifact "${need}" but no step produces it`
@@ -681,9 +696,9 @@ function checkFlatAnswerSchema(step, schema) {
681
696
  async function checkGateRenderPlaceholders(steps, cwd) {
682
697
  for (const step of steps) {
683
698
  if (step.kind !== "gate") continue;
684
- const text = "file" in step.render ? await readFile3(path3.resolve(cwd, step.render.file), "utf8") : step.render.inline;
699
+ const text2 = "file" in step.render ? await readFile3(path3.resolve(cwd, step.render.file), "utf8") : step.render.inline;
685
700
  const allowed = new Set(step.needs ?? []);
686
- for (const root of extractTemplateRoots(text)) {
701
+ for (const root of extractTemplateRoots(text2)) {
687
702
  if (!allowed.has(root)) {
688
703
  throw new WorkflowValidationError(
689
704
  `step "${step.id}": render references "{{${root}}}" but "${root}" is not in needs`
@@ -695,9 +710,9 @@ async function checkGateRenderPlaceholders(steps, cwd) {
695
710
  async function checkAgentPromptPlaceholders(steps, cwd) {
696
711
  for (const step of steps) {
697
712
  if (step.kind !== "agent") continue;
698
- const text = "file" in step.prompt ? await readFile3(path3.resolve(cwd, step.prompt.file), "utf8") : step.prompt.inline;
713
+ const text2 = "file" in step.prompt ? await readFile3(path3.resolve(cwd, step.prompt.file), "utf8") : step.prompt.inline;
699
714
  const allowed = new Set(agentInputNames(step));
700
- for (const root of extractTemplateRoots(text)) {
715
+ for (const root of extractTemplateRoots(text2)) {
701
716
  if (!allowed.has(root)) {
702
717
  throw new WorkflowValidationError(
703
718
  `step "${step.id}": prompt references "{{${root}}}" but "${root}" is not in needs or context.inherit`
@@ -883,8 +898,8 @@ async function evalExpr(expr, context, cwd) {
883
898
 
884
899
  // src/steps/gate.ts
885
900
  async function renderGate(step, inputs, cwd) {
886
- const text = "file" in step.render ? await readFile6(path7.resolve(cwd, step.render.file), "utf8") : step.render.inline;
887
- return renderTemplate(text, inputs);
901
+ const text2 = "file" in step.render ? await readFile6(path7.resolve(cwd, step.render.file), "utf8") : step.render.inline;
902
+ return renderTemplate(text2, inputs);
888
903
  }
889
904
  async function completeGate(step, ctx, answer, opts) {
890
905
  let artifactName;
@@ -997,8 +1012,8 @@ import {
997
1012
  import { readFile as readFile7 } from "fs/promises";
998
1013
  import path8 from "path";
999
1014
  async function buildPrompt(step, inputs, cwd) {
1000
- const text = "file" in step.prompt ? await readFile7(path8.resolve(cwd, step.prompt.file), "utf8") : step.prompt.inline;
1001
- return renderTemplate(text, inputs);
1015
+ const text2 = "file" in step.prompt ? await readFile7(path8.resolve(cwd, step.prompt.file), "utf8") : step.prompt.inline;
1016
+ return renderTemplate(text2, inputs);
1002
1017
  }
1003
1018
  async function callAgentOnce(step, inputs, ctx, promptSuffix) {
1004
1019
  const prompt = await buildPrompt(step, inputs, ctx.cwd) + (promptSuffix ?? "");
@@ -1248,11 +1263,20 @@ var ClaudeCodeAdapter = class {
1248
1263
  await mkdir4(path9.dirname(sessionLogPath), { recursive: true });
1249
1264
  const filesChanged = /* @__PURE__ */ new Set();
1250
1265
  let result;
1251
- for await (const message of query({ prompt: req.prompt, options })) {
1252
- await appendFile2(sessionLogPath, `${JSON.stringify(message)}
1266
+ const stream = query({ prompt: req.prompt, options });
1267
+ try {
1268
+ for await (const message of stream) {
1269
+ await appendFile2(sessionLogPath, `${JSON.stringify(message)}
1253
1270
  `, "utf8");
1254
- collectFilesChanged(message, filesChanged);
1255
- if (message.type === "result") result = message;
1271
+ collectFilesChanged(message, filesChanged);
1272
+ if (message.type === "result") result = message;
1273
+ }
1274
+ } catch (err) {
1275
+ throw new AgentStepFailedError({
1276
+ reason: "adapter-error",
1277
+ detail: `claude-code adapter for step "${this.stepId}": query() stream errored: ${err.message}`,
1278
+ recoverable: true
1279
+ });
1256
1280
  }
1257
1281
  if (!result) {
1258
1282
  throw new AgentStepFailedError({
@@ -2063,7 +2087,7 @@ async function runEligibleSteps(workflow, ctx, resumeState) {
2063
2087
  const completed = /* @__PURE__ */ new Set();
2064
2088
  const failedSteps = /* @__PURE__ */ new Set();
2065
2089
  const inFlight = /* @__PURE__ */ new Map();
2066
- const artifactHashes = /* @__PURE__ */ new Map();
2090
+ const artifactHashes = new Map(ctx.seedArtifactHashes ?? []);
2067
2091
  let failed = false;
2068
2092
  let suspended = false;
2069
2093
  if (resumeState) trustResumedSteps(remaining, producerOf, resumeState, completed, artifactHashes);
@@ -2259,6 +2283,31 @@ async function runStep(step, ctx, artifactHashes, workflowName, agentSessionIds)
2259
2283
 
2260
2284
  // src/engine/run.ts
2261
2285
  var DEFAULT_ADAPTER = "claude-code";
2286
+ var InputValidationError = class extends Error {
2287
+ name = "InputValidationError";
2288
+ };
2289
+ async function resolveRunInput(workflow, rawInput, cwd) {
2290
+ const stepNeedsInput = flattenSteps(workflow.steps).some((s) => (s.needs ?? []).includes(INPUT_ARTIFACT));
2291
+ if (rawInput === void 0 && workflow.inputSchema === void 0 && !stepNeedsInput) {
2292
+ return void 0;
2293
+ }
2294
+ const provided = rawInput ?? {};
2295
+ if (workflow.inputSchema === void 0) return provided;
2296
+ const schema = await resolveSchemaSpec(workflow.inputSchema, cwd);
2297
+ const parsed = schema.safeParse(provided);
2298
+ if (!parsed.success) {
2299
+ throw new InputValidationError(
2300
+ `--input does not satisfy the workflow's inputSchema:
2301
+ ${parsed.errorSummary}`
2302
+ );
2303
+ }
2304
+ return parsed.data;
2305
+ }
2306
+ async function seedInputHash(runDir) {
2307
+ const value = await readArtifactRawOrUndefined(runDir, INPUT_ARTIFACT);
2308
+ if (value === void 0) return void 0;
2309
+ return /* @__PURE__ */ new Map([[INPUT_ARTIFACT, sha256(JSON.stringify(value, null, 2))]]);
2310
+ }
2262
2311
  function defaultRunsDir() {
2263
2312
  return path15.join(process.cwd(), ".runs");
2264
2313
  }
@@ -2287,18 +2336,33 @@ async function executeWorkflowFile(workflowPath, opts = {}) {
2287
2336
  const raw = await loadWorkflowYaml(workflowPath);
2288
2337
  const workflow = normalizeWorkflow(raw);
2289
2338
  await validateWorkflow(workflow, cwd, isolation);
2339
+ const inputValue = await resolveRunInput(workflow, opts.input, cwd);
2290
2340
  const runId = generateRunId();
2291
2341
  const runDir = path15.join(runsDir, runId);
2292
2342
  await mkdir6(runDir, { recursive: true });
2293
2343
  await writeFile4(path15.join(runDir, "workflow.json"), JSON.stringify(workflow, null, 2), "utf8");
2344
+ let seedArtifactHashes;
2345
+ let inputWritten;
2346
+ if (inputValue !== void 0) {
2347
+ inputWritten = await writeArtifact(runDir, INPUT_ARTIFACT, inputValue, z9.unknown());
2348
+ seedArtifactHashes = /* @__PURE__ */ new Map([[INPUT_ARTIFACT, inputWritten.hash]]);
2349
+ }
2294
2350
  await appendJournalEvent(runDir, runId, {
2295
2351
  t: "run.started",
2296
2352
  runId,
2297
2353
  workflow: workflow.name,
2298
- inputHash: sha256(JSON.stringify(workflow)),
2354
+ inputHash: inputWritten?.hash ?? sha256(JSON.stringify(workflow)),
2299
2355
  adapter,
2300
2356
  isolation
2301
2357
  });
2358
+ if (inputWritten) {
2359
+ await appendJournalEvent(runDir, runId, {
2360
+ t: "artifact.written",
2361
+ name: inputWritten.name,
2362
+ hash: inputWritten.hash,
2363
+ bytes: inputWritten.bytes
2364
+ });
2365
+ }
2302
2366
  let stepCwd = cwd;
2303
2367
  if (isolation === "worktree") {
2304
2368
  const worktreePath = worktreePathFor(runsDir, runId);
@@ -2310,7 +2374,14 @@ async function executeWorkflowFile(workflowPath, opts = {}) {
2310
2374
  }
2311
2375
  stepCwd = worktreePath;
2312
2376
  }
2313
- const status = await runEligibleSteps(workflow, { runId, runDir, cwd: stepCwd, cacheDir, adapter });
2377
+ const status = await runEligibleSteps(workflow, {
2378
+ runId,
2379
+ runDir,
2380
+ cwd: stepCwd,
2381
+ cacheDir,
2382
+ adapter,
2383
+ seedArtifactHashes
2384
+ });
2314
2385
  await appendJournalEvent(runDir, runId, { t: "run.finished", status });
2315
2386
  return { runId, runDir, status };
2316
2387
  }
@@ -2383,9 +2454,18 @@ async function resumeRun(runId, opts = {}) {
2383
2454
  );
2384
2455
  }
2385
2456
  const stepCwd = persistedIsolation === "worktree" ? worktreePathFor(runsDir, runId) : cwd;
2457
+ const seedArtifactHashes = await seedInputHash(runDir);
2386
2458
  const status = await runEligibleSteps(
2387
2459
  workflow,
2388
- { runId, runDir, cwd: stepCwd, cacheDir, adapter: persistedAdapter, loopContinuations },
2460
+ {
2461
+ runId,
2462
+ runDir,
2463
+ cwd: stepCwd,
2464
+ cacheDir,
2465
+ adapter: persistedAdapter,
2466
+ loopContinuations,
2467
+ seedArtifactHashes
2468
+ },
2389
2469
  resumeState
2390
2470
  );
2391
2471
  await appendJournalEvent(runDir, runId, { t: "run.finished", status });
@@ -2463,7 +2543,55 @@ async function pendingCommand(opts = {}) {
2463
2543
  return 0;
2464
2544
  }
2465
2545
 
2546
+ // src/engine/status.ts
2547
+ function stepStatusesFromJournal(steps, events) {
2548
+ const stateByStep = /* @__PURE__ */ new Map();
2549
+ for (const event of events) {
2550
+ if (event.t === "step.started") {
2551
+ stateByStep.set(event.stepId, "pending");
2552
+ } else if (event.t === "step.completed") {
2553
+ stateByStep.set(event.stepId, event.stale ? "stale" : event.cached ? "cached" : "completed");
2554
+ } else if (event.t === "step.failed") {
2555
+ stateByStep.set(event.stepId, "failed");
2556
+ }
2557
+ }
2558
+ return steps.map((step) => ({ stepId: step.id, status: stateByStep.get(step.id) ?? "pending" }));
2559
+ }
2560
+ function failedStepsFromJournal(events) {
2561
+ return events.filter((e) => e.t === "step.failed").map((e) => ({ stepId: e.stepId, failure: e.failure }));
2562
+ }
2563
+ function formatStepFailure(failure) {
2564
+ return `${failure.detail} (${failure.reason})`;
2565
+ }
2566
+ function loopStatusFromJournal(step, events) {
2567
+ const bodyStepIds = new Set(step.body.map((s) => s.id));
2568
+ let state = "pending";
2569
+ for (const event of events) {
2570
+ if (event.t === "step.started" && event.stepId === step.id) state = "running";
2571
+ else if (event.t === "step.completed" && event.stepId === step.id) state = "completed";
2572
+ else if (event.t === "step.failed" && event.stepId === step.id) state = "failed";
2573
+ else if (event.t === "run.suspended" && event.loopStepId === step.id) state = "suspended";
2574
+ }
2575
+ const iteration = events.filter((e) => e.t === "step.started" && bodyStepIds.has(e.stepId)).reduce((max, e) => Math.max(max, e.iteration ?? 0), 0);
2576
+ const consumedTokens = events.filter((e) => e.t === "budget.consumed" && bodyStepIds.has(e.stepId)).reduce((sum, e) => sum + e.tokens, 0);
2577
+ const lastIterationEvent = [...events].reverse().find((e) => e.t === "loop.iteration" && e.stepId === step.id);
2578
+ const noProgressSignal = lastIterationEvent?.t === "loop.iteration" ? lastIterationEvent.signal : void 0;
2579
+ return {
2580
+ stepId: step.id,
2581
+ state,
2582
+ iteration,
2583
+ maxIterations: step.budget.maxIterations,
2584
+ consumedTokens,
2585
+ ...step.budget.maxTokens !== void 0 ? { maxTokens: step.budget.maxTokens } : {},
2586
+ ...noProgressSignal !== void 0 ? { noProgressSignal } : {}
2587
+ };
2588
+ }
2589
+ function formatLoopBudget(detail) {
2590
+ return detail.maxTokens !== void 0 ? `iteration ${detail.iteration}/${detail.maxIterations}, tokens ${detail.consumedTokens}/${detail.maxTokens}` : `iteration ${detail.iteration}/${detail.maxIterations}, tokens ${detail.consumedTokens}`;
2591
+ }
2592
+
2466
2593
  // src/cli/commands/resume.ts
2594
+ var EX_SUSPEND = 78;
2467
2595
  async function resumeCommand(runId, adapter) {
2468
2596
  let result;
2469
2597
  try {
@@ -2476,94 +2604,162 @@ async function resumeCommand(runId, adapter) {
2476
2604
  console.log(`run ${result.runId} finished: ok`);
2477
2605
  return 0;
2478
2606
  }
2479
- console.error(`run ${result.runId} finished: ${result.status}`);
2607
+ if (result.status === "suspended") {
2608
+ console.log(`run ${result.runId} suspended \u2014 resume with:
2609
+ yak resume ${result.runId}`);
2610
+ return EX_SUSPEND;
2611
+ }
2612
+ console.error(`run ${result.runId} failed:`);
2613
+ const events = await readJournal(result.runDir);
2614
+ for (const { stepId, failure } of failedStepsFromJournal(events)) {
2615
+ console.error(` ${stepId}: ${formatStepFailure(failure)}`);
2616
+ }
2480
2617
  return 1;
2481
2618
  }
2482
2619
 
2483
2620
  // src/cli/commands/run.ts
2484
2621
  import path18 from "path";
2622
+ import { cancel, intro, log, outro } from "@clack/prompts";
2485
2623
 
2486
2624
  // src/cli/interactive.ts
2487
2625
  import { createInterface } from "readline";
2488
- function terminalAsk(input, output) {
2489
- const rl = createInterface({ input, output });
2490
- const queued = [];
2491
- const waiters = [];
2492
- rl.on("line", (line) => {
2493
- const waiter = waiters.shift();
2494
- if (waiter) waiter(line);
2495
- else queued.push(line);
2496
- });
2497
- return (question) => {
2498
- output.write(question);
2499
- const queuedLine = queued.shift();
2500
- if (queuedLine !== void 0) return Promise.resolve(queuedLine);
2501
- return new Promise((resolve) => waiters.push(resolve));
2502
- };
2503
- }
2504
- async function promptForAnswer(request, ask, announce = () => {
2626
+ import { confirm, isCancel, select, text } from "@clack/prompts";
2627
+ var PromptCancelledError = class extends Error {
2628
+ constructor() {
2629
+ super("prompt cancelled");
2630
+ }
2631
+ };
2632
+ async function promptForAnswer(request, promptField, announce = () => {
2505
2633
  }) {
2506
2634
  if (request.kind === "gate") {
2507
- announce(`
2508
- ${request.rendered}
2509
- `);
2635
+ announce(request.rendered);
2510
2636
  } else {
2511
- announce(`
2512
- loop "${request.stepId}" exhausted (${request.tripped}) at iteration ${request.iteration}
2513
- `);
2637
+ announce(`loop "${request.stepId}" exhausted (${request.tripped}) at iteration ${request.iteration}`);
2514
2638
  }
2515
2639
  const schema = request.answerSchema;
2516
2640
  const required = new Set(schema.required ?? []);
2517
2641
  const answer = {};
2518
2642
  for (const [name, prop] of Object.entries(schema.properties ?? {})) {
2519
- const value = await promptField(ask, name, prop, required.has(name));
2643
+ const value = await promptField({ name, prop, required: required.has(name) });
2520
2644
  if (value !== void 0) answer[name] = value;
2521
2645
  }
2522
2646
  return answer;
2523
2647
  }
2524
- async function promptField(ask, name, prop, required) {
2525
- const label = required ? name : `${name} (optional, blank to skip)`;
2526
- for (; ; ) {
2527
- const raw = (await ask(prop.enum ? `${label} [${prop.enum.join("/")}]: ` : `${label}: `)).trim();
2528
- if (!raw) {
2529
- if (!required) return void 0;
2530
- continue;
2648
+ function clackFieldPrompt() {
2649
+ return async ({ name, prop, required }) => {
2650
+ if (prop.enum) {
2651
+ const result = await select({
2652
+ message: name,
2653
+ options: prop.enum.map((value) => ({ value, label: value }))
2654
+ });
2655
+ if (isCancel(result)) throw new PromptCancelledError();
2656
+ return result;
2531
2657
  }
2532
- if (prop.enum && !prop.enum.includes(raw)) continue;
2533
- if (prop.type === "number") {
2534
- const n = Number(raw);
2535
- if (Number.isNaN(n)) continue;
2536
- return n;
2658
+ if (prop.type === "boolean") {
2659
+ const result = await confirm({ message: name });
2660
+ if (isCancel(result)) throw new PromptCancelledError();
2661
+ return result;
2537
2662
  }
2538
- if (prop.type === "boolean") return /^y(es)?$/i.test(raw);
2539
- return raw;
2540
- }
2663
+ const raw = await text({
2664
+ message: required ? name : `${name} (optional)`,
2665
+ placeholder: required ? void 0 : "blank to skip",
2666
+ validate(value) {
2667
+ if (!value) return required ? "required" : void 0;
2668
+ if (prop.type === "number" && Number.isNaN(Number(value))) return "must be a number";
2669
+ return void 0;
2670
+ }
2671
+ });
2672
+ if (isCancel(raw)) throw new PromptCancelledError();
2673
+ if (raw === "") return void 0;
2674
+ return prop.type === "number" ? Number(raw) : raw;
2675
+ };
2676
+ }
2677
+ function lineFieldPrompt(input, output) {
2678
+ const rl = createInterface({ input, output });
2679
+ const queued = [];
2680
+ const waiters = [];
2681
+ rl.on("line", (line) => {
2682
+ const waiter = waiters.shift();
2683
+ if (waiter) waiter(line);
2684
+ else queued.push(line);
2685
+ });
2686
+ const askLine = (question) => {
2687
+ output.write(question);
2688
+ const queuedLine = queued.shift();
2689
+ if (queuedLine !== void 0) return Promise.resolve(queuedLine);
2690
+ return new Promise((resolve) => waiters.push(resolve));
2691
+ };
2692
+ const promptField = async ({ name, prop, required }) => {
2693
+ const label = required ? name : `${name} (optional, blank to skip)`;
2694
+ for (; ; ) {
2695
+ const raw = (await askLine(prop.enum ? `${label} [${prop.enum.join("/")}]: ` : `${label}: `)).trim();
2696
+ if (!raw) {
2697
+ if (!required) return void 0;
2698
+ continue;
2699
+ }
2700
+ if (prop.enum && !prop.enum.includes(raw)) continue;
2701
+ if (prop.type === "number") {
2702
+ const n = Number(raw);
2703
+ if (Number.isNaN(n)) continue;
2704
+ return n;
2705
+ }
2706
+ if (prop.type === "boolean") return /^y(es)?$/i.test(raw);
2707
+ return raw;
2708
+ }
2709
+ };
2710
+ return { promptField, close: () => rl.close() };
2541
2711
  }
2542
2712
 
2543
2713
  // src/cli/commands/run.ts
2544
- var EX_SUSPEND = 78;
2714
+ var EX_SUSPEND2 = 78;
2545
2715
  async function answerOpenRequestsInteractively(runId, runDir) {
2546
2716
  const events = await readJournal(runDir);
2547
2717
  const openIds = new Set(openRequestStepIds(events));
2548
2718
  if (openIds.size === 0) return;
2549
2719
  const workflow = await readRunWorkflow(runDir);
2550
2720
  const orderedIds = workflow.steps.map((s) => s.id).filter((id) => openIds.has(id));
2551
- const ask = terminalAsk(process.stdin, process.stdout);
2552
- for (const stepId of orderedIds) {
2553
- const request = await readPendingRequest(runDir, stepId);
2554
- if (!request) continue;
2555
- const answer = await promptForAnswer(request, ask, (text) => console.log(text));
2556
- await writeAnswer(runDir, stepId, answer);
2721
+ const isRealTerminal = process.stdin.isTTY && process.stdout.isTTY;
2722
+ const { promptField, close } = isRealTerminal ? { promptField: clackFieldPrompt(), close: () => {
2723
+ } } : lineFieldPrompt(process.stdin, process.stdout);
2724
+ const announce = isRealTerminal ? log.info : (text2) => console.log(`
2725
+ ${text2}
2726
+ `);
2727
+ if (isRealTerminal) intro("yak: answer pending gates");
2728
+ try {
2729
+ for (const stepId of orderedIds) {
2730
+ const request = await readPendingRequest(runDir, stepId);
2731
+ if (!request) continue;
2732
+ try {
2733
+ const answer = await promptForAnswer(request, promptField, announce);
2734
+ await writeAnswer(runDir, stepId, answer);
2735
+ } catch (err) {
2736
+ if (err instanceof PromptCancelledError) {
2737
+ cancel(`"${stepId}" left unanswered \u2014 run still suspended`);
2738
+ throw new Error(`answering "${stepId}" was cancelled`);
2739
+ }
2740
+ throw err;
2741
+ }
2742
+ }
2743
+ } finally {
2744
+ close();
2557
2745
  }
2746
+ if (isRealTerminal) outro("all gates answered");
2558
2747
  }
2559
2748
  async function runCommand(workflowPath, opts = {}) {
2560
- let result = await executeWorkflowFile(path18.resolve(workflowPath), {
2561
- adapter: opts.adapter,
2562
- isolation: opts.isolation
2563
- });
2564
- while (opts.interactive && result.status === "suspended") {
2565
- await answerOpenRequestsInteractively(result.runId, result.runDir);
2566
- result = await resumeRun(result.runId, { adapter: opts.adapter });
2749
+ let result;
2750
+ try {
2751
+ result = await executeWorkflowFile(path18.resolve(workflowPath), {
2752
+ adapter: opts.adapter,
2753
+ isolation: opts.isolation,
2754
+ input: opts.input
2755
+ });
2756
+ while (opts.interactive && result.status === "suspended") {
2757
+ await answerOpenRequestsInteractively(result.runId, result.runDir);
2758
+ result = await resumeRun(result.runId, { adapter: opts.adapter });
2759
+ }
2760
+ } catch (err) {
2761
+ console.error(err.message);
2762
+ return 1;
2567
2763
  }
2568
2764
  if (result.status === "ok") {
2569
2765
  console.log(`run ${result.runId} finished: ok`);
@@ -2572,57 +2768,18 @@ async function runCommand(workflowPath, opts = {}) {
2572
2768
  if (result.status === "suspended") {
2573
2769
  console.log(`run ${result.runId} suspended \u2014 resume with:
2574
2770
  yak resume ${result.runId}`);
2575
- return EX_SUSPEND;
2771
+ return EX_SUSPEND2;
2772
+ }
2773
+ console.error(`run ${result.runId} failed:`);
2774
+ const events = await readJournal(result.runDir);
2775
+ for (const { stepId, failure } of failedStepsFromJournal(events)) {
2776
+ console.error(` ${stepId}: ${formatStepFailure(failure)}`);
2576
2777
  }
2577
- console.error(`run ${result.runId} finished: failed`);
2578
2778
  return 1;
2579
2779
  }
2580
2780
 
2581
2781
  // src/cli/commands/status.ts
2582
2782
  import path19 from "path";
2583
-
2584
- // src/engine/status.ts
2585
- function stepStatusesFromJournal(steps, events) {
2586
- const stateByStep = /* @__PURE__ */ new Map();
2587
- for (const event of events) {
2588
- if (event.t === "step.started") {
2589
- stateByStep.set(event.stepId, "pending");
2590
- } else if (event.t === "step.completed") {
2591
- stateByStep.set(event.stepId, event.stale ? "stale" : event.cached ? "cached" : "completed");
2592
- } else if (event.t === "step.failed") {
2593
- stateByStep.set(event.stepId, "failed");
2594
- }
2595
- }
2596
- return steps.map((step) => ({ stepId: step.id, status: stateByStep.get(step.id) ?? "pending" }));
2597
- }
2598
- function loopStatusFromJournal(step, events) {
2599
- const bodyStepIds = new Set(step.body.map((s) => s.id));
2600
- let state = "pending";
2601
- for (const event of events) {
2602
- if (event.t === "step.started" && event.stepId === step.id) state = "running";
2603
- else if (event.t === "step.completed" && event.stepId === step.id) state = "completed";
2604
- else if (event.t === "step.failed" && event.stepId === step.id) state = "failed";
2605
- else if (event.t === "run.suspended" && event.loopStepId === step.id) state = "suspended";
2606
- }
2607
- const iteration = events.filter((e) => e.t === "step.started" && bodyStepIds.has(e.stepId)).reduce((max, e) => Math.max(max, e.iteration ?? 0), 0);
2608
- const consumedTokens = events.filter((e) => e.t === "budget.consumed" && bodyStepIds.has(e.stepId)).reduce((sum, e) => sum + e.tokens, 0);
2609
- const lastIterationEvent = [...events].reverse().find((e) => e.t === "loop.iteration" && e.stepId === step.id);
2610
- const noProgressSignal = lastIterationEvent?.t === "loop.iteration" ? lastIterationEvent.signal : void 0;
2611
- return {
2612
- stepId: step.id,
2613
- state,
2614
- iteration,
2615
- maxIterations: step.budget.maxIterations,
2616
- consumedTokens,
2617
- ...step.budget.maxTokens !== void 0 ? { maxTokens: step.budget.maxTokens } : {},
2618
- ...noProgressSignal !== void 0 ? { noProgressSignal } : {}
2619
- };
2620
- }
2621
- function formatLoopBudget(detail) {
2622
- return detail.maxTokens !== void 0 ? `iteration ${detail.iteration}/${detail.maxIterations}, tokens ${detail.consumedTokens}/${detail.maxTokens}` : `iteration ${detail.iteration}/${detail.maxIterations}, tokens ${detail.consumedTokens}`;
2623
- }
2624
-
2625
- // src/cli/commands/status.ts
2626
2783
  async function statusCommand(runId, opts = {}) {
2627
2784
  const runsDir = opts.runsDir ?? defaultRunsDir();
2628
2785
  const resolvedRunId = runId ?? await findLatestRunId(runsDir);
@@ -2638,6 +2795,7 @@ async function statusCommand(runId, opts = {}) {
2638
2795
  }
2639
2796
  const events = await readJournal(runDir);
2640
2797
  const statuses = stepStatusesFromJournal(workflow.steps, events);
2798
+ const failuresByStep = new Map(failedStepsFromJournal(events).map((f) => [f.stepId, f]));
2641
2799
  console.log(`run ${resolvedRunId}:`);
2642
2800
  for (const { stepId, status } of statuses) {
2643
2801
  console.log(` ${stepId}: ${status}`);
@@ -2647,6 +2805,8 @@ async function statusCommand(runId, opts = {}) {
2647
2805
  const signal = detail.noProgressSignal !== void 0 ? `, noProgress signal ${detail.noProgressSignal}` : "";
2648
2806
  console.log(` ${formatLoopBudget(detail)}${signal}`);
2649
2807
  }
2808
+ const failed = failuresByStep.get(stepId);
2809
+ if (failed) console.log(` ${formatStepFailure(failed.failure)}`);
2650
2810
  }
2651
2811
  return 0;
2652
2812
  }
@@ -2728,6 +2888,22 @@ async function watchCommand(runId, opts = {}) {
2728
2888
  return 0;
2729
2889
  }
2730
2890
 
2891
+ // src/cli/parse-input.ts
2892
+ function parseInputPairs(pairs) {
2893
+ if (pairs === void 0 || pairs.length === 0) return void 0;
2894
+ const out = {};
2895
+ for (const pair of pairs) {
2896
+ const eq = pair.indexOf("=");
2897
+ if (eq <= 0) {
2898
+ throw new Error(`--input must be key=value, got "${pair}"`);
2899
+ }
2900
+ const key = pair.slice(0, eq);
2901
+ if (key in out) throw new Error(`--input key "${key}" given more than once`);
2902
+ out[key] = pair.slice(eq + 1);
2903
+ }
2904
+ return out;
2905
+ }
2906
+
2731
2907
  // src/cli/index.ts
2732
2908
  function requireAdapterId(value) {
2733
2909
  if (value !== "mock" && value !== "claude-code") {
@@ -2743,13 +2919,19 @@ function requireIsolation(value) {
2743
2919
  }
2744
2920
  var program = new Command();
2745
2921
  program.name("yak").description("An agentic workflow engine").version("0.0.1");
2746
- program.command("run").description("Run a workflow YAML file").argument("<workflow>", "path to the workflow YAML file").option("--adapter <adapter>", "agent adapter to use", "claude-code").option("--interactive", "prompt inline for gate answers instead of exiting to resume later", false).option("--isolation <mode>", 'run inside a fresh git worktree ("worktree") or the plain cwd ("none")', "none").action(async (workflow, options) => {
2747
- process.exitCode = await runCommand(workflow, {
2748
- adapter: requireAdapterId(options.adapter),
2749
- interactive: options.interactive,
2750
- isolation: requireIsolation(options.isolation)
2751
- });
2752
- });
2922
+ program.command("run").description("Run a workflow YAML file").argument("<workflow>", "path to the workflow YAML file").option("--adapter <adapter>", "agent adapter to use", "claude-code").option("--interactive", "prompt inline for gate answers instead of exiting to resume later", false).option("--isolation <mode>", 'run inside a fresh git worktree ("worktree") or the plain cwd ("none")', "none").option(
2923
+ "--input <pair...>",
2924
+ "workflow input as key=value, repeatable \u2014 validated against the workflow inputSchema"
2925
+ ).action(
2926
+ async (workflow, options) => {
2927
+ process.exitCode = await runCommand(workflow, {
2928
+ adapter: requireAdapterId(options.adapter),
2929
+ interactive: options.interactive,
2930
+ isolation: requireIsolation(options.isolation),
2931
+ input: parseInputPairs(options.input)
2932
+ });
2933
+ }
2934
+ );
2753
2935
  program.command("resume").description("Resume an interrupted run").argument("<run-id>", "id of the run to resume").option("--adapter <adapter>", "must match the adapter the run started with, if given").action(async (runId, options) => {
2754
2936
  process.exitCode = await resumeCommand(runId, options.adapter ? requireAdapterId(options.adapter) : void 0);
2755
2937
  });
@@ -2768,4 +2950,9 @@ program.command("watch").description("Live-tail a run's step statuses in a termi
2768
2950
  program.command("artifacts").description("List a map step's per-item artifact files for a run").argument("[run-id]", "id of the run to report on (default: most recent)").action(async (runId) => {
2769
2951
  process.exitCode = await artifactsCommand(runId);
2770
2952
  });
2771
- program.parse();
2953
+ try {
2954
+ await program.parseAsync();
2955
+ } catch (err) {
2956
+ console.error(err.message);
2957
+ process.exitCode = 1;
2958
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lchase/yak",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "An agentic workflow engine",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,6 +31,7 @@
31
31
  "dependencies": {
32
32
  "@anthropic-ai/claude-agent-sdk": "^0.3.228",
33
33
  "@anthropic-ai/sdk": "^0.116.0",
34
+ "@clack/prompts": "^1.7.0",
34
35
  "ajv": "^8.20.0",
35
36
  "commander": "^12.1.0",
36
37
  "ink": "^7.1.1",