@farmslot/agent-runtime 0.2.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 (43) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/bin/farmslot-agent.mjs +6 -0
  3. package/dist/execution-template/create.d.ts +7 -0
  4. package/dist/execution-template/create.d.ts.map +1 -0
  5. package/dist/execution-template/create.js +63 -0
  6. package/dist/execution-template/create.js.map +1 -0
  7. package/dist/execution-template/execution-template.test.d.ts +2 -0
  8. package/dist/execution-template/execution-template.test.d.ts.map +1 -0
  9. package/dist/execution-template/execution-template.test.js +340 -0
  10. package/dist/execution-template/execution-template.test.js.map +1 -0
  11. package/dist/execution-template/frontmatter.d.ts +16 -0
  12. package/dist/execution-template/frontmatter.d.ts.map +1 -0
  13. package/dist/execution-template/frontmatter.js +110 -0
  14. package/dist/execution-template/frontmatter.js.map +1 -0
  15. package/dist/execution-template/index.d.ts +7 -0
  16. package/dist/execution-template/index.d.ts.map +1 -0
  17. package/dist/execution-template/index.js +6 -0
  18. package/dist/execution-template/index.js.map +1 -0
  19. package/dist/execution-template/infer.d.ts +25 -0
  20. package/dist/execution-template/infer.d.ts.map +1 -0
  21. package/dist/execution-template/infer.js +122 -0
  22. package/dist/execution-template/infer.js.map +1 -0
  23. package/dist/execution-template/lint.d.ts +6 -0
  24. package/dist/execution-template/lint.d.ts.map +1 -0
  25. package/dist/execution-template/lint.js +203 -0
  26. package/dist/execution-template/lint.js.map +1 -0
  27. package/dist/execution-template/resolve.d.ts +12 -0
  28. package/dist/execution-template/resolve.d.ts.map +1 -0
  29. package/dist/execution-template/resolve.js +126 -0
  30. package/dist/execution-template/resolve.js.map +1 -0
  31. package/dist/execution-template/types.d.ts +66 -0
  32. package/dist/execution-template/types.d.ts.map +1 -0
  33. package/dist/execution-template/types.js +3 -0
  34. package/dist/execution-template/types.js.map +1 -0
  35. package/dist/index.d.ts +1 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +1 -0
  38. package/dist/index.js.map +1 -1
  39. package/package.json +6 -4
  40. package/scripts/check-task-artifact-contract.mjs +208 -63
  41. package/scripts/execution-template-cli.mjs +203 -0
  42. package/scripts/mark-checklist-step.cjs +26 -8
  43. package/scripts/worker-terminal-contract.cjs +23 -21
