@davidorex/pi-workflows 0.1.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/CHANGELOG.md +28 -0
- package/README.md +169 -0
- package/agents/audit-fixer.agent.yaml +18 -0
- package/agents/code-explorer.agent.yaml +11 -0
- package/agents/decomposer.agent.yaml +51 -0
- package/agents/investigator.agent.yaml +49 -0
- package/agents/pattern-analyzer.agent.yaml +21 -0
- package/agents/phase-author.agent.yaml +16 -0
- package/agents/plan-decomposer.agent.yaml +12 -0
- package/agents/quality-analyzer.agent.yaml +21 -0
- package/agents/researcher.agent.yaml +38 -0
- package/agents/spec-implementer.agent.yaml +12 -0
- package/agents/structure-analyzer.agent.yaml +21 -0
- package/agents/synthesizer.agent.yaml +23 -0
- package/agents/verifier.agent.yaml +12 -0
- package/package.json +40 -0
- package/schemas/decomposition-specs.schema.json +50 -0
- package/schemas/execution-results.schema.json +50 -0
- package/schemas/investigation-findings.schema.json +45 -0
- package/schemas/pattern-analysis.schema.json +43 -0
- package/schemas/plan-breakdown.schema.json +22 -0
- package/schemas/quality-analysis.schema.json +35 -0
- package/schemas/research-findings.schema.json +44 -0
- package/schemas/structure-analysis.schema.json +42 -0
- package/schemas/synthesis.schema.json +33 -0
- package/schemas/verifier-output.schema.json +96 -0
- package/src/agent-spec.test.ts +289 -0
- package/src/agent-spec.ts +122 -0
- package/src/block-validation.test.ts +350 -0
- package/src/checkpoint.test.ts +237 -0
- package/src/checkpoint.ts +139 -0
- package/src/completion.test.ts +143 -0
- package/src/completion.ts +68 -0
- package/src/dag.test.ts +350 -0
- package/src/dag.ts +219 -0
- package/src/dispatch.test.ts +473 -0
- package/src/dispatch.ts +353 -0
- package/src/expression.test.ts +353 -0
- package/src/expression.ts +332 -0
- package/src/format.test.ts +80 -0
- package/src/format.ts +41 -0
- package/src/graduated-failure.test.ts +556 -0
- package/src/index.test.ts +114 -0
- package/src/index.ts +488 -0
- package/src/loop.test.ts +549 -0
- package/src/output.test.ts +213 -0
- package/src/output.ts +70 -0
- package/src/parallel-integration.test.ts +175 -0
- package/src/resume.test.ts +192 -0
- package/src/state.test.ts +192 -0
- package/src/state.ts +253 -0
- package/src/step-agent.test.ts +327 -0
- package/src/step-agent.ts +178 -0
- package/src/step-command.test.ts +330 -0
- package/src/step-command.ts +132 -0
- package/src/step-foreach.test.ts +647 -0
- package/src/step-foreach.ts +148 -0
- package/src/step-gate.test.ts +185 -0
- package/src/step-gate.ts +116 -0
- package/src/step-loop.test.ts +626 -0
- package/src/step-loop.ts +323 -0
- package/src/step-parallel.test.ts +475 -0
- package/src/step-parallel.ts +168 -0
- package/src/step-pause.test.ts +30 -0
- package/src/step-pause.ts +27 -0
- package/src/step-shared.test.ts +355 -0
- package/src/step-shared.ts +157 -0
- package/src/step-transform.test.ts +155 -0
- package/src/step-transform.ts +47 -0
- package/src/template-integration.test.ts +72 -0
- package/src/template.test.ts +162 -0
- package/src/template.ts +92 -0
- package/src/test-helpers.ts +51 -0
- package/src/tui.test.ts +355 -0
- package/src/tui.ts +182 -0
- package/src/types.ts +195 -0
- package/src/verifier-schema.test.ts +226 -0
- package/src/workflow-discovery.test.ts +105 -0
- package/src/workflow-discovery.ts +113 -0
- package/src/workflow-executor.test.ts +1530 -0
- package/src/workflow-executor.ts +810 -0
- package/src/workflow-sdk.test.ts +238 -0
- package/src/workflow-sdk.ts +262 -0
- package/src/workflow-spec.test.ts +576 -0
- package/src/workflow-spec.ts +471 -0
- package/templates/analyzers/base-analyzer.md +9 -0
- package/templates/analyzers/macros.md +1 -0
- package/templates/analyzers/patterns-task.md +11 -0
- package/templates/analyzers/patterns.md +15 -0
- package/templates/analyzers/quality-task.md +11 -0
- package/templates/analyzers/quality.md +15 -0
- package/templates/analyzers/structure-task.md +17 -0
- package/templates/analyzers/structure.md +15 -0
- package/templates/audit-fixer/task.md +67 -0
- package/templates/decomposer/task.md +56 -0
- package/templates/explorer/system.md +3 -0
- package/templates/explorer/task.md +9 -0
- package/templates/investigator/task.md +30 -0
- package/templates/phase-author/task.md +74 -0
- package/templates/plan-decomposer/task.md +46 -0
- package/templates/researcher/task.md +26 -0
- package/templates/spec-implementer/task.md +53 -0
- package/templates/synthesizer/system.md +3 -0
- package/templates/synthesizer/task.md +38 -0
- package/templates/verifier/task.md +57 -0
- package/workflows/create-phase.workflow.yaml +67 -0
- package/workflows/do-gap.workflow.yaml +153 -0
- package/workflows/fix-audit.workflow.yaml +217 -0
- package/workflows/gap-to-phase.workflow.yaml +98 -0
- package/workflows/parallel-analysis.workflow.yaml +62 -0
- package/workflows/parallel-explicit.workflow.yaml +42 -0
- package/workflows/pausable-analysis.workflow.yaml +44 -0
- package/workflows/resumable-analysis.workflow.yaml +42 -0
- package/workflows/self-implement.workflow.yaml +63 -0
- package/workflows/typed-analysis.workflow.yaml +63 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transform step executor — produces output by resolving expressions in a mapping.
|
|
3
|
+
* No LLM call, no subprocess, no shell command — pure expression resolution.
|
|
4
|
+
*/
|
|
5
|
+
import type { TransformSpec, StepResult } from "./types.ts";
|
|
6
|
+
import { resolveExpressions } from "./expression.ts";
|
|
7
|
+
import { zeroUsage } from "./step-shared.ts";
|
|
8
|
+
import { persistStepOutput } from "./output.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Execute a transform step: produces output by resolving expressions in the mapping.
|
|
12
|
+
* No LLM call, no subprocess, no shell command — pure expression resolution.
|
|
13
|
+
*/
|
|
14
|
+
export function executeTransform(
|
|
15
|
+
transform: TransformSpec,
|
|
16
|
+
stepName: string,
|
|
17
|
+
scope: Record<string, unknown>,
|
|
18
|
+
runDir?: string,
|
|
19
|
+
outputPath?: string,
|
|
20
|
+
): StepResult {
|
|
21
|
+
const startTime = Date.now();
|
|
22
|
+
try {
|
|
23
|
+
const output = resolveExpressions(transform.mapping, scope);
|
|
24
|
+
const result: StepResult = {
|
|
25
|
+
step: stepName,
|
|
26
|
+
agent: "transform",
|
|
27
|
+
status: "completed",
|
|
28
|
+
output,
|
|
29
|
+
textOutput: JSON.stringify(output, null, 2),
|
|
30
|
+
usage: zeroUsage(),
|
|
31
|
+
durationMs: Date.now() - startTime,
|
|
32
|
+
};
|
|
33
|
+
if (runDir) {
|
|
34
|
+
result.outputPath = persistStepOutput(runDir, stepName, output, undefined, outputPath);
|
|
35
|
+
}
|
|
36
|
+
return result;
|
|
37
|
+
} catch (err) {
|
|
38
|
+
return {
|
|
39
|
+
step: stepName,
|
|
40
|
+
agent: "transform",
|
|
41
|
+
status: "failed",
|
|
42
|
+
usage: zeroUsage(),
|
|
43
|
+
durationMs: Date.now() - startTime,
|
|
44
|
+
error: err instanceof Error ? err.message : String(err),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { createTemplateEnv, renderTemplateFile } from "./template.ts";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
|
|
8
|
+
describe("analyzer template inheritance", () => {
|
|
9
|
+
const builtinDir = path.resolve(import.meta.dirname, "..", "templates");
|
|
10
|
+
|
|
11
|
+
it("structure analyzer inherits base and overrides blocks", () => {
|
|
12
|
+
const env = createTemplateEnv("/nonexistent", builtinDir);
|
|
13
|
+
const result = renderTemplateFile(env, "analyzers/structure.md", {});
|
|
14
|
+
assert.ok(result.includes("code structure analyst"));
|
|
15
|
+
assert.ok(result.includes("Architecture"));
|
|
16
|
+
assert.ok(result.includes("Module boundaries"));
|
|
17
|
+
assert.ok(!result.includes("Test coverage"));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("quality analyzer inherits base and overrides blocks", () => {
|
|
21
|
+
const env = createTemplateEnv("/nonexistent", builtinDir);
|
|
22
|
+
const result = renderTemplateFile(env, "analyzers/quality.md", {});
|
|
23
|
+
assert.ok(result.includes("code quality analyst"));
|
|
24
|
+
assert.ok(result.includes("Test coverage"));
|
|
25
|
+
assert.ok(!result.includes("Architecture"));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("pattern analyzer inherits base and overrides blocks", () => {
|
|
29
|
+
const env = createTemplateEnv("/nonexistent", builtinDir);
|
|
30
|
+
const result = renderTemplateFile(env, "analyzers/patterns.md", {});
|
|
31
|
+
assert.ok(result.includes("design pattern analyst"));
|
|
32
|
+
assert.ok(result.includes("Design patterns"));
|
|
33
|
+
assert.ok(result.includes("Anti-patterns"));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("all three produce different output from the same base", () => {
|
|
37
|
+
const env = createTemplateEnv("/nonexistent", builtinDir);
|
|
38
|
+
const s = renderTemplateFile(env, "analyzers/structure.md", {});
|
|
39
|
+
const q = renderTemplateFile(env, "analyzers/quality.md", {});
|
|
40
|
+
const p = renderTemplateFile(env, "analyzers/patterns.md", {});
|
|
41
|
+
// All contain the shared preamble pattern
|
|
42
|
+
assert.ok(s.includes("Given an exploration summary"));
|
|
43
|
+
assert.ok(q.includes("Given an exploration summary"));
|
|
44
|
+
assert.ok(p.includes("Given an exploration summary"));
|
|
45
|
+
// But different identities
|
|
46
|
+
assert.ok(s.includes("structure"));
|
|
47
|
+
assert.ok(q.includes("quality"));
|
|
48
|
+
assert.ok(p.includes("pattern"));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("project template shadows builtin", () => {
|
|
52
|
+
const tmpProject = fs.mkdtempSync(path.join(os.tmpdir(), "pi-tmpl-proj-"));
|
|
53
|
+
const projectTemplates = path.join(tmpProject, ".pi", "templates", "analyzers");
|
|
54
|
+
fs.mkdirSync(projectTemplates, { recursive: true });
|
|
55
|
+
|
|
56
|
+
fs.writeFileSync(path.join(projectTemplates, "structure.md"), [
|
|
57
|
+
'{% extends "analyzers/base-analyzer.md" %}',
|
|
58
|
+
"{% block identity %}You are a CUSTOM structure analyst.{% endblock %}",
|
|
59
|
+
"{% block checklist %}",
|
|
60
|
+
"1. **Custom**: Project-specific check",
|
|
61
|
+
"{% endblock %}",
|
|
62
|
+
].join("\n"));
|
|
63
|
+
|
|
64
|
+
const env = createTemplateEnv(tmpProject, builtinDir);
|
|
65
|
+
const result = renderTemplateFile(env, "analyzers/structure.md", {});
|
|
66
|
+
assert.ok(result.includes("CUSTOM structure analyst"));
|
|
67
|
+
assert.ok(result.includes("Project-specific check"));
|
|
68
|
+
assert.ok(!result.includes("Module boundaries"));
|
|
69
|
+
|
|
70
|
+
fs.rmSync(tmpProject, { recursive: true });
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { createTemplateEnv, renderTemplate, renderTemplateFile } from "./template.ts";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
|
|
8
|
+
describe("renderTemplate", () => {
|
|
9
|
+
it("renders variable substitution", () => {
|
|
10
|
+
const env = createTemplateEnv("/nonexistent");
|
|
11
|
+
const result = renderTemplate(env, "Hello {{ name }}, you are {{ role }}", { name: "Alice", role: "analyst" });
|
|
12
|
+
assert.strictEqual(result, "Hello Alice, you are analyst");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("passes through plain text unchanged", () => {
|
|
16
|
+
const env = createTemplateEnv("/nonexistent");
|
|
17
|
+
const result = renderTemplate(env, "No templates here. Just markdown.", {});
|
|
18
|
+
assert.strictEqual(result, "No templates here. Just markdown.");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("renders if/else conditionals", () => {
|
|
22
|
+
const env = createTemplateEnv("/nonexistent");
|
|
23
|
+
const tmpl = "{% if verbose %}Detailed output{% else %}Brief output{% endif %}";
|
|
24
|
+
assert.strictEqual(renderTemplate(env, tmpl, { verbose: true }), "Detailed output");
|
|
25
|
+
assert.strictEqual(renderTemplate(env, tmpl, { verbose: false }), "Brief output");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("renders for loops", () => {
|
|
29
|
+
const env = createTemplateEnv("/nonexistent");
|
|
30
|
+
const tmpl = "{% for item in items %}- {{ item }}\n{% endfor %}";
|
|
31
|
+
const result = renderTemplate(env, tmpl, { items: ["a", "b", "c"] });
|
|
32
|
+
assert.ok(result.includes("- a"));
|
|
33
|
+
assert.ok(result.includes("- b"));
|
|
34
|
+
assert.ok(result.includes("- c"));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("handles undefined variables gracefully (empty string)", () => {
|
|
38
|
+
const env = createTemplateEnv("/nonexistent");
|
|
39
|
+
const result = renderTemplate(env, "Hello {{ name }}", {});
|
|
40
|
+
assert.strictEqual(result, "Hello ");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("preserves ${{ }} workflow expressions (not interpreted)", () => {
|
|
44
|
+
const env = createTemplateEnv("/nonexistent");
|
|
45
|
+
const result = renderTemplate(env, "Use ${{ steps.explore.output }}", {});
|
|
46
|
+
assert.strictEqual(result, "Use ${{ steps.explore.output }}");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("renders nested object access", () => {
|
|
50
|
+
const env = createTemplateEnv("/nonexistent");
|
|
51
|
+
const result = renderTemplate(env, "File: {{ context.file }}", { context: { file: "main.ts" } });
|
|
52
|
+
assert.strictEqual(result, "File: main.ts");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("renderTemplateFile", () => {
|
|
57
|
+
it("renders a template file from the search path", () => {
|
|
58
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-tmpl-test-"));
|
|
59
|
+
fs.writeFileSync(path.join(tmpDir, "test.md"), "Hello {{ name }}");
|
|
60
|
+
|
|
61
|
+
const env = createTemplateEnv("/nonexistent", tmpDir);
|
|
62
|
+
const result = renderTemplateFile(env, "test.md", { name: "World" });
|
|
63
|
+
assert.strictEqual(result, "Hello World");
|
|
64
|
+
|
|
65
|
+
fs.rmSync(tmpDir, { recursive: true });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("supports {% extends %} inheritance", () => {
|
|
69
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-tmpl-test-"));
|
|
70
|
+
|
|
71
|
+
fs.writeFileSync(path.join(tmpDir, "base.md"), [
|
|
72
|
+
"# {{ title }}",
|
|
73
|
+
"{% block intro %}Default intro{% endblock %}",
|
|
74
|
+
"{% block body %}Default body{% endblock %}",
|
|
75
|
+
].join("\n"));
|
|
76
|
+
|
|
77
|
+
fs.writeFileSync(path.join(tmpDir, "child.md"), [
|
|
78
|
+
'{% extends "base.md" %}',
|
|
79
|
+
"{% block body %}Custom body for {{ focus }}{% endblock %}",
|
|
80
|
+
].join("\n"));
|
|
81
|
+
|
|
82
|
+
const env = createTemplateEnv("/nonexistent", tmpDir);
|
|
83
|
+
const result = renderTemplateFile(env, "child.md", { title: "Report", focus: "security" });
|
|
84
|
+
assert.ok(result.includes("# Report"), "should render parent title");
|
|
85
|
+
assert.ok(result.includes("Default intro"), "should inherit intro block");
|
|
86
|
+
assert.ok(result.includes("Custom body for security"), "should override body block");
|
|
87
|
+
assert.ok(!result.includes("Default body"), "should NOT include default body");
|
|
88
|
+
|
|
89
|
+
fs.rmSync(tmpDir, { recursive: true });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("supports {% include %}", () => {
|
|
93
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-tmpl-test-"));
|
|
94
|
+
|
|
95
|
+
fs.writeFileSync(path.join(tmpDir, "fragment.md"), "This is a reusable fragment.");
|
|
96
|
+
fs.writeFileSync(path.join(tmpDir, "main.md"), [
|
|
97
|
+
"# Main",
|
|
98
|
+
'{% include "fragment.md" %}',
|
|
99
|
+
"End.",
|
|
100
|
+
].join("\n"));
|
|
101
|
+
|
|
102
|
+
const env = createTemplateEnv("/nonexistent", tmpDir);
|
|
103
|
+
const result = renderTemplateFile(env, "main.md", {});
|
|
104
|
+
assert.ok(result.includes("# Main"));
|
|
105
|
+
assert.ok(result.includes("This is a reusable fragment."));
|
|
106
|
+
assert.ok(result.includes("End."));
|
|
107
|
+
|
|
108
|
+
fs.rmSync(tmpDir, { recursive: true });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("supports {% macro %}", () => {
|
|
112
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-tmpl-test-"));
|
|
113
|
+
|
|
114
|
+
fs.writeFileSync(path.join(tmpDir, "macros.md"), [
|
|
115
|
+
"{% macro greeting(name) %}Hello, {{ name }}!{% endmacro %}",
|
|
116
|
+
].join("\n"));
|
|
117
|
+
fs.writeFileSync(path.join(tmpDir, "main.md"), [
|
|
118
|
+
'{% from "macros.md" import greeting %}',
|
|
119
|
+
"{{ greeting('Alice') }}",
|
|
120
|
+
"{{ greeting('Bob') }}",
|
|
121
|
+
].join("\n"));
|
|
122
|
+
|
|
123
|
+
const env = createTemplateEnv("/nonexistent", tmpDir);
|
|
124
|
+
const result = renderTemplateFile(env, "main.md", {});
|
|
125
|
+
assert.ok(result.includes("Hello, Alice!"));
|
|
126
|
+
assert.ok(result.includes("Hello, Bob!"));
|
|
127
|
+
|
|
128
|
+
fs.rmSync(tmpDir, { recursive: true });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("project templates shadow builtin templates", () => {
|
|
132
|
+
const builtinDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-tmpl-builtin-"));
|
|
133
|
+
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-tmpl-project-"));
|
|
134
|
+
|
|
135
|
+
const projectTemplates = path.join(projectDir, ".pi", "templates");
|
|
136
|
+
fs.mkdirSync(projectTemplates, { recursive: true });
|
|
137
|
+
|
|
138
|
+
fs.writeFileSync(path.join(builtinDir, "shared.md"), "BUILTIN version");
|
|
139
|
+
fs.writeFileSync(path.join(projectTemplates, "shared.md"), "PROJECT version");
|
|
140
|
+
|
|
141
|
+
const env = createTemplateEnv(projectDir, builtinDir);
|
|
142
|
+
const result = renderTemplateFile(env, "shared.md", {});
|
|
143
|
+
assert.strictEqual(result, "PROJECT version");
|
|
144
|
+
|
|
145
|
+
fs.rmSync(builtinDir, { recursive: true });
|
|
146
|
+
fs.rmSync(projectDir, { recursive: true });
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("createTemplateEnv", () => {
|
|
151
|
+
it("creates an environment that renders plain text unchanged", () => {
|
|
152
|
+
const env = createTemplateEnv("/nonexistent");
|
|
153
|
+
const result = renderTemplate(env, "Just plain text", {});
|
|
154
|
+
assert.strictEqual(result, "Just plain text");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("works when no template directories exist", () => {
|
|
158
|
+
const env = createTemplateEnv("/definitely/does/not/exist");
|
|
159
|
+
const result = renderTemplate(env, "Still works: {{ x }}", { x: 42 });
|
|
160
|
+
assert.strictEqual(result, "Still works: 42");
|
|
161
|
+
});
|
|
162
|
+
});
|
package/src/template.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nunjucks template rendering for agent prompts.
|
|
3
|
+
* Provides three-tier template resolution (project > user > builtin)
|
|
4
|
+
* and renders agent system prompts with step input as context.
|
|
5
|
+
*/
|
|
6
|
+
import nunjucks from "nunjucks";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create a Nunjucks environment with three-tier template resolution.
|
|
13
|
+
*
|
|
14
|
+
* Search order (first match wins):
|
|
15
|
+
* 1. .pi/templates/ (project-level)
|
|
16
|
+
* 2. ~/.pi/agent/templates/ (user-level)
|
|
17
|
+
* 3. <package>/templates/ (builtin)
|
|
18
|
+
*
|
|
19
|
+
* Autoescape is disabled — we're rendering markdown, not HTML.
|
|
20
|
+
*
|
|
21
|
+
* @param cwd - project root directory
|
|
22
|
+
* @param builtinDir - optional path to builtin templates (defaults to templates/ relative to package root)
|
|
23
|
+
*/
|
|
24
|
+
export function createTemplateEnv(cwd: string, builtinDir?: string): nunjucks.Environment {
|
|
25
|
+
const projectDir = path.join(cwd, ".pi", "templates");
|
|
26
|
+
const userDir = path.join(os.homedir(), ".pi", "agent", "templates");
|
|
27
|
+
const defaultBuiltinDir = builtinDir ?? path.resolve(import.meta.dirname, "..", "templates");
|
|
28
|
+
|
|
29
|
+
// Nunjucks FileSystemLoader searches directories in order — first match wins.
|
|
30
|
+
// Only include directories that exist to avoid noisy warnings.
|
|
31
|
+
const searchPaths: string[] = [];
|
|
32
|
+
if (fs.existsSync(projectDir)) searchPaths.push(projectDir);
|
|
33
|
+
if (fs.existsSync(userDir)) searchPaths.push(userDir);
|
|
34
|
+
if (fs.existsSync(defaultBuiltinDir)) searchPaths.push(defaultBuiltinDir);
|
|
35
|
+
|
|
36
|
+
// If no template directories exist, use a no-op loader — plain text passes through.
|
|
37
|
+
const loader = searchPaths.length > 0
|
|
38
|
+
? new nunjucks.FileSystemLoader(searchPaths)
|
|
39
|
+
: undefined;
|
|
40
|
+
|
|
41
|
+
return new nunjucks.Environment(loader, {
|
|
42
|
+
autoescape: false,
|
|
43
|
+
throwOnUndefined: false,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Sentinel used to protect ${{ }} workflow expressions from Nunjucks rendering. */
|
|
48
|
+
const WORKFLOW_EXPR_PLACEHOLDER = "\x00__PI_WORKFLOW_EXPR__";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Render a template string through Nunjucks.
|
|
52
|
+
*
|
|
53
|
+
* Used for agent system prompts (the markdown body after frontmatter).
|
|
54
|
+
* The context object contains the step's resolved input fields as top-level keys.
|
|
55
|
+
*
|
|
56
|
+
* Workflow expressions (${{ }}) are protected from Nunjucks interpretation
|
|
57
|
+
* by escaping them before rendering and restoring them after.
|
|
58
|
+
*
|
|
59
|
+
* @param env - Nunjucks environment (from createTemplateEnv)
|
|
60
|
+
* @param templateStr - the template text to render
|
|
61
|
+
* @param context - variables available in the template
|
|
62
|
+
* @returns rendered string
|
|
63
|
+
*/
|
|
64
|
+
export function renderTemplate(env: nunjucks.Environment, templateStr: string, context: Record<string, unknown>): string {
|
|
65
|
+
// Protect ${{ }} workflow expressions from Nunjucks
|
|
66
|
+
const escaped = templateStr.replace(/\$\{\{/g, WORKFLOW_EXPR_PLACEHOLDER);
|
|
67
|
+
const rendered = env.renderString(escaped, context);
|
|
68
|
+
return rendered.replace(new RegExp(escapeRegExp(WORKFLOW_EXPR_PLACEHOLDER), "g"), "${{");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Escape special regex characters in a string. */
|
|
72
|
+
function escapeRegExp(s: string): string {
|
|
73
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Render a named template file through Nunjucks.
|
|
78
|
+
*
|
|
79
|
+
* Used when an agent spec references a template by path:
|
|
80
|
+
* prompt:
|
|
81
|
+
* system: analyzers/base-analyzer.md
|
|
82
|
+
*
|
|
83
|
+
* The file is resolved through the three-tier search (project > user > builtin).
|
|
84
|
+
*
|
|
85
|
+
* @param env - Nunjucks environment
|
|
86
|
+
* @param templateName - relative path to the template file
|
|
87
|
+
* @param context - variables available in the template
|
|
88
|
+
* @returns rendered string
|
|
89
|
+
*/
|
|
90
|
+
export function renderTemplateFile(env: nunjucks.Environment, templateName: string, context: Record<string, unknown>): string {
|
|
91
|
+
return env.render(templateName, context);
|
|
92
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared test helpers — mock factories for ctx, pi, and workflow specs.
|
|
3
|
+
*/
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import type { WorkflowSpec, StepSpec } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Create a mock extension context for testing.
|
|
11
|
+
*/
|
|
12
|
+
export function mockCtx(cwd: string) {
|
|
13
|
+
return {
|
|
14
|
+
cwd,
|
|
15
|
+
hasUI: false,
|
|
16
|
+
ui: {
|
|
17
|
+
setWidget: () => {},
|
|
18
|
+
notify: () => {},
|
|
19
|
+
setStatus: () => {},
|
|
20
|
+
},
|
|
21
|
+
} as any;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Create a mock pi API for testing.
|
|
26
|
+
*/
|
|
27
|
+
export function mockPi() {
|
|
28
|
+
const messages: any[] = [];
|
|
29
|
+
return {
|
|
30
|
+
sendMessage: (msg: any, opts: any) => messages.push({ msg, opts }),
|
|
31
|
+
_messages: messages,
|
|
32
|
+
} as any;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Create a minimal WorkflowSpec for testing.
|
|
37
|
+
* A fresh temp directory is created for filePath.
|
|
38
|
+
*/
|
|
39
|
+
export function makeSpec(
|
|
40
|
+
overrides: Partial<WorkflowSpec> & { steps: Record<string, StepSpec> },
|
|
41
|
+
): WorkflowSpec {
|
|
42
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-test-"));
|
|
43
|
+
return {
|
|
44
|
+
name: "test-workflow",
|
|
45
|
+
description: "Test workflow",
|
|
46
|
+
version: "1",
|
|
47
|
+
source: "project" as const,
|
|
48
|
+
filePath: path.join(tmpDir, "test.workflow.yaml"),
|
|
49
|
+
...overrides,
|
|
50
|
+
};
|
|
51
|
+
}
|