@farmslot/protocol 0.81.0 → 0.82.1

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 (38) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/client-cache/cache.d.ts.map +1 -1
  3. package/dist/client-cache/cache.js +20 -5
  4. package/dist/client-cache/cache.js.map +1 -1
  5. package/dist/contracts/evals.d.ts +28 -1
  6. package/dist/contracts/evals.d.ts.map +1 -1
  7. package/dist/contracts/evals.js +44 -1
  8. package/dist/contracts/evals.js.map +1 -1
  9. package/dist/contracts/recipes.d.ts +4 -0
  10. package/dist/contracts/recipes.d.ts.map +1 -1
  11. package/dist/contracts/recipes.js +3 -0
  12. package/dist/contracts/recipes.js.map +1 -1
  13. package/dist/contracts/runs.d.ts +1 -1
  14. package/dist/contracts/runs.d.ts.map +1 -1
  15. package/dist/contracts/runs.js +7 -1
  16. package/dist/contracts/runs.js.map +1 -1
  17. package/dist/contracts/worker-terminal.d.ts +2 -0
  18. package/dist/contracts/worker-terminal.d.ts.map +1 -1
  19. package/dist/contracts/worker-terminal.js.map +1 -1
  20. package/dist/rpc/eval.d.ts +5 -0
  21. package/dist/rpc/eval.d.ts.map +1 -1
  22. package/dist/rpc/eval.js.map +1 -1
  23. package/dist/rpc/nodes.d.ts +2 -0
  24. package/dist/rpc/nodes.d.ts.map +1 -1
  25. package/dist/rpc/nodes.js.map +1 -1
  26. package/dist/transport/node-link-operations.d.ts +13 -0
  27. package/dist/transport/node-link-operations.d.ts.map +1 -1
  28. package/dist/transport/node-link-operations.js +15 -1
  29. package/dist/transport/node-link-operations.js.map +1 -1
  30. package/dist/version.d.ts +1 -1
  31. package/dist/version.js +1 -1
  32. package/package.json +15 -3
  33. package/recipe-shape.d.mts +91 -0
  34. package/recipe-shape.mjs +532 -0
  35. package/scripts/acceptance.d.mts +6 -0
  36. package/scripts/acceptance.mjs +11 -0
  37. package/scripts/recipe-library-identity.d.mts +17 -0
  38. package/scripts/recipe-library-identity.mjs +45 -0
