@deeeed/metamask-harness 0.51.5 → 0.51.7

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.
@@ -0,0 +1,56 @@
1
+ import {
2
+ changedFiles,
3
+ classifyDomain,
4
+ configPath,
5
+ discoverLibraries,
6
+ readConfig
7
+ } from "../review/knowledge.js";
8
+ import { optionFlag, optionString, parseArgs, targetPath, usageError } from "./parse-args.js";
9
+ import { adapterFromOptions } from "./review.js";
10
+ async function handleDomain(argv) {
11
+ const parsed = parseArgs(argv, "domain");
12
+ const target = targetPath(parsed.options);
13
+ const json = optionFlag(parsed.options, "json");
14
+ const declared = optionString(parsed.options, "domain") ?? process.env.MM_HARNESS_DOMAIN;
15
+ const detected = adapterFromOptions(parsed.options, target);
16
+ const adapter = detected === "unscoped" ? void 0 : detected;
17
+ if (declared) {
18
+ emit(json, { domain: declared, source: "declared", adapter: adapter ?? null, target });
19
+ return 0;
20
+ }
21
+ if (!adapter) {
22
+ throw usageError("No MetaMask checkout detected: pass --adapter <mobile|extension|core> or --domain <name>.");
23
+ }
24
+ const config = readConfig(configPath());
25
+ const libraries = discoverLibraries({ target, config });
26
+ const { base, files } = changedFiles(target, optionString(parsed.options, "base"));
27
+ const matches = classifyDomain(files, adapter, libraries);
28
+ const best = matches[0];
29
+ emit(json, {
30
+ domain: best?.domain ?? null,
31
+ source: best ? "owned-paths" : "none",
32
+ adapter,
33
+ target,
34
+ base,
35
+ changedFiles: files.length,
36
+ libraries: libraries.map((library) => ({ name: library.name, root: library.root, source: library.source, revision: library.revision ?? null })),
37
+ matches: matches.map((match) => ({ domain: match.domain, library: match.library.root, matched: match.matched }))
38
+ });
39
+ return 0;
40
+ }
41
+ function emit(json, payload) {
42
+ if (json) {
43
+ process.stdout.write(`${JSON.stringify({ command: "domain", ...payload }, null, 2)}
44
+ `);
45
+ return;
46
+ }
47
+ process.stdout.write(`${payload.domain ?? "none"}
48
+ `);
49
+ const best = payload.matches?.[0];
50
+ const detail = payload.source === "declared" ? "declared via --domain or MM_HARNESS_DOMAIN" : best ? `owned-paths match in ${best.library} (${best.matched.length} of ${payload.changedFiles} changed files)` : `no library owns the ${payload.changedFiles} changed files (base ${payload.base}; libraries scanned: ${payload.libraries?.length ?? 0})`;
51
+ process.stderr.write(`domain: ${detail}
52
+ `);
53
+ }
54
+ export {
55
+ handleDomain
56
+ };
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { color } from "../cli-color.js";
4
4
  import { detectAdapter } from "../harness.js";
5
5
  import { optionString, parseArgs, targetPath } from "./parse-args.js";
6
+ import { handleHelpReview } from "./review.js";
6
7
  const UNSCOPED_NEXT = "cd into a MetaMask checkout or pass --adapter <mobile|extension|core>";
