@lonca/baron-recipes 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.
package/dist/index.d.ts CHANGED
@@ -7,14 +7,17 @@ import { KnowledgeLoop } from '@lonca/baron-knowledge-loop';
7
7
  */
8
8
  declare const RECIPE_OPS: {
9
9
  readonly issueCreate: "issue.create";
10
+ readonly issueGet: "issue.get";
10
11
  readonly issueTransition: "issue.transition";
11
12
  readonly issueComment: "issue.comment";
12
13
  readonly issueLink: "issue.link";
14
+ readonly issueAssign: "issue.assign";
13
15
  readonly issueQuery: "issue.query";
14
16
  readonly scmBranchCreate: "scm.branch.create";
15
17
  readonly scmPrCreate: "scm.pr.create";
16
18
  readonly scmPrThread: "scm.pr.thread";
17
19
  readonly scmPrStatus: "scm.pr.status";
20
+ readonly scmPrFind: "scm.pr.find";
18
21
  readonly ciRunTrigger: "ci.run.trigger";
19
22
  readonly ciRunCancel: "ci.run.cancel";
20
23
  readonly deployDeployments: "deploy.deployments";
@@ -42,17 +45,43 @@ interface AskSpec {
42
45
  interface AskStep {
43
46
  readonly ask: AskSpec;
44
47
  }
48
+ /**
49
+ * A declarative condition over the run context. Exactly ONE key; operands are interpolated before
50
+ * evaluation. Deliberately not an expression language: four primitive tests cover the reference
51
+ * guards (refuse-if-closed, refuse-containers, skip-when-PR-exists) without opening a parser
52
+ * attack/maintenance surface.
53
+ */
54
+ interface StepCondition {
55
+ /** True when the interpolated value is present and not ''/false/null. */
56
+ readonly truthy?: string;
57
+ /** True when the interpolated value is absent, '', false, or null. */
58
+ readonly falsy?: string;
59
+ /** True when both interpolated operands are equal (string comparison). */
60
+ readonly equals?: readonly [string, string];
61
+ /** True when the interpolated operands differ (string comparison). */
62
+ readonly notEquals?: readonly [string, string];
63
+ }
64
+ /** A guard: when the condition is false the run STOPS with the (interpolated) message. */
65
+ interface RequireStep {
66
+ readonly require: StepCondition & {
67
+ readonly message: string;
68
+ };
69
+ }
45
70
  interface DoStep {
46
71
  readonly do: RecipeOp;
47
72
  /** Step parameters; string values may contain `${path}` references into the run context. */
48
73
  readonly with?: Record<string, unknown>;
49
74
  /** Context variable the step result is bound to. */
50
75
  readonly as?: string;
76
+ /** Run the step only when the condition holds; otherwise it is skipped (its `as` stays unset). */
77
+ readonly when?: StepCondition;
51
78
  }
52
79
  interface MessageStep {
53
80
  readonly message: string;
81
+ /** Emit the message only when the condition holds. */
82
+ readonly when?: StepCondition;
54
83
  }
55
- type Step = AskStep | DoStep | MessageStep;
84
+ type Step = AskStep | DoStep | MessageStep | RequireStep;
56
85
  interface Recipe {
57
86
  readonly name: string;
58
87
  readonly description?: string;
@@ -71,6 +100,7 @@ declare function recipeInputs(recipe: Recipe): RecipeInput[];
71
100
  declare function isAskStep(step: Step): step is AskStep;
72
101
  declare function isDoStep(step: Step): step is DoStep;
73
102
  declare function isMessageStep(step: Step): step is MessageStep;
103
+ declare function isRequireStep(step: Step): step is RequireStep;
74
104
  /**
75
105
  * Validate an untrusted object (typically `YAML.parse` of a recipe file) into a typed {@link Recipe}.
76
106
  * Throws {@link BaronError} (`RECIPE_PARSE`) with an actionable, pathed message on any violation.
@@ -124,13 +154,15 @@ interface RunRecipeResult {
124
154
  /**
125
155
  * Execute a recipe step by step against the injected ports, threading a context: `ask` steps gather
126
156
  * typed human input (skipped when pre-seeded), `do` steps call a primitive and bind its result,
127
- * `message` steps surface a line. All workflow opinion lives in the recipe; this engine is pure
128
- * mechanism (invariant #3) and does no role/native translation (that stays in the ports, #4).
157
+ * `message` steps surface a line, `require` steps are engine-enforced guards (decision #19: the
158
+ * rules live in the engine, not in agent judgement), and a `when:` condition skips a do/message
159
+ * step. All workflow opinion lives in the recipe; this engine is pure mechanism (invariant #3) and
160
+ * does no role/native translation (that stays in the ports, #4).
129
161
  */
130
162
  declare function runRecipe(recipe: Recipe, options: RunRecipeOptions): Promise<RunRecipeResult>;
131
163
 
132
164
  /** The recipes Baron ships out of the box, runnable by name (no file path). */
133
- declare const BUILTIN_RECIPE_NAMES: readonly ["task-start", "task-finish", "ship"];
165
+ declare const BUILTIN_RECIPE_NAMES: readonly ["task-new", "task-start", "task-finish", "ship"];
134
166
  type BuiltinRecipeName = (typeof BUILTIN_RECIPE_NAMES)[number];
135
167
  declare function isBuiltinRecipe(name: string): name is BuiltinRecipeName;
136
168
  /** Raw YAML of a built-in recipe (the canonical file — no inlined copy, so it can never drift). */
@@ -153,4 +185,4 @@ interface RecipeService {
153
185
  }
154
186
  declare function createRecipeService(ports: RecipePorts, root: string): RecipeService;
155
187
 
156
- export { ASK_TYPES, type AskSpec, type AskStep, type AskType, BUILTIN_RECIPE_NAMES, type BuiltinRecipeName, type DoStep, type MessageStep, RECIPE_OPS, type Recipe, type RecipeAsker, type RecipeContext, type RecipeInput, type RecipeOp, type RecipePorts, type RecipeService, type RecipeSummary, type RunRecipeOptions, type RunRecipeResult, type Step, createRecipeService, interpolate, isAskStep, isBuiltinRecipe, isDoStep, isMessageStep, isRecipeOp, loadBuiltinRecipe, loadBuiltinRecipeText, loadRecipe, parseRecipe, recipeInputs, runRecipe };
188
+ export { ASK_TYPES, type AskSpec, type AskStep, type AskType, BUILTIN_RECIPE_NAMES, type BuiltinRecipeName, type DoStep, type MessageStep, RECIPE_OPS, type Recipe, type RecipeAsker, type RecipeContext, type RecipeInput, type RecipeOp, type RecipePorts, type RecipeService, type RecipeSummary, type RequireStep, type RunRecipeOptions, type RunRecipeResult, type Step, type StepCondition, createRecipeService, interpolate, isAskStep, isBuiltinRecipe, isDoStep, isMessageStep, isRecipeOp, isRequireStep, loadBuiltinRecipe, loadBuiltinRecipeText, loadRecipe, parseRecipe, recipeInputs, runRecipe };
package/dist/index.js CHANGED
@@ -3,14 +3,17 @@ import { BaronError } from "@lonca/baron-core";
3
3
  import { parse as parseYaml } from "yaml";
4
4
  var RECIPE_OPS = {
5
5
  issueCreate: "issue.create",
6
+ issueGet: "issue.get",
6
7
  issueTransition: "issue.transition",
7
8
  issueComment: "issue.comment",
8
9
  issueLink: "issue.link",
10
+ issueAssign: "issue.assign",
9
11
  issueQuery: "issue.query",
10
12
  scmBranchCreate: "scm.branch.create",
11
13
  scmPrCreate: "scm.pr.create",
12
14
  scmPrThread: "scm.pr.thread",
13
15
  scmPrStatus: "scm.pr.status",
16
+ scmPrFind: "scm.pr.find",
14
17
  ciRunTrigger: "ci.run.trigger",
15
18
  ciRunCancel: "ci.run.cancel",
16
19
  deployDeployments: "deploy.deployments",
@@ -43,6 +46,9 @@ function isDoStep(step) {
43
46
  function isMessageStep(step) {
44
47
  return "message" in step;
45
48
  }
49
+ function isRequireStep(step) {
50
+ return "require" in step;
51
+ }
46
52
  var PARSE_CODE = "RECIPE_PARSE";
47
53
  function fail(message) {
48
54
  throw new BaronError(message, PARSE_CODE);
@@ -81,6 +87,41 @@ function parseAsk(raw, where) {
81
87
  }
82
88
  };
83
89
  }
90
+ var CONDITION_KEYS = ["truthy", "falsy", "equals", "notEquals"];
91
+ function parseCondition(raw, where) {
92
+ if (!isRecord(raw)) fail(`${where} must be an object.`);
93
+ const present = CONDITION_KEYS.filter((key2) => raw[key2] !== void 0);
94
+ if (present.length !== 1) {
95
+ fail(
96
+ `${where} must have exactly one of ${CONDITION_KEYS.join(", ")} (found: ${present.join(", ") || "none"}).`
97
+ );
98
+ }
99
+ const key = present[0];
100
+ const value = raw[key];
101
+ if (key === "truthy" || key === "falsy") {
102
+ if (typeof value !== "string" || value.length === 0) {
103
+ fail(`${where}.${key} must be a non-empty string.`);
104
+ }
105
+ return key === "truthy" ? { truthy: value } : { falsy: value };
106
+ }
107
+ if (!Array.isArray(value) || value.length !== 2 || value.some((v) => typeof v !== "string")) {
108
+ fail(`${where}.${key} must be an array of exactly two strings.`);
109
+ }
110
+ const pair = value;
111
+ return key === "equals" ? { equals: pair } : { notEquals: pair };
112
+ }
113
+ function parseRequire(raw, where) {
114
+ const req = raw.require;
115
+ if (!isRecord(req)) fail(`${where}: 'require' must be an object.`);
116
+ if (typeof req.message !== "string" || req.message.length === 0) {
117
+ fail(`${where}: require.message must be a non-empty string (it is what the user sees).`);
118
+ }
119
+ const { message, ...condition } = req;
120
+ return { require: { ...parseCondition(condition, `${where}.require`), message } };
121
+ }
122
+ function parseWhen(raw, where) {
123
+ return raw.when === void 0 ? void 0 : parseCondition(raw.when, `${where}.when`);
124
+ }
84
125
  function parseDo(raw, where) {
85
126
  const op = raw.do;
86
127
  if (typeof op !== "string" || !isRecipeOp(op)) {
@@ -93,25 +134,29 @@ function parseDo(raw, where) {
93
134
  if (as !== void 0 && (typeof as !== "string" || as.length === 0)) {
94
135
  fail(`${where}: 'as' must be a non-empty string.`);
95
136
  }
137
+ const when = parseWhen(raw, where);
96
138
  return {
97
139
  do: op,
98
140
  ...withParams !== void 0 ? { with: withParams } : {},
99
- ...as !== void 0 ? { as } : {}
141
+ ...as !== void 0 ? { as } : {},
142
+ ...when !== void 0 ? { when } : {}
100
143
  };
101
144
  }
102
145
  function parseStep(raw, index) {
103
146
  const where = `steps[${index}]`;
104
147
  if (!isRecord(raw)) fail(`${where} must be an object.`);
105
- const kinds = ["ask", "do", "message"].filter((kind) => kind in raw);
148
+ const kinds = ["ask", "do", "message", "require"].filter((kind) => kind in raw);
106
149
  if (kinds.length !== 1) {
107
150
  fail(
108
- `${where} must have exactly one of 'ask', 'do', or 'message' (found: ${kinds.join(", ") || "none"}).`
151
+ `${where} must have exactly one of 'ask', 'do', 'message', or 'require' (found: ${kinds.join(", ") || "none"}).`
109
152
  );
110
153
  }
111
154
  if ("ask" in raw) return parseAsk(raw, where);
112
155
  if ("do" in raw) return parseDo(raw, where);
156
+ if ("require" in raw) return parseRequire(raw, where);
113
157
  if (typeof raw.message !== "string") fail(`${where}: 'message' must be a string.`);
114
- return { message: raw.message };
158
+ const when = parseWhen(raw, where);
159
+ return { message: raw.message, ...when !== void 0 ? { when } : {} };
115
160
  }
116
161
  function parseRecipe(raw) {
117
162
  if (!isRecord(raw)) fail("recipe must be an object.");
@@ -336,10 +381,14 @@ async function dispatchOp(ports, op, params) {
336
381
  ...optStrArray(params, "labels", op) !== void 0 ? { labels: optStrArray(params, "labels", op) } : {},
337
382
  ...optStr(params, "initialRole", op) !== void 0 ? { initialRole: reqRole(params, "initialRole", op) } : {}
338
383
  });
384
+ case RECIPE_OPS.issueGet:
385
+ return issues(ports, op).get(reqStr(params, "id", op));
339
386
  case RECIPE_OPS.issueTransition:
340
387
  return issues(ports, op).transition(reqStr(params, "id", op), reqRole(params, "role", op));
341
388
  case RECIPE_OPS.issueComment:
342
389
  return issues(ports, op).comment(reqStr(params, "id", op), reqStr(params, "body", op));
390
+ case RECIPE_OPS.issueAssign:
391
+ return issues(ports, op).assign(reqStr(params, "id", op), reqStr(params, "assignee", op));
343
392
  case RECIPE_OPS.issueLink:
344
393
  return issues(ports, op).link(
345
394
  reqStr(params, "fromId", op),
@@ -348,9 +397,11 @@ async function dispatchOp(ports, op, params) {
348
397
  );
349
398
  case RECIPE_OPS.issueQuery: {
350
399
  const limit = optNum(params, "limit", op);
400
+ const assignee = optStr(params, "assignee", op);
351
401
  const query = {
352
402
  ...optStr(params, "role", op) !== void 0 ? { role: reqRole(params, "role", op) } : {},
353
403
  ...optStr(params, "typeRole", op) !== void 0 ? { typeRole: reqTypeRole(params, "typeRole", op) } : {},
404
+ ...assignee !== void 0 ? { assignee } : {},
354
405
  ...limit !== void 0 ? { limit } : {}
355
406
  };
356
407
  return issues(ports, op).query(query);
@@ -375,6 +426,10 @@ async function dispatchOp(ports, op, params) {
375
426
  );
376
427
  case RECIPE_OPS.scmPrStatus:
377
428
  return scm(ports, op).prStatus(reqStr(params, "pullRequestId", op));
429
+ case RECIPE_OPS.scmPrFind: {
430
+ const found = await scm(ports, op).prForBranch(reqStr(params, "sourceBranch", op));
431
+ return found ?? null;
432
+ }
378
433
  case RECIPE_OPS.ciRunTrigger: {
379
434
  const ref = optStr(params, "ref", op);
380
435
  const variables = optStrRecord(params, "variables", op);
@@ -446,6 +501,26 @@ async function dispatchOp(ports, op, params) {
446
501
  }
447
502
  }
448
503
  }
504
+ var REQUIRE = "RECIPE_REQUIRE";
505
+ function isTruthy(value) {
506
+ return value !== void 0 && value !== null && value !== "" && value !== false;
507
+ }
508
+ function asComparable(value) {
509
+ return value === void 0 || value === null ? "" : String(value);
510
+ }
511
+ function evalCondition(condition, context) {
512
+ if (condition.truthy !== void 0) return isTruthy(interpolate(condition.truthy, context));
513
+ if (condition.falsy !== void 0) return !isTruthy(interpolate(condition.falsy, context));
514
+ if (condition.equals !== void 0) {
515
+ const [a, b] = condition.equals;
516
+ return asComparable(interpolate(a, context)) === asComparable(interpolate(b, context));
517
+ }
518
+ if (condition.notEquals !== void 0) {
519
+ const [a, b] = condition.notEquals;
520
+ return asComparable(interpolate(a, context)) !== asComparable(interpolate(b, context));
521
+ }
522
+ throw new BaronError2("Empty step condition.", REQUIRE);
523
+ }
449
524
  async function runRecipe(recipe, options) {
450
525
  const context = { ...options.inputs };
451
526
  for (const step of recipe.steps) {
@@ -461,11 +536,20 @@ async function runRecipe(recipe, options) {
461
536
  }
462
537
  continue;
463
538
  }
539
+ if (isRequireStep(step)) {
540
+ const { message, ...condition } = step.require;
541
+ if (!evalCondition(condition, context)) {
542
+ throw new BaronError2(String(interpolate(message, context)), REQUIRE);
543
+ }
544
+ continue;
545
+ }
464
546
  if (isMessageStep(step)) {
547
+ if (step.when !== void 0 && !evalCondition(step.when, context)) continue;
465
548
  options.asker.note(String(interpolate(step.message, context)));
466
549
  continue;
467
550
  }
468
551
  if (isDoStep(step)) {
552
+ if (step.when !== void 0 && !evalCondition(step.when, context)) continue;
469
553
  const params = interpolate(step.with ?? {}, context) ?? {};
470
554
  const result = await dispatchOp(options.ports, step.do, params);
471
555
  if (step.as !== void 0) context[step.as] = result;
@@ -478,7 +562,7 @@ async function runRecipe(recipe, options) {
478
562
  import { readFileSync } from "fs";
479
563
  import { fileURLToPath } from "url";
480
564
  var RECIPES_DIR = fileURLToPath(new URL("../recipes/", import.meta.url));
481
- var BUILTIN_RECIPE_NAMES = ["task-start", "task-finish", "ship"];
565
+ var BUILTIN_RECIPE_NAMES = ["task-new", "task-start", "task-finish", "ship"];
482
566
  function isBuiltinRecipe(name) {
483
567
  return BUILTIN_RECIPE_NAMES.includes(name);
484
568
  }
@@ -567,6 +651,7 @@ export {
567
651
  isDoStep,
568
652
  isMessageStep,
569
653
  isRecipeOp,
654
+ isRequireStep,
570
655
  loadBuiltinRecipe,
571
656
  loadBuiltinRecipeText,
572
657
  loadRecipe,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lonca/baron-recipes",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "main": "./dist/index.js",
@@ -17,13 +17,13 @@
17
17
  ],
18
18
  "dependencies": {
19
19
  "yaml": "^2.6.0",
20
- "@lonca/baron-core": "0.1.0",
21
- "@lonca/baron-knowledge-loop": "0.1.0"
20
+ "@lonca/baron-core": "0.3.0",
21
+ "@lonca/baron-knowledge-loop": "0.3.0"
22
22
  },
23
23
  "devDependencies": {
24
24
  "@types/node": "^22.0.0",
25
- "@lonca/baron-conformance": "0.1.0",
26
- "@lonca/baron-adapter-github": "0.1.0"
25
+ "@lonca/baron-adapter-github": "0.3.0",
26
+ "@lonca/baron-conformance": "0.3.0"
27
27
  },
28
28
  "description": "Baron recipe engine: run declarative YAML workflows over Baron primitives, deterministically.",
29
29
  "keywords": [
@@ -1,22 +1,39 @@
1
1
  name: task-finish
2
- description: Open a pull request for a branch and move its issue to review.
2
+ description: Open a draft pull request for a work-item branch and post the PR link back on the item. Idempotent — an existing open PR is reported, never duplicated. Deliberately does NOT move the item's role; review state changes when the PR merges.
3
3
  steps:
4
4
  - ask: { as: issueId, type: text, message: "Issue id?" }
5
5
  - ask: { as: branch, type: text, message: "Source branch?" }
6
6
  - ask: { as: title, type: text, message: "Pull request title?" }
7
+ # Idempotency probe: the engine (not the agent) decides create-vs-report (decision #19).
8
+ - do: scm.pr.find
9
+ as: existingPr
10
+ with:
11
+ sourceBranch: ${branch}
12
+ - message: "PR already open for ${branch}: ${existingPr.url} — not duplicating."
13
+ when:
14
+ truthy: "${existingPr}"
7
15
  - do: scm.pr.create
8
16
  as: pr
17
+ when:
18
+ falsy: "${existingPr}"
9
19
  with:
10
20
  # targetBranch omitted — defaults to the repo's default branch, keeping the recipe portable.
11
21
  title: ${title}
12
22
  sourceBranch: ${branch}
13
23
  draft: true
14
- - do: issue.transition
15
- with:
16
- id: ${issueId}
17
- role: in_review
18
24
  - do: scm.pr.thread
25
+ when:
26
+ falsy: "${existingPr}"
19
27
  with:
20
28
  pullRequestId: ${pr.id}
21
29
  body: "Opened for issue ${issueId}."
22
- - message: "Opened PR ${pr.url}; moved ${issueId} to review."
30
+ # Give the card full context: reviewers/QA opening the work item see the PR without digging.
31
+ - do: issue.comment
32
+ when:
33
+ falsy: "${existingPr}"
34
+ with:
35
+ id: ${issueId}
36
+ body: "PR opened: ${pr.url} (from ${branch}). Role unchanged — moves to in_review on merge."
37
+ - message: "Opened PR ${pr.url} for ${issueId}. Role unchanged; transition on merge (task-move)."
38
+ when:
39
+ falsy: "${existingPr}"
@@ -0,0 +1,15 @@
1
+ name: task-new
2
+ description: Create a NEW work item (type + optional parent + optional starting role). Starting work on it afterwards is `task-start`.
3
+ steps:
4
+ - ask: { as: title, type: text, message: "Work item title?" }
5
+ - ask: { as: typeRole, type: choice, message: "Type role?", choices: [task, bug, story] }
6
+ - ask: { as: parentId, type: text, message: "Parent id (empty for none)?", optional: true }
7
+ - do: issue.create
8
+ as: issue
9
+ with:
10
+ title: ${title}
11
+ typeRole: ${typeRole}
12
+ # Exactly-one-reference interpolation: an unanswered optional ask stays truly unset,
13
+ # so hierarchy/gap negotiation only runs when a parent was actually given.
14
+ parentId: ${parentId}
15
+ - message: "Created ${issue.key} (${issue.nativeType}): ${issue.title}"
@@ -1,18 +1,25 @@
1
1
  name: task-start
2
- description: Create a task, branch for it, and move it into progress.
2
+ description: Start work on an EXISTING work item — load it, cut its canonical branch, move it to in_progress, and note the branch on the item. (Creating a new item is `task-new`.)
3
3
  steps:
4
- - ask: { as: title, type: text, message: "Task title?" }
5
- - do: issue.create
4
+ - ask: { as: issueId, type: text, message: "Work item id?" }
5
+ - do: issue.get
6
6
  as: issue
7
7
  with:
8
- title: ${title}
9
- typeRole: task
8
+ id: ${issueId}
9
+ # Engine-enforced guards (decision #19): a failed require STOPS the run before any mutation.
10
+ - require:
11
+ notEquals: ["${issue.role}", "done"]
12
+ message: "${issue.key} is already done — reopen it (task-move) or pick an active item."
13
+ - require:
14
+ truthy: "${issue.branchName}"
15
+ message: "${issue.key} (${issue.nativeType}) has no canonical branch — containers (epic/initiative) and unmapped types are never branched. Pick a child story/task/bug."
16
+ # ${issue.branchName} is the core-derived canonical name (<prefix>/<id>-<slug>) — every agent and
17
+ # recipe derives the SAME name for the same item.
10
18
  - do: scm.branch.create
11
19
  as: branch
12
20
  with:
13
- # fromBranch omitted on purpose — defaults to the repo's default branch (main/release/master),
14
- # so this recipe is portable across repos instead of assuming 'main'.
15
- name: feature/${issue.id}
21
+ # fromBranch omitted on purpose — defaults to the repo's default branch (main/release/master).
22
+ name: ${issue.branchName}
16
23
  - do: issue.transition
17
24
  as: issue
18
25
  with:
@@ -21,5 +28,5 @@ steps:
21
28
  - do: issue.comment
22
29
  with:
23
30
  id: ${issue.id}
24
- body: "Started on branch ${branch.name}."
25
- - message: "Task ${issue.key} is in progress on ${branch.name}."
31
+ body: "Branch created: ${branch.name}."
32
+ - message: "${issue.key} is in progress on ${branch.name}."