@@ -0,0 +1,66 @@
1
+ /** Shared Markdown execution-template catalog (ADR-049). */
2
+ export type ExecutionTemplateSourceKind = 'custom' | 'project' | 'workspace' | 'user' | 'package' | 'fallback';
3
+ export type ExecutionTemplateLayout = 'flow-tree' | 'worker-flat';
4
+ export type ExecutionRunMode = 'autonomous' | 'interactive' | 'validation';
5
+ export interface ExecutionTemplateSource {
6
+ /** Stable source label for catalogs / shadowing (e.g. project:farmslot-farm). */
7
+ id: string;
8
+ kind: ExecutionTemplateSourceKind;
9
+ /** Absolute directory to scan. */
10
+ root: string;
11
+ layout: ExecutionTemplateLayout;
12
+ }
13
+ export interface ExecutionTemplateFrontmatter {
14
+ id?: string;
15
+ title?: string;
16
+ flow?: string;
17
+ version?: string | number;
18
+ runMode?: ExecutionRunMode;
19
+ platforms?: string[];
20
+ labels?: string[];
21
+ [key: string]: unknown;
22
+ }
23
+ export interface ExecutionTemplateEntry {
24
+ id: string;
25
+ title: string;
26
+ flow: string;
27
+ version: string;
28
+ runMode: ExecutionRunMode | null;
29
+ platforms: string[];
30
+ labels: string[];
31
+ path: string;
32
+ relativePath: string;
33
+ sourceId: string;
34
+ sourceKind: ExecutionTemplateSourceKind;
35
+ /** Present when a higher-precedence source already claimed this id. */
36
+ shadowedBy?: string;
37
+ frontmatter: ExecutionTemplateFrontmatter | null;
38
+ heading: string | null;
39
+ }
40
+ export interface ListExecutionTemplatesOptions {
41
+ sources: ExecutionTemplateSource[];
42
+ flow?: string;
43
+ runMode?: ExecutionRunMode;
44
+ platform?: string;
45
+ /** Include shadowed duplicates (default true for list diagnostics). */
46
+ includeShadowed?: boolean;
47
+ }
48
+ export interface LintIssue {
49
+ path: string;
50
+ severity: 'error' | 'warning';
51
+ message: string;
52
+ }
53
+ export interface LintExecutionTemplatesResult {
54
+ ok: boolean;
55
+ issues: LintIssue[];
56
+ filesChecked: number;
57
+ }
58
+ export interface CreateExecutionTemplateOptions {
59
+ path: string;
60
+ flow?: string;
61
+ runMode?: ExecutionRunMode;
62
+ platforms?: string[];
63
+ title?: string;
64
+ force?: boolean;
65
+ }
66
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/execution-template/types.ts"],"names":[],"mappings":"AAAA,4DAA4D;AAE5D,MAAM,MAAM,2BAA2B,GACnC,QAAQ,GACR,SAAS,GACT,WAAW,GACX,MAAM,GACN,SAAS,GACT,UAAU,CAAC;AAEf,MAAM,MAAM,uBAAuB,GAAG,WAAW,GAAG,aAAa,CAAC;AAElE,MAAM,MAAM,gBAAgB,GAAG,YAAY,GAAG,aAAa,GAAG,YAAY,CAAC;AAE3E,MAAM,WAAW,uBAAuB;IACtC,iFAAiF;IACjF,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,2BAA2B,CAAC;IAClC,kCAAkC;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,uBAAuB,CAAC;CACjC;AAED,MAAM,WAAW,4BAA4B;IAC3C,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAC1B,OAAO,CAAC,EAAE,gBAAgB,CAAC;IAC3B,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;IACrB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,sBAAsB;IACrC,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,gBAAgB,GAAG,IAAI,CAAC;IACjC,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,2BAA2B,CAAC;IACxC,uEAAuE;IACvE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,4BAA4B,GAAG,IAAI,CAAC;IACjD,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB;AAED,MAAM,WAAW,6BAA6B;IAC5C,OAAO,EAAE,uBAAuB,EAAE,CAAC;IACnC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,gBAAgB,CAAC;IAC3B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,uEAAuE;IACvE,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B;AAED,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,OAAO,GAAG,SAAS,CAAC;IAC9B,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,4BAA4B;IAC3C,EAAE,EAAE,OAAO,CAAC;IACZ,MAAM,EAAE,SAAS,EAAE,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,8BAA8B;IAC7C,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,gBAAgB,CAAC;IAC3B,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB"}
@@ -0,0 +1,3 @@
1
+ /** Shared Markdown execution-template catalog (ADR-049). */
2
+ export {};
3
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/execution-template/types.ts"],"names":[],"mappings":"AAAA,4DAA4D"}
package/dist/index.d.ts CHANGED
@@ -4,5 +4,6 @@ export declare const AGENT_RUNTIME_SCRIPT_EXPORTS: {
4
4
  readonly workerTerminalContract: "@farmslot/agent-runtime/scripts/worker-terminal-contract.cjs";
5
5
  readonly checkTaskArtifactContract: "@farmslot/agent-runtime/scripts/check-task-artifact-contract.mjs";
6
6
  };
7
+ export * from './execution-template/index.js';
7
8
  export * from './recipe-quality.js';
8
9
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,qBAAqB,4BAA4B,CAAC;AAE/D,eAAO,MAAM,4BAA4B;;;;CAI/B,CAAC;AAEX,cAAc,qBAAqB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,qBAAqB,4BAA4B,CAAC;AAE/D,eAAO,MAAM,4BAA4B;;;;CAI/B,CAAC;AAEX,cAAc,+BAA+B,CAAC;AAC9C,cAAc,qBAAqB,CAAC"}
package/dist/index.js CHANGED
@@ -4,5 +4,6 @@ export const AGENT_RUNTIME_SCRIPT_EXPORTS = {
4
4
  workerTerminalContract: '@farmslot/agent-runtime/scripts/worker-terminal-contract.cjs',
5
5
  checkTaskArtifactContract: '@farmslot/agent-runtime/scripts/check-task-artifact-contract.mjs',
6
6
  };
