@lonca/baron-recipes 0.2.0 → 0.4.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
@@ -45,17 +45,43 @@ interface AskSpec {
45
45
  interface AskStep {
46
46
  readonly ask: AskSpec;
47
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
+ }
48
70
  interface DoStep {
49
71
  readonly do: RecipeOp;
50
72
  /** Step parameters; string values may contain `${path}` references into the run context. */
51
73
  readonly with?: Record<string, unknown>;
52
74
  /** Context variable the step result is bound to. */
53
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;
54
78
  }
55
79
  interface MessageStep {
56
80
  readonly message: string;
81
+ /** Emit the message only when the condition holds. */
82
+ readonly when?: StepCondition;
57
83
  }
58
- type Step = AskStep | DoStep | MessageStep;
84
+ type Step = AskStep | DoStep | MessageStep | RequireStep;
59
85
  interface Recipe {
60
86
  readonly name: string;
61
87
  readonly description?: string;
@@ -74,6 +100,7 @@ declare function recipeInputs(recipe: Recipe): RecipeInput[];
74
100
  declare function isAskStep(step: Step): step is AskStep;
75
101
  declare function isDoStep(step: Step): step is DoStep;
76
102
  declare function isMessageStep(step: Step): step is MessageStep;
103
+ declare function isRequireStep(step: Step): step is RequireStep;
77
104
  /**
78
105
  * Validate an untrusted object (typically `YAML.parse` of a recipe file) into a typed {@link Recipe}.
79
106
  * Throws {@link BaronError} (`RECIPE_PARSE`) with an actionable, pathed message on any violation.
@@ -127,8 +154,10 @@ interface RunRecipeResult {
127
154
  /**
128
155
  * Execute a recipe step by step against the injected ports, threading a context: `ask` steps gather
129
156
  * typed human input (skipped when pre-seeded), `do` steps call a primitive and bind its result,
130
- * `message` steps surface a line. All workflow opinion lives in the recipe; this engine is pure
131
- * 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).
132
161
  */
133
162
  declare function runRecipe(recipe: Recipe, options: RunRecipeOptions): Promise<RunRecipeResult>;
134
163
 
@@ -156,4 +185,4 @@ interface RecipeService {
156
185
  }
157
186
  declare function createRecipeService(ports: RecipePorts, root: string): RecipeService;
158
187
 
159
- 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
@@ -46,6 +46,9 @@ function isDoStep(step) {
46
46
  function isMessageStep(step) {
47
47
  return "message" in step;
48
48
  }
49
+ function isRequireStep(step) {
50
+ return "require" in step;
51
+ }
49
52
  var PARSE_CODE = "RECIPE_PARSE";
50
53
  function fail(message) {
51
54
  throw new BaronError(message, PARSE_CODE);
@@ -84,6 +87,41 @@ function parseAsk(raw, where) {
84
87
  }
85
88
  };
86
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
+ }
87
125
  function parseDo(raw, where) {
88
126
  const op = raw.do;
89
127
  if (typeof op !== "string" || !isRecipeOp(op)) {
@@ -96,25 +134,29 @@ function parseDo(raw, where) {
96
134
  if (as !== void 0 && (typeof as !== "string" || as.length === 0)) {
97
135
  fail(`${where}: 'as' must be a non-empty string.`);
98
136
  }
137
+ const when = parseWhen(raw, where);
99
138
  return {
100
139
  do: op,
101
140
  ...withParams !== void 0 ? { with: withParams } : {},
102
- ...as !== void 0 ? { as } : {}
141
+ ...as !== void 0 ? { as } : {},
142
+ ...when !== void 0 ? { when } : {}
103
143
  };
104
144
  }
105
145
  function parseStep(raw, index) {
106
146
  const where = `steps[${index}]`;
107
147
  if (!isRecord(raw)) fail(`${where} must be an object.`);
108
- const kinds = ["ask", "do", "message"].filter((kind) => kind in raw);
148
+ const kinds = ["ask", "do", "message", "require"].filter((kind) => kind in raw);
109
149
  if (kinds.length !== 1) {
110
150
  fail(
111
- `${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"}).`
112
152
  );
113
153
  }
114
154
  if ("ask" in raw) return parseAsk(raw, where);
115
155
  if ("do" in raw) return parseDo(raw, where);
156
+ if ("require" in raw) return parseRequire(raw, where);
116
157
  if (typeof raw.message !== "string") fail(`${where}: 'message' must be a string.`);
117
- return { message: raw.message };
158
+ const when = parseWhen(raw, where);
159
+ return { message: raw.message, ...when !== void 0 ? { when } : {} };
118
160
  }
119
161
  function parseRecipe(raw) {
120
162
  if (!isRecord(raw)) fail("recipe must be an object.");
@@ -355,9 +397,11 @@ async function dispatchOp(ports, op, params) {
355
397
  );
356
398
  case RECIPE_OPS.issueQuery: {
357
399
  const limit = optNum(params, "limit", op);
400
+ const assignee = optStr(params, "assignee", op);
358
401
  const query = {
359
402
  ...optStr(params, "role", op) !== void 0 ? { role: reqRole(params, "role", op) } : {},
360
403
  ...optStr(params, "typeRole", op) !== void 0 ? { typeRole: reqTypeRole(params, "typeRole", op) } : {},
404
+ ...assignee !== void 0 ? { assignee } : {},
361
405
  ...limit !== void 0 ? { limit } : {}
362
406
  };
363
407
  return issues(ports, op).query(query);
@@ -457,6 +501,26 @@ async function dispatchOp(ports, op, params) {
457
501
  }
458
502
  }
459
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
+ }
460
524
  async function runRecipe(recipe, options) {
461
525
  const context = { ...options.inputs };
462
526
  for (const step of recipe.steps) {
@@ -472,11 +536,20 @@ async function runRecipe(recipe, options) {
472
536
  }
473
537
  continue;
474
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
+ }
475
546
  if (isMessageStep(step)) {
547
+ if (step.when !== void 0 && !evalCondition(step.when, context)) continue;
476
548
  options.asker.note(String(interpolate(step.message, context)));
477
549
  continue;
478
550
  }
479
551
  if (isDoStep(step)) {
552
+ if (step.when !== void 0 && !evalCondition(step.when, context)) continue;
480
553
  const params = interpolate(step.with ?? {}, context) ?? {};
481
554
  const result = await dispatchOp(options.ports, step.do, params);
482
555
  if (step.as !== void 0) context[step.as] = result;
@@ -578,6 +651,7 @@ export {
578
651
  isDoStep,
579
652
  isMessageStep,
580
653
  isRecipeOp,
654
+ isRequireStep,
581
655
  loadBuiltinRecipe,
582
656
  loadBuiltinRecipeText,
583
657
  loadRecipe,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lonca/baron-recipes",
3
- "version": "0.2.0",
3
+ "version": "0.4.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.2.0",
21
- "@lonca/baron-knowledge-loop": "0.2.0"
20
+ "@lonca/baron-core": "0.4.0",
21
+ "@lonca/baron-knowledge-loop": "0.4.0"
22
22
  },
23
23
  "devDependencies": {
24
24
  "@types/node": "^22.0.0",
25
- "@lonca/baron-adapter-github": "0.2.0",
26
- "@lonca/baron-conformance": "0.2.0"
25
+ "@lonca/baron-adapter-github": "0.4.0",
26
+ "@lonca/baron-conformance": "0.4.0"
27
27
  },
28
28
  "description": "Baron recipe engine: run declarative YAML workflows over Baron primitives, deterministically.",
29
29
  "keywords": [
@@ -1,23 +1,39 @@
1
1
  name: task-finish
2
- description: Open a draft pull request for a work-item branch and post the PR link back on the item. Deliberately does NOT move the item's role review/testing state changes when the PR actually merges, not when it opens.
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
24
  - do: scm.pr.thread
25
+ when:
26
+ falsy: "${existingPr}"
15
27
  with:
16
28
  pullRequestId: ${pr.id}
17
29
  body: "Opened for issue ${issueId}."
18
30
  # Give the card full context: reviewers/QA opening the work item see the PR without digging.
19
31
  - do: issue.comment
32
+ when:
33
+ falsy: "${existingPr}"
20
34
  with:
21
35
  id: ${issueId}
22
36
  body: "PR opened: ${pr.url} (from ${branch}). Role unchanged — moves to in_review on merge."
23
37
  - message: "Opened PR ${pr.url} for ${issueId}. Role unchanged; transition on merge (task-move)."
38
+ when:
39
+ falsy: "${existingPr}"
@@ -6,9 +6,15 @@ steps:
6
6
  as: issue
7
7
  with:
8
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."
9
16
  # ${issue.branchName} is the core-derived canonical name (<prefix>/<id>-<slug>) — every agent and
10
- # recipe derives the SAME name for the same item. It is unset for container types (epic/initiative),
11
- # which makes this step fail loudly rather than branch on an epic.
17
+ # recipe derives the SAME name for the same item.
12
18
  - do: scm.branch.create
13
19
  as: branch
14
20
  with: