@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
package/src/types.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// ── Workflow Spec (parsed from YAML) ──
|
|
2
|
+
|
|
3
|
+
export interface WorkflowSpec {
|
|
4
|
+
name: string;
|
|
5
|
+
description: string;
|
|
6
|
+
version?: string;
|
|
7
|
+
input?: Record<string, unknown>; // JSON Schema object
|
|
8
|
+
output?: Record<string, unknown>; // JSON Schema object
|
|
9
|
+
triggerTurn?: boolean; // default: true
|
|
10
|
+
completion?: CompletionSpec; // controls post-completion message to main LLM
|
|
11
|
+
steps: Record<string, StepSpec>; // ordered (YAML preserves insertion order)
|
|
12
|
+
artifacts?: Record<string, ArtifactSpec>; // persistent files written after workflow completion
|
|
13
|
+
// Set by discovery, not by YAML:
|
|
14
|
+
source: "user" | "project";
|
|
15
|
+
filePath: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ArtifactSpec {
|
|
19
|
+
path: string; // destination path, may contain ${{ }} expressions
|
|
20
|
+
from: string; // ${{ }} expression for the data source
|
|
21
|
+
schema?: string; // optional JSON Schema to validate before writing
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RetryConfig {
|
|
25
|
+
maxAttempts?: number; // default 1 (no retry). Total attempts, not retries.
|
|
26
|
+
onExhausted?: 'fail' | 'skip'; // default 'fail'
|
|
27
|
+
steeringMessage?: string; // optional custom steering text for retries (agent steps only)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface StepSpec {
|
|
31
|
+
agent?: string; // optional — not needed for gate/transform steps
|
|
32
|
+
model?: string; // model override
|
|
33
|
+
input?: Record<string, unknown>; // values may contain ${{ }} expressions
|
|
34
|
+
output?: StepOutputSpec;
|
|
35
|
+
when?: string; // ${{ }} expression, evaluated as truthy/falsy
|
|
36
|
+
timeout?: { seconds: number };
|
|
37
|
+
retry?: RetryConfig; // per-step retry config
|
|
38
|
+
loop?: LoopSpec;
|
|
39
|
+
gate?: GateSpec;
|
|
40
|
+
transform?: TransformSpec;
|
|
41
|
+
parallel?: Record<string, StepSpec>; // named sub-steps to run concurrently
|
|
42
|
+
pause?: string | boolean; // pause step — string is message to display, true = pause with no message
|
|
43
|
+
command?: string; // shell command to run (captures stdout as output)
|
|
44
|
+
forEach?: string; // ${{ }} expression resolving to an array
|
|
45
|
+
as?: string; // variable name to bind each element (default: "item")
|
|
46
|
+
workflow?: string; // phase 6 — not yet supported
|
|
47
|
+
}
|
|
48
|
+
// Note: exactly one of agent, gate, transform, loop, or parallel must be set.
|
|
49
|
+
// workflow is phase 6.
|
|
50
|
+
|
|
51
|
+
export interface LoopSpec {
|
|
52
|
+
maxAttempts: number; // max iterations (required)
|
|
53
|
+
attempts?: string; // ${{ }} expression overriding maxAttempts at runtime
|
|
54
|
+
steps: Record<string, StepSpec>; // steps to execute each iteration
|
|
55
|
+
onExhausted?: StepSpec; // step to run if all attempts fail
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface GateSpec {
|
|
59
|
+
check: string; // shell command to run
|
|
60
|
+
onPass?: "continue" | "break"; // default: "continue" (proceed to next step)
|
|
61
|
+
onFail?: "continue" | "break" | "fail"; // default: "fail" (stop the workflow)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface TransformSpec {
|
|
65
|
+
/**
|
|
66
|
+
* A mapping of output field names to ${{ }} expressions.
|
|
67
|
+
* The result is an object with each field resolved.
|
|
68
|
+
* No LLM invocation — pure data transformation.
|
|
69
|
+
*/
|
|
70
|
+
mapping: Record<string, unknown>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface StepOutputSpec {
|
|
74
|
+
format?: "json" | "text"; // default: "text"
|
|
75
|
+
schema?: string; // path to JSON Schema file, relative to workflow spec
|
|
76
|
+
path?: string; // output file path — may contain ${{ }} expressions
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Agent Spec (loaded from .md frontmatter or .agent.yaml) ──
|
|
80
|
+
|
|
81
|
+
export interface AgentSpec {
|
|
82
|
+
name: string;
|
|
83
|
+
description?: string;
|
|
84
|
+
role?: string; // sensor | reasoning | action | quality
|
|
85
|
+
systemPrompt?: string; // compiled system prompt (from template rendering)
|
|
86
|
+
promptTemplate?: string; // system prompt template path
|
|
87
|
+
taskTemplate?: string; // task prompt template path
|
|
88
|
+
model?: string;
|
|
89
|
+
thinking?: string;
|
|
90
|
+
tools?: string[];
|
|
91
|
+
extensions?: string[];
|
|
92
|
+
skills?: string[];
|
|
93
|
+
output?: string; // output file path
|
|
94
|
+
inputSchema?: Record<string, unknown>; // JSON Schema for input
|
|
95
|
+
outputFormat?: "json" | "text"; // what the agent produces
|
|
96
|
+
outputSchema?: string; // path to output JSON Schema
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Execution State ──
|
|
100
|
+
|
|
101
|
+
export interface ExecutionState {
|
|
102
|
+
input: unknown;
|
|
103
|
+
steps: Record<string, StepResult>;
|
|
104
|
+
status: "running" | "completed" | "failed" | "paused";
|
|
105
|
+
loop?: LoopState; // set when inside a loop
|
|
106
|
+
// Resume support:
|
|
107
|
+
workflowName?: string; // which workflow this run belongs to
|
|
108
|
+
specVersion?: string; // workflow spec version at time of run
|
|
109
|
+
startedAt?: string; // ISO timestamp of run start
|
|
110
|
+
updatedAt?: string; // ISO timestamp of last state write
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface LoopState {
|
|
114
|
+
stepName: string; // name of the loop step
|
|
115
|
+
iteration: number; // current iteration (0-based)
|
|
116
|
+
maxAttempts: number;
|
|
117
|
+
priorAttempts: LoopAttempt[]; // results from previous iterations
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface LoopAttempt {
|
|
121
|
+
iteration: number;
|
|
122
|
+
steps: Record<string, StepResult>; // results of all steps in this iteration
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface StepResult {
|
|
126
|
+
step: string;
|
|
127
|
+
agent: string;
|
|
128
|
+
status: "completed" | "failed" | "skipped";
|
|
129
|
+
output?: unknown; // parsed structured output (if schema-bound)
|
|
130
|
+
textOutput?: string; // raw text from last assistant message
|
|
131
|
+
outputPath?: string; // absolute path to persisted output JSON
|
|
132
|
+
sessionLog?: string; // path to session log file
|
|
133
|
+
usage: StepUsage;
|
|
134
|
+
durationMs: number;
|
|
135
|
+
error?: string; // error message if failed
|
|
136
|
+
truncated?: boolean; // true if stdout exceeded MAX_STDOUT_BYTES and data was lost
|
|
137
|
+
warnings?: string[]; // non-fatal issues encountered during step execution
|
|
138
|
+
attempt?: number; // which attempt this result is from (1-based)
|
|
139
|
+
totalAttempts?: number; // total attempts made
|
|
140
|
+
priorErrors?: string[]; // error messages from prior failed attempts
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface StepUsage {
|
|
144
|
+
input: number;
|
|
145
|
+
output: number;
|
|
146
|
+
cacheRead: number;
|
|
147
|
+
cacheWrite: number;
|
|
148
|
+
cost: number;
|
|
149
|
+
turns: number;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Workflow Result (returned to caller) ──
|
|
153
|
+
|
|
154
|
+
export interface WorkflowResult {
|
|
155
|
+
workflow: string;
|
|
156
|
+
runId: string;
|
|
157
|
+
status: "completed" | "failed" | "paused";
|
|
158
|
+
steps: Record<string, StepResult>;
|
|
159
|
+
output?: unknown; // final workflow output (last step's output, or explicit)
|
|
160
|
+
totalUsage: StepUsage;
|
|
161
|
+
totalDurationMs: number;
|
|
162
|
+
runDir: string; // absolute path to .pi/workflow-runs/<name>/runs/<run-id>/
|
|
163
|
+
artifacts?: Record<string, string>; // name → absolute path of written artifact files
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── Completion Spec ──
|
|
167
|
+
|
|
168
|
+
export interface CompletionSpec {
|
|
169
|
+
message?: string; // LLM instruction text (may contain ${{ }} expressions)
|
|
170
|
+
include?: string[]; // expression paths to resolve and attach as data
|
|
171
|
+
template?: string; // full template with ${{ }} (mutually exclusive with message)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── Expression Scope (what ${{ }} expressions resolve against) ──
|
|
175
|
+
|
|
176
|
+
export interface ExpressionScope {
|
|
177
|
+
input: unknown;
|
|
178
|
+
steps: Record<string, StepResult>;
|
|
179
|
+
[key: string]: unknown; // allows use as Record<string, unknown>
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── Completion Scope (wider scope for completion templates) ──
|
|
183
|
+
|
|
184
|
+
export interface CompletionScope {
|
|
185
|
+
input: unknown;
|
|
186
|
+
steps: Record<string, StepResult>;
|
|
187
|
+
totalUsage: StepUsage;
|
|
188
|
+
totalDurationMs: number;
|
|
189
|
+
runDir: string;
|
|
190
|
+
runId: string;
|
|
191
|
+
workflow: string;
|
|
192
|
+
status: "completed" | "failed";
|
|
193
|
+
output?: unknown;
|
|
194
|
+
[key: string]: unknown; // allows use as Record<string, unknown>
|
|
195
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { validateFromFile } from "@davidorex/pi-project/src/schema-validator.ts";
|
|
5
|
+
|
|
6
|
+
const schemaPath = path.resolve(import.meta.dirname, "..", "schemas", "verifier-output.schema.json");
|
|
7
|
+
|
|
8
|
+
function validVerifierOutput() {
|
|
9
|
+
return {
|
|
10
|
+
status: "passed",
|
|
11
|
+
score: "3/3",
|
|
12
|
+
truths: [
|
|
13
|
+
{ truth: "Tests pass", status: "verified", evidence: "npm test exited 0" },
|
|
14
|
+
{ truth: "File exists", status: "verified", evidence: "ls confirmed" },
|
|
15
|
+
{ truth: "Schema valid", status: "verified", evidence: "ajv validates" },
|
|
16
|
+
],
|
|
17
|
+
criteria_results: [
|
|
18
|
+
{
|
|
19
|
+
criterion: "All tests pass",
|
|
20
|
+
verify_method: "command",
|
|
21
|
+
status: "passed",
|
|
22
|
+
expected_outcome: "exit code 0",
|
|
23
|
+
actual_outcome: "exit code 0",
|
|
24
|
+
evidence: "npm test output: 3 passing",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
criterion: "File structure correct",
|
|
28
|
+
verify_method: "inspect",
|
|
29
|
+
status: "passed",
|
|
30
|
+
evidence: "All expected files present",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
criterion: "UI renders correctly",
|
|
34
|
+
verify_method: "inspect",
|
|
35
|
+
status: "skipped",
|
|
36
|
+
evidence: "Requires visual inspection",
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("verifier-output schema validation", () => {
|
|
43
|
+
it("validates a complete verifier output", () => {
|
|
44
|
+
const data = validVerifierOutput();
|
|
45
|
+
const result = validateFromFile(schemaPath, data, "test");
|
|
46
|
+
assert.deepStrictEqual(result, data);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("validates with all optional fields present", () => {
|
|
50
|
+
const data = {
|
|
51
|
+
...validVerifierOutput(),
|
|
52
|
+
artifacts: [
|
|
53
|
+
{ path: "src/index.ts", status: "verified", exists: true, substantive: true, wired: true, details: "Main entry point" },
|
|
54
|
+
{ path: "src/missing.ts", status: "missing", exists: false },
|
|
55
|
+
],
|
|
56
|
+
requirements_coverage: [
|
|
57
|
+
{ requirement_id: "REQ-001", status: "satisfied", supporting_truths: ["Tests pass"] },
|
|
58
|
+
{ requirement_id: "REQ-002", status: "needs_human" },
|
|
59
|
+
],
|
|
60
|
+
human_verification: [
|
|
61
|
+
{ name: "UI layout", test: "Open browser and check layout", expected: "Grid renders correctly", why_human: "Requires visual inspection" },
|
|
62
|
+
],
|
|
63
|
+
gaps: [
|
|
64
|
+
{ truth: "Performance benchmark", status: "failed", reason: "No benchmark data available" },
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
const result = validateFromFile(schemaPath, data, "test");
|
|
68
|
+
assert.ok(result);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("validates with empty optional arrays", () => {
|
|
72
|
+
const data = {
|
|
73
|
+
...validVerifierOutput(),
|
|
74
|
+
artifacts: [],
|
|
75
|
+
requirements_coverage: [],
|
|
76
|
+
human_verification: [],
|
|
77
|
+
gaps: [],
|
|
78
|
+
};
|
|
79
|
+
const result = validateFromFile(schemaPath, data, "test");
|
|
80
|
+
assert.ok(result);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("rejects missing required field: status", () => {
|
|
84
|
+
const data = validVerifierOutput();
|
|
85
|
+
delete (data as any).status;
|
|
86
|
+
assert.throws(
|
|
87
|
+
() => validateFromFile(schemaPath, data, "test"),
|
|
88
|
+
(err: any) => err.message.includes("Validation failed"),
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("rejects missing required field: score", () => {
|
|
93
|
+
const data = validVerifierOutput();
|
|
94
|
+
delete (data as any).score;
|
|
95
|
+
assert.throws(
|
|
96
|
+
() => validateFromFile(schemaPath, data, "test"),
|
|
97
|
+
(err: any) => err.message.includes("Validation failed"),
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("rejects missing required field: truths", () => {
|
|
102
|
+
const data = validVerifierOutput();
|
|
103
|
+
delete (data as any).truths;
|
|
104
|
+
assert.throws(
|
|
105
|
+
() => validateFromFile(schemaPath, data, "test"),
|
|
106
|
+
(err: any) => err.message.includes("Validation failed"),
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("rejects missing required field: criteria_results", () => {
|
|
111
|
+
const data = validVerifierOutput();
|
|
112
|
+
delete (data as any).criteria_results;
|
|
113
|
+
assert.throws(
|
|
114
|
+
() => validateFromFile(schemaPath, data, "test"),
|
|
115
|
+
(err: any) => err.message.includes("Validation failed"),
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("rejects invalid status enum value", () => {
|
|
120
|
+
const data = validVerifierOutput();
|
|
121
|
+
data.status = "invalid_status" as any;
|
|
122
|
+
assert.throws(
|
|
123
|
+
() => validateFromFile(schemaPath, data, "test"),
|
|
124
|
+
(err: any) => err.message.includes("Validation failed"),
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("rejects invalid truth status enum value", () => {
|
|
129
|
+
const data = validVerifierOutput();
|
|
130
|
+
data.truths[0].status = "bad_status" as any;
|
|
131
|
+
assert.throws(
|
|
132
|
+
() => validateFromFile(schemaPath, data, "test"),
|
|
133
|
+
(err: any) => err.message.includes("Validation failed"),
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("rejects invalid verify_method enum value", () => {
|
|
138
|
+
const data = validVerifierOutput();
|
|
139
|
+
data.criteria_results[0].verify_method = "magic" as any;
|
|
140
|
+
assert.throws(
|
|
141
|
+
() => validateFromFile(schemaPath, data, "test"),
|
|
142
|
+
(err: any) => err.message.includes("Validation failed"),
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("rejects invalid criteria_results status enum value", () => {
|
|
147
|
+
const data = validVerifierOutput();
|
|
148
|
+
data.criteria_results[0].status = "invalid" as any;
|
|
149
|
+
assert.throws(
|
|
150
|
+
() => validateFromFile(schemaPath, data, "test"),
|
|
151
|
+
(err: any) => err.message.includes("Validation failed"),
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("rejects invalid artifact status enum value", () => {
|
|
156
|
+
const data = {
|
|
157
|
+
...validVerifierOutput(),
|
|
158
|
+
artifacts: [{ path: "test.ts", status: "unknown" }],
|
|
159
|
+
};
|
|
160
|
+
assert.throws(
|
|
161
|
+
() => validateFromFile(schemaPath, data, "test"),
|
|
162
|
+
(err: any) => err.message.includes("Validation failed"),
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("plan-breakdown schema validation", () => {
|
|
168
|
+
const planSchemaPath = path.resolve(import.meta.dirname, "..", "schemas", "plan-breakdown.schema.json");
|
|
169
|
+
|
|
170
|
+
it("validates a valid plan breakdown", () => {
|
|
171
|
+
const data = {
|
|
172
|
+
plans: [
|
|
173
|
+
{
|
|
174
|
+
name: "implement-auth",
|
|
175
|
+
intent: "Add authentication",
|
|
176
|
+
tasks: ["Create auth module", "Add login endpoint"],
|
|
177
|
+
acceptance_criteria: ["Login works", "Tests pass"],
|
|
178
|
+
files_to_change: ["src/auth.ts"],
|
|
179
|
+
context_needed: ["existing user model"],
|
|
180
|
+
parallel_group: "core",
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
};
|
|
184
|
+
const result = validateFromFile(planSchemaPath, data, "test");
|
|
185
|
+
assert.ok(result);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("rejects missing required plan fields", () => {
|
|
189
|
+
const data = { plans: [{ name: "test" }] };
|
|
190
|
+
assert.throws(
|
|
191
|
+
() => validateFromFile(planSchemaPath, data, "test"),
|
|
192
|
+
(err: any) => err.message.includes("Validation failed"),
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("execution-results schema validation", () => {
|
|
198
|
+
const execSchemaPath = path.resolve(import.meta.dirname, "..", "schemas", "execution-results.schema.json");
|
|
199
|
+
|
|
200
|
+
it("validates a valid execution result", () => {
|
|
201
|
+
const data = {
|
|
202
|
+
status: "complete",
|
|
203
|
+
tasks: [
|
|
204
|
+
{ name: "implement feature", status: "done", files_modified: ["src/index.ts"], commit_hash: "abc123", notes: "Done" },
|
|
205
|
+
],
|
|
206
|
+
decisions: [
|
|
207
|
+
{ id: "D-001", decision: "Use ESM", rationale: "Modern standard", status: "decided" },
|
|
208
|
+
],
|
|
209
|
+
issues: [
|
|
210
|
+
{ severity: "info", description: "Minor lint warning" },
|
|
211
|
+
],
|
|
212
|
+
test_count: 42,
|
|
213
|
+
commit_hash: "abc123",
|
|
214
|
+
};
|
|
215
|
+
const result = validateFromFile(execSchemaPath, data, "test");
|
|
216
|
+
assert.ok(result);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("rejects invalid status enum", () => {
|
|
220
|
+
const data = { status: "unknown", tasks: [] };
|
|
221
|
+
assert.throws(
|
|
222
|
+
() => validateFromFile(execSchemaPath, data, "test"),
|
|
223
|
+
(err: any) => err.message.includes("Validation failed"),
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { discoverWorkflows, findWorkflow } from "./workflow-discovery.ts";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
|
|
8
|
+
// Use a nonexistent dir as builtinDir so tests don't pick up real demo workflows
|
|
9
|
+
const NO_BUILTINS = path.join(os.tmpdir(), "wf-no-builtins-nonexistent");
|
|
10
|
+
|
|
11
|
+
describe("discoverWorkflows", () => {
|
|
12
|
+
it("discovers workflows from project directory", () => {
|
|
13
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-test-"));
|
|
14
|
+
const wfDir = path.join(tmpDir, ".pi", "workflows");
|
|
15
|
+
fs.mkdirSync(wfDir, { recursive: true });
|
|
16
|
+
fs.writeFileSync(
|
|
17
|
+
path.join(wfDir, "test.workflow.yaml"),
|
|
18
|
+
"name: test\nsteps:\n s:\n agent: a",
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const specs = discoverWorkflows(tmpDir, NO_BUILTINS);
|
|
22
|
+
assert.strictEqual(specs.length, 1);
|
|
23
|
+
assert.strictEqual(specs[0].name, "test");
|
|
24
|
+
assert.strictEqual(specs[0].source, "project");
|
|
25
|
+
|
|
26
|
+
fs.rmSync(tmpDir, { recursive: true });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns empty array when no workflow directories exist", () => {
|
|
30
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-test-"));
|
|
31
|
+
const specs = discoverWorkflows(tmpDir, NO_BUILTINS);
|
|
32
|
+
assert.strictEqual(specs.length, 0);
|
|
33
|
+
fs.rmSync(tmpDir, { recursive: true });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("skips invalid specs with warning (no throw)", () => {
|
|
37
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-test-"));
|
|
38
|
+
const wfDir = path.join(tmpDir, ".pi", "workflows");
|
|
39
|
+
fs.mkdirSync(wfDir, { recursive: true });
|
|
40
|
+
fs.writeFileSync(path.join(wfDir, "bad.workflow.yaml"), "not: valid: workflow");
|
|
41
|
+
fs.writeFileSync(
|
|
42
|
+
path.join(wfDir, "good.workflow.yaml"),
|
|
43
|
+
"name: good\nsteps:\n s:\n agent: a",
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const specs = discoverWorkflows(tmpDir, NO_BUILTINS);
|
|
47
|
+
assert.strictEqual(specs.length, 1);
|
|
48
|
+
assert.strictEqual(specs[0].name, "good");
|
|
49
|
+
|
|
50
|
+
fs.rmSync(tmpDir, { recursive: true });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("project specs shadow user specs with same name", () => {
|
|
54
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-test-"));
|
|
55
|
+
const projectDir = path.join(tmpDir, ".pi", "workflows");
|
|
56
|
+
fs.mkdirSync(projectDir, { recursive: true });
|
|
57
|
+
fs.writeFileSync(
|
|
58
|
+
path.join(projectDir, "shared.workflow.yaml"),
|
|
59
|
+
"name: shared\ndescription: project version\nsteps:\n s:\n agent: a",
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const specs = discoverWorkflows(tmpDir, NO_BUILTINS);
|
|
63
|
+
assert.strictEqual(specs.length, 1);
|
|
64
|
+
assert.strictEqual(specs[0].source, "project");
|
|
65
|
+
|
|
66
|
+
fs.rmSync(tmpDir, { recursive: true });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("discovers builtin demo workflows", () => {
|
|
70
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-test-"));
|
|
71
|
+
const demoDir = path.resolve(import.meta.dirname, "..", "workflows");
|
|
72
|
+
|
|
73
|
+
const specs = discoverWorkflows(tmpDir, demoDir);
|
|
74
|
+
assert.ok(specs.length > 0, "should find at least one demo workflow");
|
|
75
|
+
|
|
76
|
+
const names = specs.map((s) => s.name);
|
|
77
|
+
assert.ok(names.includes("typed-analysis"), "should find typed-analysis demo");
|
|
78
|
+
|
|
79
|
+
fs.rmSync(tmpDir, { recursive: true });
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("findWorkflow", () => {
|
|
84
|
+
it("finds a workflow by name", () => {
|
|
85
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-test-"));
|
|
86
|
+
const wfDir = path.join(tmpDir, ".pi", "workflows");
|
|
87
|
+
fs.mkdirSync(wfDir, { recursive: true });
|
|
88
|
+
fs.writeFileSync(
|
|
89
|
+
path.join(wfDir, "bugfix.workflow.yaml"),
|
|
90
|
+
"name: bugfix\nsteps:\n s:\n agent: a",
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const spec = findWorkflow("bugfix", tmpDir, NO_BUILTINS);
|
|
94
|
+
assert.ok(spec);
|
|
95
|
+
assert.strictEqual(spec.name, "bugfix");
|
|
96
|
+
|
|
97
|
+
fs.rmSync(tmpDir, { recursive: true });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("returns undefined for unknown workflow", () => {
|
|
101
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-test-"));
|
|
102
|
+
assert.strictEqual(findWorkflow("nonexistent", tmpDir, NO_BUILTINS), undefined);
|
|
103
|
+
fs.rmSync(tmpDir, { recursive: true });
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import type { WorkflowSpec } from "./types.ts";
|
|
5
|
+
import { parseWorkflowSpec } from "./workflow-spec.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Discover all workflow specs from project, user, and builtin directories.
|
|
9
|
+
*
|
|
10
|
+
* Scans (highest priority first):
|
|
11
|
+
* 1. .pi/workflows/ (project-level, source: "project")
|
|
12
|
+
* 2. ~/.pi/agent/workflows/ (user-level, source: "user")
|
|
13
|
+
* 3. <package>/workflows/ (builtin workflows, source: "user")
|
|
14
|
+
*
|
|
15
|
+
* Higher-priority specs shadow lower-priority specs with the same name.
|
|
16
|
+
*
|
|
17
|
+
* @param cwd - current working directory (project root)
|
|
18
|
+
* @param builtinDir - optional path to builtin workflows (defaults to workflows/ relative to package root)
|
|
19
|
+
* @returns Array of parsed WorkflowSpec objects. Specs that fail parsing are
|
|
20
|
+
* skipped with a warning (logged to stderr), not thrown.
|
|
21
|
+
*/
|
|
22
|
+
export function discoverWorkflows(cwd: string, builtinDir?: string): WorkflowSpec[] {
|
|
23
|
+
const projectDir = path.join(cwd, ".pi", "workflows");
|
|
24
|
+
const userDir = path.join(os.homedir(), ".pi", "agent", "workflows");
|
|
25
|
+
const demoDir = builtinDir ?? path.resolve(import.meta.dirname, "..", "workflows");
|
|
26
|
+
|
|
27
|
+
const projectSpecs = scanDirectory(projectDir, "project");
|
|
28
|
+
const userSpecs = scanDirectory(userDir, "user");
|
|
29
|
+
const builtinSpecs = scanDirectory(demoDir, "user");
|
|
30
|
+
|
|
31
|
+
// Deduplicate: project > user > builtin (last write wins, so add lowest priority first)
|
|
32
|
+
const byName = new Map<string, WorkflowSpec>();
|
|
33
|
+
|
|
34
|
+
for (const spec of builtinSpecs) {
|
|
35
|
+
byName.set(spec.name, spec);
|
|
36
|
+
}
|
|
37
|
+
for (const spec of userSpecs) {
|
|
38
|
+
byName.set(spec.name, spec);
|
|
39
|
+
}
|
|
40
|
+
for (const spec of projectSpecs) {
|
|
41
|
+
byName.set(spec.name, spec);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Return sorted by name
|
|
45
|
+
const result = Array.from(byName.values());
|
|
46
|
+
result.sort((a, b) => a.name.localeCompare(b.name));
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Find a workflow by name from discovered workflows.
|
|
52
|
+
* Returns undefined if not found.
|
|
53
|
+
*/
|
|
54
|
+
export function findWorkflow(name: string, cwd: string, builtinDir?: string): WorkflowSpec | undefined {
|
|
55
|
+
const specs = discoverWorkflows(cwd, builtinDir);
|
|
56
|
+
return specs.find((s) => s.name === name);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Scan a directory for *.workflow.yaml files and parse them.
|
|
61
|
+
* Non-recursive. Returns parsed specs; logs warnings for parse failures.
|
|
62
|
+
*/
|
|
63
|
+
function scanDirectory(dirPath: string, source: "user" | "project"): WorkflowSpec[] {
|
|
64
|
+
if (!fs.existsSync(dirPath)) {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let entries: string[];
|
|
69
|
+
try {
|
|
70
|
+
entries = fs.readdirSync(dirPath);
|
|
71
|
+
} catch {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const specs: WorkflowSpec[] = [];
|
|
76
|
+
|
|
77
|
+
for (const entry of entries) {
|
|
78
|
+
if (!entry.endsWith(".workflow.yaml")) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const filePath = path.join(dirPath, entry);
|
|
83
|
+
|
|
84
|
+
// Only process files, not directories
|
|
85
|
+
let stat: fs.Stats;
|
|
86
|
+
try {
|
|
87
|
+
stat = fs.statSync(filePath);
|
|
88
|
+
} catch {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (!stat.isFile()) {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let content: string;
|
|
96
|
+
try {
|
|
97
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
98
|
+
} catch {
|
|
99
|
+
console.error(`[pi-workflows] Warning: skipping ${filePath}: could not read file`);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const spec = parseWorkflowSpec(content, filePath, source);
|
|
105
|
+
specs.push(spec);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
108
|
+
console.error(`[pi-workflows] Warning: skipping ${filePath}: ${msg}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return specs;
|
|
113
|
+
}
|