7
+ export * from './execution-template/index.js';
7
8
  export * from './recipe-quality.js';
8
9
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,qBAAqB,GAAG,yBAAyB,CAAC;AAE/D,MAAM,CAAC,MAAM,4BAA4B,GAAG;IAC1C,iBAAiB,EAAE,yDAAyD;IAC5E,sBAAsB,EAAE,8DAA8D;IACtF,yBAAyB,EAAE,kEAAkE;CACrF,CAAC;AAEX,cAAc,qBAAqB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,qBAAqB,GAAG,yBAAyB,CAAC;AAE/D,MAAM,CAAC,MAAM,4BAA4B,GAAG;IAC1C,iBAAiB,EAAE,yDAAyD;IAC5E,sBAAsB,EAAE,8DAA8D;IACtF,yBAAyB,EAAE,kEAAkE;CACrF,CAAC;AAEX,cAAc,+BAA+B,CAAC;AAC9C,cAAc,qBAAqB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farmslot/agent-runtime",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -20,7 +20,7 @@
20
20
  },
21
21
  "scripts": {
22
22
  "typecheck": "yarn workspace @farmslot/protocol build && tsc --noEmit --project tsconfig.json",
23
- "test": "node test/mark-checklist-step.test.cjs && node test/checklist-target-sync.test.mjs && node test/package-exports.test.cjs && node test/check-task-artifact-contract.test.cjs && node test/recipe-quality-builder.test.cjs",
23
+ "test": "node test/mark-checklist-step.test.cjs && node test/checklist-target-sync.test.mjs && node test/package-exports.test.cjs && node test/check-task-artifact-contract.test.cjs && node test/recipe-quality-builder.test.cjs && node ../../scripts/quality/run-tsx-tests.mjs --cwd . --tsconfig tsconfig.json src/execution-template",
24
24
  "format": "prettier --write --ignore-path ../../.prettierignore .",
25
25
  "format:check": "prettier --check --ignore-path ../../.prettierignore .",
26
26
  "lint": "eslint \"src/**/*.ts\"",
@@ -31,12 +31,13 @@
31
31
  "prepublishOnly": "node ../../scripts/quality/check-farmslot-package-readiness.mjs --publish --packages @farmslot/agent-runtime"
32
32
  },
33
33
  "dependencies": {
34
- "@farmslot/protocol": "0.8.0"
34
+ "@farmslot/protocol": "0.11.1"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "^22.0.0",
38
38
  "eslint": "^10.3.0",
39
39
  "prettier": "^3.8.3",
40
+ "tsx": "^4.19.0",
40
41
  "typescript": "^5.6.0"
41
42
  },
42
43
  "repository": {
@@ -66,7 +67,8 @@
66
67
  "dist/**/*.d.ts.map",
67
68
  "dist/**/*.js.map",
68
69
  "scripts/**/*.cjs",
69
- "scripts/**/*.mjs"
70
+ "scripts/**/*.mjs",
71
+ "scripts/execution-template-cli.mjs"
70
72
  ],
71
73
  "publishConfig": {
72
74
  "access": "public"
@@ -1,41 +1,84 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, readFileSync, statSync } from 'node:fs';
2
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
3
3
  import { createRequire } from 'node:module';
4
4
  import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
5
6
 
6
7
  const require = createRequire(import.meta.url);
7
8
  const { expandedArtifactsForCommand } = require('./worker-terminal-contract.cjs');
9
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
10
+ const workspaceProtocolRoot = path.resolve(packageRoot, '../protocol');
11
+
12
+ function isMissingWorkspaceProtocolBuild(error, output) {
13
+ return (
14
+ error?.code === 'ERR_MODULE_NOT_FOUND' &&
15
+ path.basename(path.dirname(packageRoot)) === 'packages' &&
16
+ existsSync(path.join(workspaceProtocolRoot, 'package.json')) &&
17
+ !existsSync(path.join(workspaceProtocolRoot, output))
18
+ );
19
+ }
8
20
 