7
8
  function validHelpLine(value) {
8
9
  return typeof value === "string" || Boolean(value && typeof value.text === "string" && Array.isArray(value.adapters) && value.adapters.length > 0 && value.adapters.every((adapter) => ["mobile", "extension", "core"].includes(adapter)));
@@ -70,6 +71,7 @@ function renderRecipeHelp(topic, harnessVersion, adapter) {
70
71
  }
71
72
  function handleHelp(argv, context) {
72
73
  const parsed = parseArgs(argv, "help");
74
+ if (parsed.positional[0] === "review") return handleHelpReview(argv, context.harnessVersion);
73
75
  const target = targetPath(parsed.options);
74
76
  const adapter = optionString(parsed.options, "adapter") ?? detectAdapter(target) ?? "unscoped";
75
77
  const topic = loadRecipeHelp(context.packageRoot);
@@ -0,0 +1,31 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { BODY_FILE, renderPrBody } from "../pr-body/render.js";
4
+ import { optionFlag, optionString, parseArgs, usageError } from "./parse-args.js";
5
+ const USAGE = "Usage: mm-harness pr-body render <task-dir> [--command <text>] [--out <file>] [--json]";
6
+ async function handlePrBody(argv) {
7
+ const parsed = parseArgs(argv, "pr-body");
8
+ const [action, taskArg] = parsed.positional;
9
+ if (action !== "render" || !taskArg) throw usageError(USAGE);
10
+ const taskDir = path.resolve(taskArg);
11
+ if (!fs.existsSync(path.join(taskDir, "artifacts"))) {
12
+ throw usageError(`${taskDir} has no artifacts/ directory; pass the task directory mm-harness task init wrote.`);
13
+ }
14
+ const command = optionString(parsed.options, "command");
15
+ const result = renderPrBody({ taskDir, ...command ? { command } : {} });
16
+ const out = path.resolve(optionString(parsed.options, "out") ?? path.join(taskDir, "artifacts", BODY_FILE));
17
+ fs.mkdirSync(path.dirname(out), { recursive: true });
18
+ fs.writeFileSync(out, result.body);
19
+ if (optionFlag(parsed.options, "json")) {
20
+ process.stdout.write(`${JSON.stringify({ command: "pr-body", subcommand: "render", status: "ok", taskDir, out, ...result, body: void 0 }, null, 2)}
21
+ `);
22
+ } else {
23
+ process.stdout.write(`pr body: ${out}
24
+ ${result.sections.join("\n ")}
25
+ `);
26
+ }
27
+ return 0;
28
+ }
29
+ export {
30
+ handlePrBody
31
+ };
@@ -0,0 +1,135 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { detectAdapter } from "../harness.js";
4
+ import { runnerDir } from "../paths.js";
5
+ import {
6
+ configPath,
7
+ loadDomainKnowledge,
8
+ parityAdapter,
9
+ readConfig,
10
+ referenceHint,
11
+ renderReviewChecklist,
12
+ renderReviewHelp,
13
+ resolveLibrary,
14
+ resolveReference,
15
+ defaultBaseRef
16
+ } from "../review/knowledge.js";
17
+ import { optionFlag, optionString, parseArgs, targetPath, usageError } from "./parse-args.js";
18
+ const EXIT_OK = 0;
19
+ let cachedHarnessVersion;
20
+ function harnessVersion() {
21
+ if (cachedHarnessVersion === void 0) {
22
+ const pkg = JSON.parse(fs.readFileSync(path.join(runnerDir, "package.json"), "utf8"));
23
+ cachedHarnessVersion = pkg.version ?? "unknown";
24
+ }
25
+ return cachedHarnessVersion;
26
+ }
27
+ function adapterFromOptions(options, target) {
28
+ const raw = optionString(options, "adapter") ?? optionString(options, "platform");
29
+ if (raw !== void 0) {
30
+ if (raw !== "mobile" && raw !== "extension" && raw !== "core") throw usageError(`--adapter must be mobile, extension, or core (got ${raw}).`);
31
+ return raw;
32
+ }
33
+ return detectAdapter(target) ?? "unscoped";
34
+ }
35
+ function resolveReviewContext(options, { fetch = true } = {}) {
36
+ const target = targetPath(options);
37
+ const adapter = adapterFromOptions(options, target);
38
+ const domain = optionString(options, "domain") ?? process.env.MM_HARNESS_DOMAIN;
39
+ const config = readConfig(configPath());
40
+ const context = { target, adapter, ...domain ? { domain } : {} };
41
+ if (domain) {
42
+ const library = resolveLibrary(domain, { target, config, fetch });
43
+ if (library) context.knowledge = loadDomainKnowledge(library, adapter === "unscoped" ? void 0 : adapter);
44
+ else if (fetch) throw usageError(`No library found for domain "${domain}". Set RECIPE_LIBRARY_PATH="${domain}=<path>" or run: mm-harness config set libraries.${domain} <path>`);
45
+ }
46
+ const parity = parityAdapter(adapter);
47
+ if (parity) {
48
+ const reference = resolveReference(parity, { target, config });
49
+ if (reference) context.reference = reference;
50
+ else context.referenceMissing = `no ${parity} checkout found (${referenceHint(parity)})`;
51
+ }
52
+ return context;
53
+ }
54
+ function handleHelpReview(argv, harnessVersion2) {
55
+ const parsed = parseArgs(argv, "help");
56
+ const context = resolveReviewContext(parsed.options, { fetch: false });
57
+ const input = {
58
+ adapter: context.adapter,
59
+ harnessVersion: harnessVersion2,
60
+ ...context.domain ? { domain: context.domain } : {},
61
+ ...context.knowledge ? { knowledge: context.knowledge } : {},
62
+ ...context.reference ? { reference: context.reference } : {},
63
+ ...context.referenceMissing ? { referenceMissing: context.referenceMissing } : {}
64
+ };
65
+ if (optionFlag(parsed.options, "json")) {
66
+ process.stdout.write(`${JSON.stringify({ command: "help", topic: "review", ...input }, null, 2)}
67
+ `);
68
+ } else {
69
+ process.stdout.write(renderReviewHelp(input));
70
+ }
71
+ return EXIT_OK;
72
+ }
73
+ async function handleReview(argv, version = harnessVersion()) {
74
+ const parsed = parseArgs(argv, "review");
75
+ const subcommand = parsed.positional[0];
76
+ if (subcommand !== "checklist") {
77
+ throw usageError("Usage: mm-harness review checklist [--domain <name>] [--since <sha>] [--base <ref>] [--out <file>] [--json]");
78
+ }
79
+ const context = resolveReviewContext(parsed.options);
80
+ const since = optionString(parsed.options, "since");
81
+ const base = optionString(parsed.options, "base") ?? defaultBaseRef(context.target);
82
+ const checklist = renderReviewChecklist({
83
+ adapter: context.adapter,
84
+ target: context.target,
85
+ base,
86
+ harnessVersion: version,
87
+ ...since ? { since } : {},
88
+ ...context.domain ? { domain: context.domain } : {},
89
+ ...context.knowledge ? { knowledge: context.knowledge } : {},
90
+ ...context.reference ? { reference: context.reference } : {},
91
+ ...context.referenceMissing ? { referenceMissing: context.referenceMissing } : {}
92
+ });
93
+ const out = optionString(parsed.options, "out");
94
+ if (out) {
95
+ const file = path.resolve(out);
96
+ fs.mkdirSync(path.dirname(file), { recursive: true });
97
+ fs.writeFileSync(file, checklist);
98
+ }
99
+ if (optionFlag(parsed.options, "json")) {
100
+ process.stdout.write(
101
+ `${JSON.stringify(
102
+ {
103
+ command: "review",
104
+ subcommand: "checklist",
105
+ target: context.target,
106
+ adapter: context.adapter,
107
+ domain: context.domain ?? null,
108
+ library: context.knowledge?.library ?? null,
109
+ reference: context.reference ?? null,
110
+ referenceMissing: context.referenceMissing ?? null,
111
+ base,
112
+ since: since ?? null,
113
+ out: out ? path.resolve(out) : null,
114
+ checklist
115
+ },
116
+ null,
117
+ 2
118
+ )}
119
+ `
120
+ );
121
+ } else if (out) {
122
+ process.stdout.write(`review checklist: ${path.resolve(out)}
123
+ `);
124
+ } else {
125
+ process.stdout.write(checklist);
126
+ }
127
+ return EXIT_OK;
128
+ }
129
+ export {
130
+ adapterFromOptions,
131
+ handleHelpReview,
132
+ handleReview,
133
+ harnessVersion,
134
+ resolveReviewContext
135
+ };
@@ -35,9 +35,99 @@ const REAL = [
35
35
  --target <path> Checkout path (default: cwd)
36
36
  --json Load the same guide as structured agent context
37
37
 
38
+ help review [--domain <name>] prints the review guide: the base review every PR
39
+ gets, plus the team library's anti-pattern families, parity rule, and shared
40
+ package notes when --domain names a library (see mm-harness review --help).
41
+
38
42
  Example:
39
43
  mm-harness help
40
- mm-harness help --json`
44
+ mm-harness help --json
45
+ mm-harness help review --domain perps`
46
+ },
47
+ {
48
+ name: "review",
49
+ summary: "Materialize a review checklist: base review + team library patterns + parity + verdict.",
50
+ example: "mm-harness review checklist --domain perps",
51
+ helpText: `mm-harness review checklist [flags]
52
+
53
+ Writes a phase-structured checklist (Setup, Base review, Domain patterns,
54
+ Parity, Verdict) that mm-harness checklist mark can track. Domain patterns are
55
+ one line per "##" section of the library's review/antipatterns.md; Parity names
56
+ the other platform's checkout when one resolves and says "not checked" otherwise.
57
+
58
+ Library resolution for --domain <name>: RECIPE_LIBRARY_PATH entry \u2192 engineer
59
+ config (mm-harness config) \u2192 sibling directory beside the checkout \u2192 shallow
60
+ fetch into <checkout>/.skills-cache/<name> (known libraries only). Reference
61
+ checkout resolution: MM_HARNESS_REF_<ADAPTER> \u2192 engineer config \u2192 sibling
62
+ checkout of that adapter.
63
+
64
+ --domain <name> Team library to compose (default: MM_HARNESS_DOMAIN; none = base review only)
65
+ --since <sha> Incremental re-review: scope the checklist to <sha>..HEAD
66
+ --base <ref> Base ref for the diff range (default: origin/main when present)
67
+ --out <file> Also write the checklist to <file>
68
+ --adapter, --target, --json
69
+
70
+ Example:
71
+ mm-harness review checklist --domain perps
72
+ mm-harness review checklist --domain perps --since 4dd9be9 --out temp/tasks/review/CHECKLIST.md
73
+ mm-harness review checklist --json`
74
+ },
75
+ {
76
+ name: "domain",
77
+ summary: "Which team library owns the change: declared value first, else owned-paths.json against the changed files.",
78
+ example: "mm-harness domain",
79
+ helpText: `mm-harness domain [flags]
80
+
81
+ Prints the domain name (or "none"). A declared --domain / MM_HARNESS_DOMAIN wins;
82
+ otherwise the changed files (base...HEAD, working tree, untracked) are matched
83
+ against owned-paths.json in every reachable library (RECIPE_LIBRARY_PATH,
84
+ mm-harness config, sibling directories, .skills-cache).
85
+
86
+ --domain <name> Declared domain (skips classification)
87
+ --base <ref> Base ref for the changed-file range (default: origin/main when present)
88
+ --adapter, --target, --json
89
+
90
+ Example:
91
+ mm-harness domain
92
+ mm-harness domain --json`
93
+ },
94
+ {
95
+ name: "pr-body",
96
+ summary: "Render the publishable PR body: the authored description plus the recipe and run log sections built from artifacts.",
97
+ example: "mm-harness pr-body render temp/tasks/dev/TAT-1",
98
+ helpText: `mm-harness pr-body render <task-dir> [flags]
99
+
100
+ Reads <task-dir>/artifacts/pr-description.md (the PR body the worker wrote in
101
+ the repository PR template shape) and inserts the machine sections rendered
102
+ from artifacts: "Validation Recipe" from artifacts/recipe.json, "Validation
103
+ Logs" from artifacts/recipe-run/report.md, and "Recipe Workflow" from
104
+ artifacts/workflow.mmd when it exists. Any such section the worker pasted by
105
+ hand is replaced. The sections go before the first checklist section, else at
106
+ the end. Fences are always longer than any backtick run in the content and
107
+ close on their own line. Writes <task-dir>/artifacts/pr-body.md.
108
+
109
+ --command <text> Recipe invocation to show above the run log
110
+ --out <file> Write elsewhere than artifacts/pr-body.md
111
+ --json Machine-readable result
112
+
113
+ Example:
114
+ mm-harness pr-body render temp/tasks/dev/TAT-1 --json`
115
+ },
116
+ {
117
+ name: "config",
118
+ summary: "Per-engineer machine locations for team libraries and reference checkouts.",
119
+ example: "mm-harness config set references.extension ~/dev/metamask-extension",
120
+ helpText: `mm-harness config <list|path|get <key>|set <key> <path>|unset <key>> [--json]
121
+
122
+ Stored outside every repo in ~/.mm-harness/config.json (override: MM_HARNESS_CONFIG).
123
+ Keys: libraries.<name> (team library checkout), references.<mobile|extension|core>
124
+ (the checkout used as the parity reference). Env always wins over config:
125
+ RECIPE_LIBRARY_PATH for libraries, MM_HARNESS_REF_<ADAPTER> for references.
126
+
127
+ Example:
128
+ mm-harness config set libraries.perps ~/dev/experimental-metamask-recipe-perps
129
+ mm-harness config set references.extension ~/dev/metamask-extension
130
+ mm-harness config list`
41
131
  },
42
132
  {
43
133
  name: "tutorial",
@@ -783,12 +873,12 @@ const HELP_GROUPS = [
783
873
  {
784
874
  title: "DISCOVER",
785
875
  blurb: "discover atomic actions and reusable recipes (--json is the agent-primary form)",
786
- commands: ["help", "tutorial", "actions", "call", "execution-template"]
876
+ commands: ["help", "tutorial", "actions", "call", "execution-template", "domain", "config"]
787
877
  },
788
878
  {
789
879
  title: "PROVE",
790
880
  blurb: "run recipes and inspect readiness",
791
- commands: ["run", "last", "doctor", "check", "checklist", "recipe-quality"]
881
+ commands: ["run", "last", "doctor", "check", "checklist", "review", "pr-body", "recipe-quality"]
792
882
  },
793
883
  {
794
884
  title: "RUNTIME OVERLAY",
@@ -0,0 +1,136 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ const PROSE_FILE = "pr-description.md";
4
+ const BODY_FILE = "pr-body.md";
5
+ const RECIPE_HEADING = "## **Validation Recipe**";
6
+ const LOGS_HEADING = "## **Validation Logs**";
7
+ const WORKFLOW_HEADING = "## **Recipe Workflow**";
8
+ function headingKey(heading) {
9
+ return heading.replace(/^#+\s*/, "").replace(/[*_`]/g, "").replace(/\s+/g, " ").trim().toLowerCase();
10
+ }
11
+ const MACHINE_KEYS = new Set([RECIPE_HEADING, LOGS_HEADING, WORKFLOW_HEADING].map(headingKey));
12
+ function levelTwoHeadingLines(lines) {
13
+ const found = [];
14
+ lines.forEach((line, index) => {
15
+ if (/^ {0,3}##(?!#)\s+\S/.test(line)) found.push(index);
16
+ });
17
+ return found;
18
+ }
19
+ function fenceFor(content) {
20
+ let longest = 0;
21
+ for (const run of content.match(/`+/g) ?? []) longest = Math.max(longest, run.length);
22
+ return "`".repeat(Math.max(3, longest + 1));
23
+ }
24
+ function fenced(content, info = "") {
25
+ const fence = fenceFor(content);
26
+ return `${fence}${info}
27
+ ${content.replace(/\n+$/, "")}
28
+ ${fence}`;
29
+ }
30
+ function escapeHtml(text) {
31
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
32
+ }
33
+ function countRecipeNodes(recipe) {
34
+ if (!recipe || typeof recipe !== "object") return 0;
35
+ const record = recipe;
36
+ if (Array.isArray(record.nodes)) return record.nodes.length;
37
+ if (Array.isArray(record.steps)) return record.steps.length;
38
+ const workflow = record.workflow;
39
+ if (!workflow || typeof workflow !== "object") return 0;
40
+ return Object.values(workflow).reduce(
41
+ (total, value) => total + (Array.isArray(value) ? value.length : 0),
42
+ 0
43
+ );
44
+ }
45
+ function recipeSection(file) {
46
+ const recipe = JSON.parse(fs.readFileSync(file, "utf8"));
47
+ const nodes = countRecipeNodes(recipe);
48
+ const record = recipe && typeof recipe === "object" ? recipe : {};
49
+ const title = typeof record.title === "string" ? record.title : typeof record.description === "string" ? record.description : "";
50
+ const summary = escapeHtml(`recipe.json (${nodes} steps${title ? ` \u2014 ${title.slice(0, 120)}` : ""})`);
51
+ return {
52
+ nodes,
53
+ body: [`<details><summary>${summary}</summary>`, "", fenced(JSON.stringify(recipe, null, 2), "json"), "</details>"].join("\n")
54
+ };
55
+ }
56
+ function logsSection(file, command) {
57
+ const report = fs.readFileSync(file, "utf8");
58
+ const status = /^Status:\s*(\S+)/m.exec(report)?.[1];
59
+ const nodes = /^Nodes:\s*(.+?)\s*$/m.exec(report)?.[1];
60
+ const summary = escapeHtml(status || nodes ? `Full output (${[nodes, status].filter(Boolean).join(", ")})` : "Full output");
61
+ const lines = [];
62
+ if (command) lines.push("Command:", "", fenced(command, "bash"), "");
63
+ lines.push(`<details><summary>${summary}</summary>`, "", fenced(report), "</details>");
64
+ return lines.join("\n");
65
+ }
66
+ function trimEnd(lines) {
67
+ let end = lines.length;
68
+ while (end > 0 && !lines[end - 1].trim()) end -= 1;
69
+ return lines.slice(0, end);
70
+ }
71
+ function renderPrBody(input) {
72
+ const artifacts = path.join(input.taskDir, "artifacts");
73
+ const prosePath = path.join(artifacts, PROSE_FILE);
74
+ if (!fs.existsSync(prosePath)) {
75
+ throw new Error(`Missing ${path.relative(input.taskDir, prosePath)}: write the PR description there first.`);
76
+ }
77
+ const recipePath = path.join(artifacts, "recipe.json");
78
+ const reportPath = path.join(artifacts, "recipe-run", "report.md");
79
+ const workflowPath = path.join(artifacts, "workflow.mmd");
80
+ const machine = [];
81
+ let recipe = null;
82
+ if (fs.existsSync(recipePath)) {
83
+ const rendered = recipeSection(recipePath);
84
+ recipe = { nodes: rendered.nodes, path: path.relative(input.taskDir, recipePath) };
85
+ machine.push([RECIPE_HEADING, rendered.body]);
86
+ } else {
87
+ machine.push([RECIPE_HEADING, "Not applicable: this task has no `artifacts/recipe.json`."]);
88
+ }
89
+ const hasReport = fs.existsSync(reportPath);
90
+ machine.push([
91
+ LOGS_HEADING,
92
+ hasReport ? logsSection(reportPath, input.command) : "Not applicable: no recipe run report under `artifacts/recipe-run/`."
93
+ ]);
94
+ const hasWorkflow = fs.existsSync(workflowPath);
95
+ if (hasWorkflow) {
96
+ machine.push([
97
+ WORKFLOW_HEADING,
98
+ ["<details><summary>workflow.mmd</summary>", "", fenced(fs.readFileSync(workflowPath, "utf8"), "mermaid"), "</details>"].join("\n")
99
+ ]);
100
+ }
101
+ const lines = fs.readFileSync(prosePath, "utf8").replace(/\r\n?/g, "\n").split("\n");
102
+ const headings = levelTwoHeadingLines(lines);
103
+ const keep = new Array(lines.length).fill(true);
104
+ let insertAt = lines.length;
105
+ headings.forEach((start, at) => {
106
+ const end = headings[at + 1] ?? lines.length;
107
+ const key = headingKey(lines[start]);
108
+ if (MACHINE_KEYS.has(key)) keep.fill(false, start, end);
109
+ else if (insertAt === lines.length && /checklist/.test(key)) insertAt = start;
110
+ });
111
+ const before = lines.slice(0, insertAt).filter((_, index) => keep[index]);
112
+ const after = lines.slice(insertAt).filter((_, index) => keep[insertAt + index]);
113
+ const generated = machine.flatMap(([heading, body2]) => [heading, "", body2, ""]);
114
+ const body = [...trimEnd(before), "", ...generated, ...after].join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n*$/, "\n");
115
+ return {
116
+ body,
117
+ sections: machine.map(([heading]) => heading),
118
+ recipe,
119
+ run: hasReport ? { path: path.relative(input.taskDir, reportPath) } : null,
120
+ workflow: hasWorkflow ? { path: path.relative(input.taskDir, workflowPath) } : null
121
+ };
122
+ }
123
+ export {
124
+ BODY_FILE,
125
+ LOGS_HEADING,
126
+ PROSE_FILE,
127
+ RECIPE_HEADING,
128
+ WORKFLOW_HEADING,
129
+ countRecipeNodes,
130
+ escapeHtml,
131
+ fenceFor,
132
+ fenced,
133
+ headingKey,
134
+ levelTwoHeadingLines,
135
+ renderPrBody
136
+ };