@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 +33 -4
- package/dist/index.js +78 -4
- package/package.json +5 -5
- package/recipes/task-finish.yaml +17 -1
- package/recipes/task-start.yaml +8 -2
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
|
|
131
|
-
*
|
|
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 '
|
|
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
|
-
|
|
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.
|
|
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.
|
|
21
|
-
"@lonca/baron-knowledge-loop": "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.
|
|
26
|
-
"@lonca/baron-conformance": "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": [
|
package/recipes/task-finish.yaml
CHANGED
|
@@ -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
|
|
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}"
|
package/recipes/task-start.yaml
CHANGED
|
@@ -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.
|
|
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:
|