9
21
  let sharedRecipeQualityValidator = null;
10
22
  let sharedRecipeDocumentValidator = null;
11
- // Mirror @farmslot/protocol recipeProtocolSchemaUrlForVersion: only v1 is a
12
- // supported schema_version, so do not synthesize URLs for other versions.
13
- let sharedRecipeSchemaUrlForVersion = (version) =>
14
- version === 1 ? 'https://farmslot.io/schemas/recipe-v1.schema.json' : null;
23
+ let sharedRecipeArtifactPackageValidator = null;
24
+ let sharedResolvedRecipeArtifactPath = null;
25
+ // Keep the source-checkout fallback pinned to the canonical Recipe v1 schema.
26
+ const RECIPE_SCHEMA_URL = 'https://farmslot.io/schemas/recipe-v1.schema.json';
15
27
  try {
16
- ({ isRecipeQualityArtifact: sharedRecipeQualityValidator } =
17
- await import('@farmslot/protocol/contracts/recipes'));
28
+ const qualityProtocol = await import('@farmslot/protocol/contracts/recipes');
29
+ if (typeof qualityProtocol.isRecipeQualityArtifact !== 'function') {
30
+ throw new Error('installed @farmslot/protocol does not export isRecipeQualityArtifact');
31
+ }
32
+ sharedRecipeQualityValidator = qualityProtocol.isRecipeQualityArtifact;
18
33
  } catch (error) {
34
+ if (!isMissingWorkspaceProtocolBuild(error, 'dist/contracts/recipes.js')) throw error;
19
35
  // Source checkouts run this script before @farmslot/protocol has emitted dist/.
20
36
  // Keep strict local validation instead of downgrading to the historical loose gate.
21
37
  if (process.env.FARMSLOT_DEBUG_AGENT_RUNTIME) {
22
38
  console.warn(`[agent-runtime] using local RecipeQualityArtifact fallback: ${error.message}`);
23
39
  }
24
40
  }
41
+ let recipeProtocol = null;
25
42
  try {
26
- const recipeProtocol = await import('@farmslot/protocol/recipe');
27
- sharedRecipeDocumentValidator = recipeProtocol.validateRecipeDocument;
28
- if (typeof recipeProtocol.recipeProtocolSchemaUrlForVersion === 'function') {
29
- sharedRecipeSchemaUrlForVersion = recipeProtocol.recipeProtocolSchemaUrlForVersion;
30
- }
43
+ recipeProtocol = await import('@farmslot/protocol/recipe');
31
44
  } catch (error) {
32
- // Compatibility fail-safe: source checkouts can run before @farmslot/protocol
33
- // has emitted dist/. In that degraded state, do not bypass recipe checks;
34
- // enforce the minimum executable envelope locally until the shared validator loads.
45
+ if (!isMissingWorkspaceProtocolBuild(error, 'dist/recipe/index.js')) throw error;
46
+ // Source checkouts can run before @farmslot/protocol has emitted dist/.
47
+ // Keep strict local validation until the shared validator is available.
35
48
  if (process.env.FARMSLOT_DEBUG_AGENT_RUNTIME) {
36
49
  console.warn(`[agent-runtime] using local Recipe v1 fallback: ${error.message}`);
37
50
  }
38
51
  }
52
+ if (recipeProtocol) {
53
+ for (const api of [
54
+ 'validateRecipeDocument',
55
+ 'validateRecipeArtifactPackage',
56
+ 'resolvedRecipeArtifactPath',
57
+ ]) {
58
+ if (typeof recipeProtocol[api] !== 'function') {
59
+ throw new Error(`installed @farmslot/protocol does not export ${api}`);
60
+ }
61
+ }
62
+ const compatibilityProbe = recipeProtocol.validateRecipeDocument({
63
+ $schema: RECIPE_SCHEMA_URL,
64
+ description: 'Validate the canonical Recipe v1 envelope.',
65
+ workflow: {
66
+ entry: 'done',
67
+ nodes: { done: { action: 'end', status: 'pass' } },
68
+ },
69
+ });
70
+ const incompatibleFinding = compatibilityProbe?.findings?.find(
71
+ (finding) => finding?.severity === 'error',
72
+ );
73
+ if (incompatibleFinding) {
74
+ throw new Error(
75
+ `installed @farmslot/protocol rejects canonical Recipe v1 (${incompatibleFinding.code})`,
76
+ );
77
+ }
78
+ sharedRecipeDocumentValidator = recipeProtocol.validateRecipeDocument;
79
+ sharedRecipeArtifactPackageValidator = recipeProtocol.validateRecipeArtifactPackage;
80
+ sharedResolvedRecipeArtifactPath = recipeProtocol.resolvedRecipeArtifactPath;
81
+ }
39
82
 