@@ -0,0 +1,532 @@
1
+ import { sha256 } from '@noble/hashes/sha256';
2
+ import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils';
3
+
4
+ import {
5
+ filterWorkerAcceptanceCriteria,
6
+ registeredAcceptanceCriteria,
7
+ } from './scripts/acceptance.mjs';
8
+ import {
9
+ BUILT_IN_RECIPE_PLATFORMS,
10
+ recipeFileIdentity,
11
+ } from './scripts/recipe-library-identity.mjs';
12
+ const POLICY = {
13
+ short: {
14
+ good: 8,
15
+ max: 40,
16
+ },
17
+ positive: {
18
+ short: 0.6,
19
+ composed: 0.8,
20
+ parameterized: 0.7,
21
+ },
22
+ skipValueKeys:
23
+ '^(test_?id|selector|locator|route|screen|page|text|label|key|role|target|path|url|file|dir|expect|expected|equals|contains|match|text_?match|status|state|mode|platform|visibility|direction|surface|title|message|name|description|reason|kind|format|assert|source|stream|cmd|command|cwd|artifacts|category|covers|expression|fail_when|probes|scope|require_features|pixels|distance|width|height|x|y|index|count|timeout|interval|retries|attempts|delay|poll|limit)$|_ms$|^css_|timeout|delay|interval|duration|poll',
24
+ selectorKeys: '^(test_?id|selector|locator|route|text|label|ref|key)$',
25
+ };
26
+ Object.freeze(POLICY.short);
27
+ Object.freeze(POLICY.positive);
28
+ Object.freeze(POLICY);
29
+ const POLICY_VERSION = 1;
30
+ const STRUCTURAL = new Set([
31
+ 'action',
32
+ 'intent',
33
+ 'next',
34
+ 'cases',
35
+ 'default',
36
+ 'proves',
37
+ 'claimBindings',
38
+ 'ref',
39
+ 'review',
40
+ 'id',
41
+ ]);
42
+ const skipValue = new RegExp(POLICY.skipValueKeys, 'i');
43
+ const selectorKey = new RegExp(POLICY.selectorKeys, 'i');
44
+ const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
45
+
46
+ function canonical(value) {
47
+ if (Array.isArray(value)) return value.map(canonical);
48
+ if (record(value))
49
+ return Object.fromEntries(
50
+ Object.keys(value)
51
+ .sort()
52
+ .map((key) => [key, canonical(value[key])]),
53
+ );
54
+ return value;
55
+ }
56
+ function stableDigest(value) {
57
+ return bytesToHex(sha256(utf8ToBytes(JSON.stringify(canonical(value)))));
58
+ }
59
+ function graphOrder(nodes, start) {
60
+ const seen = new Set(),
61
+ order = [],
62
+ stack = [start];
63
+ while (stack.length) {
64
+ const id = stack.pop();
65
+ if (typeof id !== 'string' || !record(nodes[id]) || seen.has(id)) continue;
66
+ seen.add(id);
67
+ const node = nodes[id];
68
+ if (node.action === 'end') continue;
69
+ order.push(id);
70
+ const edges = [node.next, ...Object.values(record(node.cases) ? node.cases : {}), node.default];
71
+ stack.push(...edges.reverse());
72
+ }
73
+ return order;
74
+ }
75
+ function recipeShapeSelector(node) {
76
+ for (const [key, value] of Object.entries(node))
77
+ if (
78
+ !STRUCTURAL.has(key) &&
79
+ selectorKey.test(key) &&
80
+ ['string', 'number'].includes(typeof value)
81
+ )
82
+ return `${key}=${value}`;
83
+ return null;
84
+ }
85
+ function recipeShapeNodeToken(node) {
86
+ const action = node.action ?? '?';
87
+ let selector = recipeShapeSelector(node);
88
+ if (!selector) return action;
89
+ if (selector.includes('{{')) {
90
+ selector = selector.replace(/\{\{[^}]*\}\}/g, '*');
91
+ if (!/\w/.test(selector.slice(selector.indexOf('=') + 1).replaceAll('*', ''))) return action;
92
+ }
93
+ return `${action}|${selector}`;
94
+ }
95
+ function isRecipeShapeObservation(action = '') {
96
+ return (
97
+ ['wait', 'app.hud', 'end'].includes(action) ||
98
+ action.startsWith('assert') ||
99
+ action.includes('.assert_') ||
100
+ action.includes('.read_') ||
101
+ ['wait_for', 'screenshot', 'capture_surface'].some((suffix) => action.endsWith(suffix))
102
+ );
103
+ }
104
+ function recipeShapeSignature(tokens) {
105
+ return tokens.map((token) =>
106
+ isRecipeShapeObservation(token.split('|')[0]) ? token.split('|')[0] : token,
107
+ );
108
+ }
109
+ function inspectRecipeShape(recipe) {
110
+ const workflow = recipe.workflow ?? {},
111
+ nodes = workflow.nodes ?? {};
112
+ const reachable = [
113
+ ...new Set([...graphOrder(nodes, workflow.entry), ...graphOrder(nodes, workflow.teardown)]),
114
+ ];
115
+ const order = [
116
+ ...reachable,
117
+ ...Object.keys(nodes).filter(
118
+ (id) => !reachable.includes(id) && record(nodes[id]) && nodes[id].action !== 'end',
119
+ ),
120
+ ];
121
+ const tokens = order.map((id) => recipeShapeNodeToken(nodes[id]));
122
+ return { order, reachable, tokens, signature: recipeShapeSignature(tokens) };
123
+ }
124
+ function valueLeaves(value, path, out) {
125
+ if (Array.isArray(value))
126
+ value.forEach((item, index) => valueLeaves(item, [...path, String(index)], out));
127
+ else if (record(value))
128
+ Object.entries(value).forEach(([key, item]) => valueLeaves(item, [...path, key], out));
129
+ else if (typeof value === 'string' && value.includes('{{')) out.templated++;
130
+ else if (
131
+ (typeof value === 'string' && value !== '') ||
132
+ (typeof value === 'number' && Number.isFinite(value))
133
+ )
134
+ out.literals.push(path.join('.'));
135
+ }
136
+ function nodeValues(node, id, out) {
137
+ for (const [key, value] of Object.entries(node)) {
138
+ if (STRUCTURAL.has(key) || skipValue.test(key)) continue;
139
+ if (key === 'params' && record(value)) {
140
+ for (const [parameter, item] of Object.entries(value))
141
+ if (!skipValue.test(parameter)) valueLeaves(item, [id, key, parameter], out);
142
+ } else valueLeaves(value, [id, key], out);
143
+ }
144
+ }
145
+ function globRegex(pattern) {
146
+ let result = '^';
147
+ for (let index = 0; index < pattern.length; index++) {
148
+ const char = pattern[index];
149
+ if (char === '*') result += '.*';
150
+ else if (char === '?') result += '.';
151
+ else if (char === '[') {
152
+ let end = index + 1;
153
+ if (pattern[end] === '!') end++;
154
+ if (pattern[end] === ']') end++;
155
+ while (end < pattern.length && pattern[end] !== ']') end++;
156
+ if (end === pattern.length) result += '\\[';
157
+ else {
158
+ let content = pattern.slice(index + 1, end);
159
+ if (content.includes('-')) {
160
+ const chunks = [];
161
+ let beginning = index + 1,
162
+ scan = beginning + (pattern[beginning] === '!' ? 2 : 1);
163
+ for (;;) {
164
+ const dash = pattern.indexOf('-', scan);
165
+ if (dash < 0 || dash >= end) break;
166
+ chunks.push(pattern.slice(beginning, dash));
167
+ beginning = dash + 1;
168
+ scan = dash + 3;
169
+ }
170
+ const tail = pattern.slice(beginning, end);
171
+ if (tail) chunks.push(tail);
172
+ else chunks[chunks.length - 1] += '-';
173
+ // Preserve the dataset's fnmatchcase handling of reversed/empty ranges.
174
+ for (let part = chunks.length - 1; part > 0; part--) {
175
+ if (chunks[part - 1].slice(-1) > chunks[part][0]) {
176
+ chunks[part - 1] = chunks[part - 1].slice(0, -1) + chunks[part].slice(1);
177
+ chunks.splice(part, 1);
178
+ }
179
+ }
180
+ content = chunks
181
+ .map((chunk) => chunk.replaceAll('\\', '\\\\').replaceAll('-', '\\-'))
182
+ .join('-');
183
+ } else content = content.replaceAll('\\', '\\\\');
184
+ content = content.replace(/[&~|]/g, '\\$&');
185
+ if (!content) result += '(?!)';
186
+ else if (content === '!') result += '.';
187
+ else {
188
+ if (content[0] === '!') content = '^' + content.slice(1);
189
+ else if (['^', '['].includes(content[0])) content = '\\' + content;
190
+ result += `[${content}]`;
191
+ }
192
+ index = end;
193
+ }
194
+ } else result += char.replace(/[\\^$+?.()|{}\[\]]/g, '\\$&');
195
+ }
196
+ return new RegExp(result + '$', 's');
197
+ }
198
+ function sameToken(token, pattern, patterns) {
199
+ if (pattern.includes('*') && !patterns.has(pattern)) patterns.set(pattern, globRegex(pattern));
200
+ return (
201
+ token === pattern ||
202
+ (!pattern.includes('|') && token.split('|')[0] === pattern) ||
203
+ (pattern.includes('*') && patterns.get(pattern).test(token))
204
+ );
205
+ }
206
+ function roundScore(value) {
207
+ const scaled = value * 1000,
208
+ floor = Math.floor(scaled),
209
+ fraction = scaled - floor;
210
+ return (fraction === 0.5 ? floor + (floor % 2) : Math.round(scaled)) / 1000;
211
+ }
212
+ function scoreRecipeShape(input) {
213
+ const recipe = input.recipe;
214
+ if (!record(recipe) || !record(recipe.workflow) || !record(recipe.workflow.nodes))
215
+ throw new TypeError('Expected a Recipe v1 workflow.nodes object');
216
+ const catalog = input.catalog ?? { available: false, blocks: [], sources: [] };
217
+ const blocks = catalog.available ? (catalog.blocks ?? []) : [];
218
+ if (blocks.length > 2048 || utf8ToBytes(JSON.stringify(catalog)).length > 8 * 1024 * 1024)
219
+ throw new TypeError('Shape catalog exceeds 2048 blocks or 8 MiB');
220
+ const inspected = inspectRecipeShape(recipe),
221
+ nodes = recipe.workflow.nodes,
222
+ count = inspected.order.length;
223
+ const calls = inspected.order
224
+ .filter((id) => nodes[id].action === 'call')
225
+ .map((id) => nodes[id].ref);
226
+ const values = { templated: 0, literals: [] };
227
+ inspected.order.forEach((id) => nodeValues(nodes[id], id, values));
228
+ const positions = new Set(),
229
+ duplicates = [];
230
+ const patterns = new Map();
231
+ for (const block of blocks) {
232
+ const signature = block.sig ?? block.tokens ?? [];
233
+ if (!signature.length) continue;
234
+ for (let start = 0; start <= inspected.tokens.length - signature.length; start++) {
235
+ if (
236
+ !signature.every((token, offset) =>
237
+ sameToken(inspected.tokens[start + offset], token, patterns),
238
+ )
239
+ )
240
+ continue;
241
+ const span = Array.from({ length: signature.length }, (_, offset) => start + offset);
242
+ if (span.every((index) => positions.has(index))) continue;
243
+ span.forEach((index) => positions.add(index));
244
+ duplicates.push({
245
+ flow: block.id,
246
+ ...(block.ref ? { ref: block.ref } : {}),
247
+ nodes: inspected.order.slice(start, start + signature.length),
248
+ });
249
+ }
250
+ }
251
+ const short =
252
+ count <= POLICY.short.good
253
+ ? 1
254
+ : Math.max(
255
+ 0,
256
+ 1 - (count - POLICY.short.good) / Math.max(1, POLICY.short.max - POLICY.short.good),
257
+ );
258
+ const composed = catalog.available ? (count ? 1 - positions.size / count : 1) : null;
259
+ const parameterized =
260
+ values.templated + values.literals.length
261
+ ? values.templated / (values.templated + values.literals.length)
262
+ : 1;
263
+ const criteria = input.criteria ?? null,
264
+ declaredReview = new Set(
265
+ (recipe.proofTargets ?? [])
266
+ .filter((target) => target.proofMode === 'review')
267
+ .map((target) => target.id),
268
+ );
269
+ const expected = criteria?.map((criterion) => criterion.id) ?? [];
270
+ const reviewOnly =
271
+ criteria
272
+ ?.filter((criterion) => criterion.proofMode === 'review' || declaredReview.has(criterion.id))
273
+ .map((criterion) => criterion.id) ?? [];
274
+ const proved = new Set(
275
+ inspected.reachable.flatMap((id) => [
276
+ ...(Array.isArray(nodes[id].proves) ? nodes[id].proves : []),
277
+ ...(nodes[id].action === 'call' && record(nodes[id].claimBindings)
278
+ ? Object.values(nodes[id].claimBindings)
279
+ : []),
280
+ ]),
281
+ );
282
+ const covered = expected.filter((id) => proved.has(id) && !reviewOnly.includes(id));
283
+ const missing = expected.filter((id) => !covered.includes(id));
284
+ const unexpected = [...proved].filter(
285
+ (id) => !expected.includes(id) && !(input.nonLocalCriteria ?? []).includes(id),
286
+ );
287
+ const hints = [];
288
+ if (parameterized < POLICY.positive.parameterized)
289
+ values.literals.forEach((field) =>
290
+ hints.push({
291
+ kind: 'parameterize',
292
+ field,
293
+ message: `Lift ${field} into typed paramsSchema/params and use a params template at that field.`,
294
+ }),
295
+ );
296
+ duplicates.forEach((duplicate) =>
297
+ hints.push({
298
+ kind: 'compose',
299
+ nodes: duplicate.nodes,
300
+ flow: duplicate.flow,
301
+ ...(duplicate.ref ? { ref: duplicate.ref } : {}),
302
+ message: `Replace ${duplicate.nodes.join(', ')} with ${duplicate.ref ?? duplicate.flow}.`,
303
+ }),
304
+ );
305
+ if (short < POLICY.positive.short)
306
+ hints.push({
307
+ kind: 'shorten',
308
+ message: `${count} executable nodes exceed the shortness target; remove repeated setup or compose available blocks.`,
309
+ });
310
+ missing.forEach((id) =>
311
+ hints.push({
312
+ kind: 'acceptance',
313
+ criterionId: id,
314
+ message: reviewOnly.includes(id)
315
+ ? `${id} requires review and cannot be recipe-proven; record this limitation, never add an invalid proves node.`
316
+ : `Bind a reachable qualifying proving node to registered ${id}.`,
317
+ }),
318
+ );
319
+ if (!expected.length)
320
+ hints.push({
321
+ kind: 'acceptance',
322
+ message:
323
+ 'No registered worker AC set is available; this is not an AC-bearing task-proof candidate.',
324
+ });
325
+ if (!catalog.available)
326
+ hints.push({
327
+ kind: 'catalog',
328
+ message:
329
+ 'Declared library catalog is unavailable; composition and imitation eligibility are unknown.',
330
+ });
331
+ const goodShape =
332
+ count > 0 &&
333
+ short >= POLICY.positive.short &&
334
+ composed !== null &&
335
+ composed >= POLICY.positive.composed &&
336
+ parameterized >= POLICY.positive.parameterized;
337
+ const acComplete =
338
+ criteria !== null && expected.length > 0 && missing.length === 0 && unexpected.length === 0;
339
+ return {
340
+ policyVersion: POLICY_VERSION,
341
+ inputIdentity: {
342
+ recipe: stableDigest(recipe),
343
+ criteria: stableDigest({ criteria, nonLocalCriteria: input.nonLocalCriteria ?? [] }),
344
+ catalog: stableDigest({
345
+ adapter: catalog.adapter ?? null,
346
+ available: catalog.available,
347
+ blocks: catalog.blocks ?? [],
348
+ sources: catalog.sources ?? [],
349
+ }),
350
+ },
351
+ nodes: count,
352
+ calls: calls.length,
353
+ templatedValues: values.templated,
354
+ literalValues: values.literals.length,
355
+ short: roundScore(short),
356
+ composed: composed === null ? null : roundScore(composed),
357
+ parameterized: roundScore(parameterized),
358
+ inlineDuplicates: duplicates,
359
+ goodShape,
360
+ acCoverage: {
361
+ nonLocal: input.nonLocalCriteria ?? [],
362
+ available: criteria !== null,
363
+ expected,
364
+ covered,
365
+ missing,
366
+ reviewOnly,
367
+ unexpected,
368
+ complete: acComplete,
369
+ },
370
+ eligibleForImitation: goodShape && acComplete,
371
+ catalogTrust: 'worker-supplied-self-consistent',
372
+ status:
373
+ !catalog.available || criteria === null
374
+ ? 'unavailable'
375
+ : goodShape && acComplete
376
+ ? 'ok'
377
+ : 'warn',
378
+ hints,
379
+ };
380
+ }
381
+ function recipeShapeLibraryRef(name, relativePath, platforms = [], adapter) {
382
+ const portable = relativePath.replaceAll('\\', '/').replace(/^recipes\//, '');
383
+ // Offline catalogs may include every platform; a bound worker supplies its active adapter.
384
+ const inferred =
385
+ adapter ??
386
+ [...BUILT_IN_RECIPE_PLATFORMS, ...platforms].find(
387
+ (platform) =>
388
+ portable.startsWith(platform + '/') || portable.endsWith('.' + platform + '.recipe.json'),
389
+ );
390
+ const identity = recipeFileIdentity(portable, inferred, platforms);
391
+ if (!identity || 'error' in identity) return null;
392
+ return name ? `${name}.${identity.ref}` : identity.ref;
393
+ }
394
+
395
+ function recipeShapeBlock(ref, recipe) {
396
+ const inspected = inspectRecipeShape(recipe);
397
+ const generic = new Set(['call', 'switch', 'wait', 'end', 'command']);
398
+ const changing = inspected.signature.filter(
399
+ (token) => !isRecipeShapeObservation(token.split('|')[0]),
400
+ );
401
+ if (
402
+ inspected.signature.length < 2 ||
403
+ !changing.some((token) => token.includes('|') || !generic.has(token))
404
+ )
405
+ return null;
406
+ return {
407
+ id: `flow:${ref}:${stableDigest(recipe).slice(0, 12)}`,
408
+ ref,
409
+ tokens: inspected.tokens,
410
+ sig: inspected.signature,
411
+ };
412
+ }
413
+ function processShapeRequest(request) {
414
+ if (request.operation === 'policy') return { policyVersion: POLICY_VERSION, policy: POLICY };
415
+ if (request.operation === 'inspect') return inspectRecipeShape(request.recipe);
416
+ if (request.operation === 'selector') return recipeShapeSelector(request.node);
417
+ if (request.operation === 'token') return recipeShapeNodeToken(request.node);
418
+ if (request.operation === 'signature') return recipeShapeSignature(request.tokens);
419
+ if (request.operation === 'observation') return isRecipeShapeObservation(request.action);
420
+ if (request.operation === 'block')
421
+ return recipeShapeBlock(
422
+ request.ref ?? recipeShapeLibraryRef(request.name, request.relativePath, request.platforms),
423
+ request.recipe,
424
+ );
425
+ if (request.operation === 'digest') return stableDigest(request.value);
426
+ if (request.operation === 'criteria') {
427
+ return filterWorkerAcceptanceCriteria(
428
+ registeredAcceptanceCriteria(request.criteria ?? [], request.registration),
429
+ request.registration,
430
+ request.flow,
431
+ );
432
+ }
433
+ return scoreRecipeShape(request);
434
+ }
435
+ function isRecipeShapeFeedback(value) {
436
+ if (
437
+ !record(value) ||
438
+ !Number.isSafeInteger(value.policyVersion) ||
439
+ value.policyVersion < 1 ||
440
+ !record(value.inputIdentity) ||
441
+ !record(value.acCoverage)
442
+ )
443
+ return false;
444
+ if (
445
+ !['recipe', 'criteria', 'catalog'].every((key) =>
446
+ /^[a-f0-9]{64}$/.test(value.inputIdentity[key]),
447
+ )
448
+ )
449
+ return false;
450
+ if (
451
+ !['nodes', 'calls', 'templatedValues', 'literalValues'].every(
452
+ (key) => Number.isInteger(value[key]) && value[key] >= 0,
453
+ )
454
+ )
455
+ return false;
456
+ if (
457
+ !['short', 'parameterized'].every(
458
+ (key) => typeof value[key] === 'number' && value[key] >= 0 && value[key] <= 1,
459
+ )
460
+ )
461
+ return false;
462
+ if (
463
+ value.composed !== null &&
464
+ !(typeof value.composed === 'number' && value.composed >= 0 && value.composed <= 1)
465
+ )
466
+ return false;
467
+ if (
468
+ !['ok', 'warn', 'unavailable'].includes(value.status) ||
469
+ value.catalogTrust !== 'worker-supplied-self-consistent'
470
+ )
471
+ return false;
472
+ if (
473
+ typeof value.goodShape !== 'boolean' ||
474
+ typeof value.eligibleForImitation !== 'boolean' ||
475
+ !Array.isArray(value.inlineDuplicates) ||
476
+ !Array.isArray(value.hints)
477
+ )
478
+ return false;
479
+ if (
480
+ !value.inlineDuplicates.every(
481
+ (item) =>
482
+ record(item) &&
483
+ typeof item.flow === 'string' &&
484
+ (item.ref === undefined || typeof item.ref === 'string') &&
485
+ Array.isArray(item.nodes) &&
486
+ item.nodes.every((node) => typeof node === 'string'),
487
+ )
488
+ )
489
+ return false;
490
+ if (
491
+ !value.hints.every(
492
+ (item) =>
493
+ record(item) &&
494
+ ['field', 'flow', 'ref', 'criterionId'].every(
495
+ (key) => item[key] === undefined || typeof item[key] === 'string',
496
+ ) &&
497
+ (item.nodes === undefined ||
498
+ (Array.isArray(item.nodes) && item.nodes.every((node) => typeof node === 'string'))),
499
+ )
500
+ )
501
+ return false;
502
+ return (
503
+ typeof value.acCoverage.available === 'boolean' &&
504
+ typeof value.acCoverage.complete === 'boolean' &&
505
+ ['expected', 'covered', 'missing', 'reviewOnly', 'unexpected', 'nonLocal'].every(
506
+ (key) =>
507
+ Array.isArray(value.acCoverage[key]) &&
508
+ value.acCoverage[key].every((id) => typeof id === 'string'),
509
+ ) &&
510
+ value.hints.every(
511
+ (hint) =>
512
+ record(hint) &&
513
+ typeof hint.message === 'string' &&
514
+ ['parameterize', 'compose', 'shorten', 'acceptance', 'catalog'].includes(hint.kind),
515
+ )
516
+ );
517
+ }
518
+ export {
519
+ inspectRecipeShape,
520
+ isRecipeShapeFeedback,
521
+ isRecipeShapeObservation,
522
+ POLICY_VERSION,
523
+ processShapeRequest,
524
+ POLICY as RECIPE_SHAPE_POLICY,
525
+ recipeShapeBlock,
526
+ recipeShapeLibraryRef,
527
+ recipeShapeNodeToken,
528
+ recipeShapeSelector,
529
+ recipeShapeSignature,
530
+ scoreRecipeShape,
531
+ stableDigest,
532
+ };
@@ -30,3 +30,9 @@ export function validateAcceptanceStatusLedger(value: unknown): string[];
30
30
  export function acceptanceCriteriaView(criteria: readonly unknown[], ledger: unknown): unknown[];
31
31
  export function summarizeAcceptanceStatus(ledger: unknown, criteria?: readonly unknown[]): unknown;
32
32
  export function renderAcceptanceCoverage(ledger: unknown, criteria?: readonly unknown[]): string;
33
+
34
+ export function filterWorkerAcceptanceCriteria<T extends { id: string }>(
35
+ criteria: readonly T[],
36
+ registration: unknown,
37
+ flow: string,
38
+ ): T[];
@@ -18,6 +18,16 @@ function acceptanceCriterionId(index) {
18
18
  return `AC-${index + 1}`;
19
19
  }
20
20
 
21
+ function filterWorkerAcceptanceCriteria(criteria, registration, flow) {
22
+ if (flow !== 'pr-complete') return [...criteria];
23
+ const derived = new Set(
24
+ (isRecord(registration) && Array.isArray(registration.derived) ? registration.derived : []).map(
25
+ (row) => row.id,
26
+ ),
27
+ );
28
+ return criteria.filter((row) => derived.has(row.id));
29
+ }
30
+
21
31
  function registeredAcceptanceCriteria(criteria, registration) {
22
32
  const original = criteria
23
33
  .filter((text) => String(text).trim().length > 0)
@@ -311,6 +321,7 @@ export {
311
321
  acceptanceCriterionId,
312
322
  acceptanceRowExecution,
313
323
  acceptanceVerdictLabel,
324
+ filterWorkerAcceptanceCriteria,
314
325
  INHERITED_ACCEPTANCE_INPUT,
315
326
  registeredAcceptanceCriteria,
316
327
  renderAcceptanceCoverage,
@@ -0,0 +1,17 @@
1
+ export const BUILT_IN_RECIPE_PLATFORMS: readonly string[];
2
+ export interface RecipeFileIdentity {
3
+ ref: string;
4
+ adapter?: string;
5
+ }
6
+ export interface RecipeFileIdentityError {
7
+ error: {
8
+ code: 'RECIPE_LIBRARY_ADAPTER_DECLARATION_CONFLICT' | 'RECIPE_LIBRARY_RECIPE_INVALID';
9
+ message: string;
10
+ recovery: string;
11
+ };
12
+ }
13
+ export function recipeFileIdentity(
14
+ relativeFile: string,
15
+ adapter?: string,
16
+ platforms?: readonly string[],
17
+ ): RecipeFileIdentity | RecipeFileIdentityError | undefined;
@@ -0,0 +1,45 @@
1
+ export const BUILT_IN_RECIPE_PLATFORMS = ['core', 'extension', 'mobile'];
2
+ const SUFFIX = '.recipe.json';
3
+
4
+ /** Resolver identity without filesystem access. Diagnostics keep caller-owned errors. */
5
+ export function recipeFileIdentity(relativeFile, adapter, platforms = []) {
6
+ const portable = relativeFile.replaceAll('\\', '/');
7
+ if (!portable.endsWith(SUFFIX)) return undefined;
8
+ const base = portable.slice(0, -SUFFIX.length);
9
+ const first = base.includes('/') ? base.slice(0, base.indexOf('/')) : undefined;
10
+ const adapters = new Set([
11
+ ...BUILT_IN_RECIPE_PLATFORMS,
12
+ ...platforms,
13
+ ...(adapter ? [adapter] : []),
14
+ ]);
15
+ const directoryAdapter = adapters.has(first) ? first : undefined;
16
+ const scope = first === 'shared' ? first : directoryAdapter;
17
+ const suffix = base.slice(base.lastIndexOf('.') + 1);
18
+ const filenameAdapter = BUILT_IN_RECIPE_PLATFORMS.includes(suffix) ? suffix : undefined;
19
+ if (scope && filenameAdapter)
20
+ return {
21
+ error: {
22
+ code: 'RECIPE_LIBRARY_ADAPTER_DECLARATION_CONFLICT',
23
+ message: `Library recipe recipes/${portable} declares ${scope} scope in its directory and ${filenameAdapter} adapter in its filename.`,
24
+ recovery:
25
+ 'declare the adapter once using recipes/<adapter>/.../*.recipe.json, or use recipes/shared/.../*.recipe.json without an adapter suffix',
26
+ },
27
+ };
28
+ const declaredAdapter = directoryAdapter ?? filenameAdapter;
29
+ const idPath = scope
30
+ ? base.slice(scope.length + 1)
31
+ : filenameAdapter
32
+ ? base.slice(0, -(filenameAdapter.length + 1))
33
+ : base;
34
+ if ((scope || declaredAdapter) && !idPath.trim())
35
+ return {
36
+ error: {
37
+ code: 'RECIPE_LIBRARY_RECIPE_INVALID',
38
+ message: `Library recipe recipes/${portable} declares ${declaredAdapter ? `adapter ${declaredAdapter}` : 'shared scope'} but has no recipe id.`,
39
+ recovery: `move it to recipes/${scope ?? declaredAdapter}/<name>.recipe.json`,
40
+ },
41
+ };
42
+ if (declaredAdapter && declaredAdapter !== adapter) return undefined;
43
+ const ref = idPath.replaceAll('/', '.').trim();
44
+ return ref ? { ref, ...(declaredAdapter ? { adapter: declaredAdapter } : {}) } : undefined;
45
+ }