40
83
  const taskDir = process.argv[2];
41
84
  const rawArgs = process.argv.slice(3);
@@ -85,15 +128,34 @@ const allowedOmitKeys = new Set(['file', 'reason']);
85
128
 
86
129
  function fileExists(rel) {
87
130
  try {
88
- return statSync(path.join(taskDir, rel)).isFile();
89
- } catch {
131
+ return statSync(resolveTaskArtifactPath(rel)).isFile();
132
+ } catch (error) {
133
+ if (!isMissingPathError(error)) throw error;
90
134
  return false;
91
135
  }
92
136
  }
93
137
 
94
138
  function readText(rel) {
95
- const p = path.join(taskDir, rel);
96
- return existsSync(p) ? readFileSync(p, 'utf8') : null;
139
+ try {
140
+ return readFileSync(resolveTaskArtifactPath(rel), 'utf8');
141
+ } catch (error) {
142
+ if (!isMissingPathError(error)) throw error;
143
+ return null;
144
+ }
145
+ }
146
+
147
+ function isMissingPathError(error) {
148
+ return error && typeof error === 'object' && error.code === 'ENOENT';
149
+ }
150
+
151
+ function resolveTaskArtifactPath(rel) {
152
+ const root = realpathSync(taskDir);
153
+ const candidate = realpathSync(path.resolve(taskDir, rel));
154
+ const relative = path.relative(root, candidate);
155
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
156
+ throw new Error(`${rel} resolves outside the task directory`);
157
+ }
158
+ return candidate;
97
159
  }
98
160
 
99
161
  function isRecord(value) {
@@ -135,7 +197,7 @@ function isRecipeQualityArtifactFallback(value) {
135
197
  if (fields.project != null && typeof fields.project !== 'string') return false;
136
198
  if (
137
199
  fields.flow_type != null &&
138
- !['fix-bug', 'review-pr', 'dev', 'pr-complete', 'merge-main'].includes(fields.flow_type)
200
+ !['fix-bug', 'review-pr', 'dev', 'pr-complete', 'update-branch'].includes(fields.flow_type)
139
201
  )
140
202
  return false;
141
203
  if (fields.task_type != null && typeof fields.task_type !== 'string') return false;
@@ -246,47 +308,97 @@ function validateRecipeQualityArtifact() {
246
308
  }
247
309
 
248
310
  function validateRecipeDocumentArtifact() {
249
- // Mirror the artifact-package contract (protocol/recipe/artifact.ts): a failed run
250
- // keeps recipe.json envelope-only and does not re-validate the composition, so a
251
- // graceful failure (e.g. a flow cycle) is not rejected here.
252
- const summaryText = readText('artifacts/summary.json');
253
- let runFailed = false;
254
- if (summaryText) {
255
- try {
256
- runFailed = JSON.parse(summaryText)?.status === 'fail';
257
- } catch {
258
- // A malformed summary is another check's concern; without a confirmed failure
259
- // status, enforce the stricter (passing-run) validation as the safe default.
260
- runFailed = false;
311
+ const authoredRecipe = readJsonArtifact('artifacts/recipe.json', 'recipe.json');
312
+ if (authoredRecipe === undefined) return;
313
+ validateRecipeDocumentValue(authoredRecipe, 'recipe.json', { skipRecipeCallResolution: true });
314
+
315
+ const packageRoot = 'artifacts/recipe-run';
316
+ const recipe = readJsonArtifact(`${packageRoot}/recipe.json`, 'recipe-run/recipe.json');
317
+ if (recipe === undefined) {
318
+ issues.push('artifacts/recipe-run/recipe.json is missing');
319
+ return;
320
+ }
321
+ if (canonicalJson(authoredRecipe) !== canonicalJson(recipe)) {
322
+ issues.push('artifacts/recipe-run/recipe.json does not match artifacts/recipe.json');
323
+ }
324
+ const manifest = readJsonArtifact(
325
+ `${packageRoot}/artifact-manifest.json`,
326
+ 'recipe-run/artifact-manifest.json',
327
+ );
328
+ const recipeResolution = readJsonArtifact(
329
+ `${packageRoot}/recipe-resolution.json`,
330
+ 'recipe-run/recipe-resolution.json',
331
+ );
332
+ const resolvedRecipes = {};
333
+ if (isRecord(recipeResolution) && Array.isArray(recipeResolution.dependencies)) {
334
+ for (const dependency of recipeResolution.dependencies) {
335
+ if (!isRecord(dependency)) continue;
336
+ const artifact = resolvedRecipeArtifactPath(dependency.digest);
337
+ if (!artifact) continue;
338
+ const document = readJsonArtifact(`${packageRoot}/${artifact}`, `recipe-run/${artifact}`);
339
+ if (document !== undefined) resolvedRecipes[String(dependency.digest)] = document;
261
340
  }
262
341
  }
263
- if (runFailed) {
264
- validateRecipeDocumentArtifactAt('artifacts/recipe.json', 'recipe.json', {
265
- skipFlowCallResolution: true,
342
+ if (sharedRecipeArtifactPackageValidator) {
343
+ const result = sharedRecipeArtifactPackageValidator({
344
+ recipe,
345
+ manifest,
346
+ recipeResolution,
347
+ resolvedRecipes,
348
+ artifactPaths: listArtifactPaths(path.join(taskDir, packageRoot)),
266
349
  });
350
+ for (const finding of result.findings) {
351
+ if (finding.severity === 'error') {
352
+ issues.push(`${finding.code} ${finding.path}: ${finding.message}`);
353
+ }
354
+ }
267
355
  return;
268
356
  }
269
- // Passing/unknown run: the composition must be proven — recipe.json's call.refs are
270
- // proven either by resolved-recipe.json (validated in full, so recipe.json is
271
- // envelope-only) or by recipe.json being self-contained (validated in full).
272
- const hasResolvedRecipe = fileExists('artifacts/resolved-recipe.json');
273
- validateRecipeDocumentArtifactAt('artifacts/recipe.json', 'recipe.json', {
274
- skipFlowCallResolution: hasResolvedRecipe,
275
- externalCatalogsResolvable: false,
276
- });
277
- validateRecipeDocumentArtifactAt('artifacts/resolved-recipe.json', 'resolved-recipe.json', {});
357
+ if (manifest === undefined) issues.push('artifact-manifest.json is missing');
358
+ if (recipeResolution === undefined) issues.push('recipe-resolution.json is missing');
359
+ const externalRecipeIds = new Set(
360
+ isRecord(recipeResolution) && Array.isArray(recipeResolution.dependencies)
361
+ ? recipeResolution.dependencies
362
+ .filter(isRecord)
363
+ .map((dependency) => dependency.ref)
364
+ .filter((ref) => typeof ref === 'string')
365
+ : [],
366
+ );
367
+ validateRecipeDocumentValue(recipe, 'recipe.json', { externalRecipeIds });
368
+ for (const [digest, document] of Object.entries(resolvedRecipes)) {
369
+ validateRecipeDocumentValue(document, `resolved recipe ${digest}`, { externalRecipeIds });
370
+ }
371
+ }
372
+
373
+ function canonicalJson(value) {
374
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
375
+ if (isRecord(value)) {
376
+ return `{${Object.entries(value)
377
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
378
+ .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`)
379
+ .join(',')}}`;
380
+ }
381
+ return JSON.stringify(value);
278
382
  }
279
383
 
280
- function validateRecipeDocumentArtifactAt(relPath, label, options) {
384
+ function resolvedRecipeArtifactPath(digest) {
385
+ if (sharedResolvedRecipeArtifactPath) return sharedResolvedRecipeArtifactPath(digest);
386
+ if (typeof digest !== 'string' || !/^sha256:[a-f0-9]{64}$/u.test(digest)) return undefined;
387
+ return `resolved-recipes/${digest.slice('sha256:'.length)}.recipe.json`;
388
+ }
389
+
390
+ function readJsonArtifact(relPath, label) {
281
391
  const text = readText(relPath);
282
- if (!text) return;
283
- let recipe;
392
+ if (!text) return undefined;
284
393
  try {
285
- recipe = JSON.parse(text);
394
+ return JSON.parse(text);
286
395
  } catch (error) {
287
396
  issues.push(`${label}: invalid JSON: ${error.message}`);
288
- return;
397
+ return undefined;
289
398
  }
399
+ }
400
+
401
+ function validateRecipeDocumentValue(recipe, label, options) {
290
402
  if (sharedRecipeDocumentValidator) {
291
403
  const result = sharedRecipeDocumentValidator(recipe, options);
292
404
  for (const finding of result.findings) {
@@ -300,26 +412,59 @@ function validateRecipeDocumentArtifactAt(relPath, label, options) {
300
412
  issues.push(`${label}: expected object`);
301
413
  return;
302
414
  }
303
- if (recipe.schema_version !== 1) {
304
- issues.push(`${label}: schema_version must equal 1`);
305
- }
306
- const expectedSchemaUrl = sharedRecipeSchemaUrlForVersion(recipe.schema_version);
307
- if (recipe.$schema != null && recipe.$schema !== expectedSchemaUrl) {
308
- issues.push(
309
- `${label}: $schema must match schema_version (${expectedSchemaUrl ?? 'unknown schema_version'})`,
310
- );
311
- }
312
- if (!isRecord(recipe.validate) || !isRecord(recipe.validate.workflow)) {
313
- issues.push(`${label}: validate.workflow is required`);
415
+ const allowedRootFields = new Set([
416
+ '$schema',
417
+ 'title',
418
+ 'description',
419
+ 'paramsSchema',
420
+ 'proofTargets',
421
+ 'workflow',
422
+ ]);
423
+ for (const field of Object.keys(recipe)) {
424
+ if (!allowedRootFields.has(field))
425
+ issues.push(`${label}: unsupported top-level field ${field}`);
426
+ }
427
+ if (recipe.$schema !== RECIPE_SCHEMA_URL) {
428
+ issues.push(`${label}: $schema must equal ${RECIPE_SCHEMA_URL}`);
429
+ }
430
+ if (typeof recipe.description !== 'string' || !recipe.description.trim()) {
431
+ issues.push(`${label}: description must be a non-empty string`);
432
+ }
433
+ if (!isRecord(recipe.workflow)) {
434
+ issues.push(`${label}: workflow is required`);
314
435
  return;
315
436
  }
316
- const workflow = recipe.validate.workflow;
437
+ const workflow = recipe.workflow;
317
438
  if (typeof workflow.entry !== 'string' || !workflow.entry.trim()) {
318
- issues.push(`${label}: validate.workflow.entry must be a non-empty string`);
439
+ issues.push(`${label}: workflow.entry must be a non-empty string`);
319
440
  }
320
441
  if (!isRecord(workflow.nodes) || Object.keys(workflow.nodes).length === 0) {
321
- issues.push(`${label}: validate.workflow.nodes must be a non-empty object`);
442
+ issues.push(`${label}: workflow.nodes must be a non-empty object`);
443
+ return;
322
444
  }
445
+ for (const [nodeId, node] of Object.entries(workflow.nodes)) {
446
+ if (!isRecord(node) || typeof node.action !== 'string' || !node.action.trim()) {
447
+ issues.push(`${label}: workflow.nodes.${nodeId}.action must be a non-empty string`);
448
+ continue;
449
+ }
450
+ if (node.action !== 'end' && (typeof node.intent !== 'string' || !node.intent.trim())) {
451
+ issues.push(`${label}: workflow.nodes.${nodeId}.intent must be a non-empty string`);
452
+ }
453
+ }
454
+ }
455
+
456
+ function listArtifactPaths(root) {
457
+ if (!existsSync(root)) return [];
458
+ const paths = [];
459
+ const visit = (dir, prefix) => {
460
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
461
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
462
+ if (entry.isDirectory()) visit(path.join(dir, entry.name), relative);
463
+ else if (entry.isFile()) paths.push(relative);
464
+ }
465
+ };
466
+ visit(root, '');
467
+ return paths.sort();
323
468
  }
324
469
 
325
470
  function parseManifest() {