akm-cli 0.4.1 → 0.5.0-rc1
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/dist/asset-registry.js +7 -0
- package/dist/asset-spec.js +35 -0
- package/dist/cli.js +1120 -31
- package/dist/completions.js +2 -2
- package/dist/config-cli.js +41 -0
- package/dist/config.js +62 -0
- package/dist/file-context.js +2 -1
- package/dist/github.js +20 -1
- package/dist/indexer.js +55 -5
- package/dist/init.js +11 -0
- package/dist/install-audit.js +53 -8
- package/dist/installed-kits.js +2 -0
- package/dist/llm.js +64 -23
- package/dist/matchers.js +56 -4
- package/dist/metadata.js +68 -4
- package/dist/paths.js +3 -0
- package/dist/registry-install.js +36 -7
- package/dist/registry-resolve.js +25 -0
- package/dist/renderers.js +182 -2
- package/dist/search-fields.js +4 -0
- package/dist/search-source.js +12 -8
- package/dist/setup.js +158 -33
- package/dist/stash-add.js +84 -11
- package/dist/stash-providers/git.js +182 -44
- package/dist/stash-show.js +56 -1
- package/dist/stash-source-manage.js +14 -4
- package/dist/templates/wiki-templates.js +100 -0
- package/dist/vault.js +290 -0
- package/dist/wiki.js +886 -0
- package/dist/workflow-authoring.js +131 -0
- package/dist/workflow-cli.js +44 -0
- package/dist/workflow-db.js +55 -0
- package/dist/workflow-markdown.js +251 -0
- package/dist/workflow-runs.js +364 -0
- package/package.json +2 -1
- package/LICENSE +0 -374
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { resolveAssetPathFromName } from "./asset-spec";
|
|
4
|
+
import { isWithin, resolveStashDir } from "./common";
|
|
5
|
+
import { UsageError } from "./errors";
|
|
6
|
+
import { parseWorkflowMarkdown, WorkflowValidationError } from "./workflow-markdown";
|
|
7
|
+
const DEFAULT_WORKFLOW_TEMPLATE = renderWorkflowTemplate({
|
|
8
|
+
title: "Example Workflow",
|
|
9
|
+
firstStepTitle: "First Step",
|
|
10
|
+
firstStepId: "first-step",
|
|
11
|
+
});
|
|
12
|
+
export function getWorkflowTemplate() {
|
|
13
|
+
return DEFAULT_WORKFLOW_TEMPLATE;
|
|
14
|
+
}
|
|
15
|
+
export function buildWorkflowTemplate(name) {
|
|
16
|
+
if (!name)
|
|
17
|
+
return DEFAULT_WORKFLOW_TEMPLATE;
|
|
18
|
+
const title = humanizeWorkflowName(name);
|
|
19
|
+
const stepId = slugifyWorkflowStepId(name);
|
|
20
|
+
const customized = renderWorkflowTemplate({
|
|
21
|
+
title,
|
|
22
|
+
firstStepTitle: `${title} Setup`,
|
|
23
|
+
firstStepId: `${stepId}-setup`,
|
|
24
|
+
});
|
|
25
|
+
parseWorkflowMarkdown(customized);
|
|
26
|
+
return customized;
|
|
27
|
+
}
|
|
28
|
+
export function createWorkflowAsset(input) {
|
|
29
|
+
const stashDir = resolveStashDir();
|
|
30
|
+
const typeRoot = path.join(stashDir, "workflows");
|
|
31
|
+
fs.mkdirSync(typeRoot, { recursive: true });
|
|
32
|
+
const normalizedName = normalizeWorkflowName(input.name);
|
|
33
|
+
const assetPath = resolveAssetPathFromName("workflow", typeRoot, normalizedName);
|
|
34
|
+
if (!isWithin(assetPath, typeRoot)) {
|
|
35
|
+
throw new UsageError(`Resolved workflow path escapes the stash: "${normalizedName}"`);
|
|
36
|
+
}
|
|
37
|
+
if (fs.existsSync(assetPath) && !input.force) {
|
|
38
|
+
throw new UsageError(`Workflow "${normalizedName}" already exists. Re-run with --force to overwrite it.`);
|
|
39
|
+
}
|
|
40
|
+
const content = input.from
|
|
41
|
+
? readWorkflowSource(input.from)
|
|
42
|
+
: (input.content ?? buildWorkflowTemplate(normalizedName));
|
|
43
|
+
try {
|
|
44
|
+
parseWorkflowMarkdown(content);
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (error instanceof WorkflowValidationError) {
|
|
48
|
+
throw new UsageError(error.message);
|
|
49
|
+
}
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
fs.mkdirSync(path.dirname(assetPath), { recursive: true });
|
|
53
|
+
fs.writeFileSync(assetPath, content.endsWith("\n") ? content : `${content}\n`, "utf8");
|
|
54
|
+
return {
|
|
55
|
+
ref: `workflow:${normalizedName}`,
|
|
56
|
+
path: assetPath,
|
|
57
|
+
stashDir,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function readWorkflowSource(source) {
|
|
61
|
+
const resolved = path.resolve(source);
|
|
62
|
+
let stat;
|
|
63
|
+
try {
|
|
64
|
+
stat = fs.statSync(resolved);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
throw new UsageError(`Workflow source not found: "${source}".`);
|
|
68
|
+
}
|
|
69
|
+
if (!stat.isFile()) {
|
|
70
|
+
throw new UsageError(`Workflow source must be a file: "${source}".`);
|
|
71
|
+
}
|
|
72
|
+
return fs.readFileSync(resolved, "utf8");
|
|
73
|
+
}
|
|
74
|
+
function normalizeWorkflowName(name) {
|
|
75
|
+
const normalized = name
|
|
76
|
+
.trim()
|
|
77
|
+
.replace(/\\/g, "/")
|
|
78
|
+
.replace(/^\/+|\/+$/g, "")
|
|
79
|
+
.replace(/\.md$/i, "");
|
|
80
|
+
if (!normalized) {
|
|
81
|
+
throw new UsageError("Workflow name cannot be empty.");
|
|
82
|
+
}
|
|
83
|
+
const segments = normalized.split("/");
|
|
84
|
+
if (segments.some((segment) => !segment || segment === "." || segment === "..")) {
|
|
85
|
+
throw new UsageError("Workflow name must be a relative path without '.' or '..' segments.");
|
|
86
|
+
}
|
|
87
|
+
return normalized;
|
|
88
|
+
}
|
|
89
|
+
function humanizeWorkflowName(name) {
|
|
90
|
+
return (name
|
|
91
|
+
.split("/")
|
|
92
|
+
.pop()
|
|
93
|
+
?.replace(/[-_]+/g, " ")
|
|
94
|
+
.replace(/\b\w/g, (match) => match.toUpperCase())
|
|
95
|
+
.trim() || "Example Workflow");
|
|
96
|
+
}
|
|
97
|
+
function slugifyWorkflowStepId(name) {
|
|
98
|
+
return (name
|
|
99
|
+
.split("/")
|
|
100
|
+
.pop()
|
|
101
|
+
?.toLowerCase()
|
|
102
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
103
|
+
.replace(/^-+|-+$/g, "") || "workflow");
|
|
104
|
+
}
|
|
105
|
+
function renderWorkflowTemplate(input) {
|
|
106
|
+
return `---
|
|
107
|
+
description: Describe what this workflow accomplishes
|
|
108
|
+
tags:
|
|
109
|
+
- example
|
|
110
|
+
params:
|
|
111
|
+
example_param: Explain this parameter
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
# Workflow: ${input.title}
|
|
115
|
+
|
|
116
|
+
## Step: ${input.firstStepTitle}
|
|
117
|
+
Step ID: ${input.firstStepId}
|
|
118
|
+
|
|
119
|
+
### Instructions
|
|
120
|
+
Describe what to do in this step.
|
|
121
|
+
|
|
122
|
+
### Completion Criteria
|
|
123
|
+
- Confirm the first step is complete
|
|
124
|
+
|
|
125
|
+
## Step: Second Step
|
|
126
|
+
Step ID: second-step
|
|
127
|
+
|
|
128
|
+
### Instructions
|
|
129
|
+
Describe what happens next.
|
|
130
|
+
`;
|
|
131
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { UsageError } from "./errors";
|
|
2
|
+
export const WORKFLOW_STEP_STATES = [
|
|
3
|
+
"completed",
|
|
4
|
+
"blocked",
|
|
5
|
+
"failed",
|
|
6
|
+
"skipped",
|
|
7
|
+
];
|
|
8
|
+
export const WORKFLOW_SUBCOMMANDS = new Set([
|
|
9
|
+
"start",
|
|
10
|
+
"next",
|
|
11
|
+
"complete",
|
|
12
|
+
"status",
|
|
13
|
+
"list",
|
|
14
|
+
"create",
|
|
15
|
+
"template",
|
|
16
|
+
"resume",
|
|
17
|
+
]);
|
|
18
|
+
export function parseWorkflowJsonObject(raw, flagName) {
|
|
19
|
+
if (!raw)
|
|
20
|
+
return {};
|
|
21
|
+
let parsed;
|
|
22
|
+
try {
|
|
23
|
+
parsed = JSON.parse(raw);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw new UsageError(`${flagName} must be valid JSON.`);
|
|
27
|
+
}
|
|
28
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
29
|
+
throw new UsageError(`${flagName} must be a JSON object.`);
|
|
30
|
+
}
|
|
31
|
+
return parsed;
|
|
32
|
+
}
|
|
33
|
+
export function parseWorkflowStepState(value) {
|
|
34
|
+
if (!value)
|
|
35
|
+
return "completed";
|
|
36
|
+
if (WORKFLOW_STEP_STATES.includes(value)) {
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
throw new UsageError(`Invalid workflow step state "${value}". Expected one of: ${WORKFLOW_STEP_STATES.join(", ")}`);
|
|
40
|
+
}
|
|
41
|
+
export function hasWorkflowSubcommand(args) {
|
|
42
|
+
const command = Array.isArray(args._) ? args._[0] : undefined;
|
|
43
|
+
return typeof command === "string" && WORKFLOW_SUBCOMMANDS.has(command);
|
|
44
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { getWorkflowDbPath } from "./paths";
|
|
5
|
+
export function openWorkflowDatabase(dbPath = getWorkflowDbPath()) {
|
|
6
|
+
const dir = path.dirname(dbPath);
|
|
7
|
+
if (!fs.existsSync(dir)) {
|
|
8
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
9
|
+
}
|
|
10
|
+
const db = new Database(dbPath);
|
|
11
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
12
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
13
|
+
ensureWorkflowSchema(db);
|
|
14
|
+
return db;
|
|
15
|
+
}
|
|
16
|
+
export function closeWorkflowDatabase(db) {
|
|
17
|
+
db.close();
|
|
18
|
+
}
|
|
19
|
+
function ensureWorkflowSchema(db) {
|
|
20
|
+
db.exec(`
|
|
21
|
+
CREATE TABLE IF NOT EXISTS workflow_runs (
|
|
22
|
+
id TEXT PRIMARY KEY,
|
|
23
|
+
workflow_ref TEXT NOT NULL,
|
|
24
|
+
workflow_entry_id INTEGER,
|
|
25
|
+
workflow_title TEXT NOT NULL,
|
|
26
|
+
status TEXT NOT NULL CHECK (status IN ('active', 'completed', 'blocked', 'failed')),
|
|
27
|
+
params_json TEXT NOT NULL DEFAULT '{}',
|
|
28
|
+
current_step_id TEXT,
|
|
29
|
+
created_at TEXT NOT NULL,
|
|
30
|
+
updated_at TEXT NOT NULL,
|
|
31
|
+
completed_at TEXT
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_runs_ref ON workflow_runs(workflow_ref);
|
|
35
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_runs_status ON workflow_runs(status);
|
|
36
|
+
|
|
37
|
+
CREATE TABLE IF NOT EXISTS workflow_run_steps (
|
|
38
|
+
run_id TEXT NOT NULL,
|
|
39
|
+
step_id TEXT NOT NULL,
|
|
40
|
+
step_title TEXT NOT NULL,
|
|
41
|
+
instructions TEXT NOT NULL,
|
|
42
|
+
completion_json TEXT,
|
|
43
|
+
sequence_index INTEGER NOT NULL,
|
|
44
|
+
status TEXT NOT NULL CHECK (status IN ('pending', 'completed', 'blocked', 'failed', 'skipped')),
|
|
45
|
+
notes TEXT,
|
|
46
|
+
evidence_json TEXT,
|
|
47
|
+
completed_at TEXT,
|
|
48
|
+
PRIMARY KEY (run_id, step_id),
|
|
49
|
+
FOREIGN KEY (run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_run_steps_run_sequence
|
|
53
|
+
ON workflow_run_steps(run_id, sequence_index);
|
|
54
|
+
`);
|
|
55
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { parseFrontmatter, toStringOrUndefined } from "./frontmatter";
|
|
2
|
+
const ALLOWED_FRONTMATTER_KEYS = new Set(["description", "tags", "params"]);
|
|
3
|
+
const STEP_ID_REGEX = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
4
|
+
export class WorkflowValidationError extends Error {
|
|
5
|
+
constructor(message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "WorkflowValidationError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export function parseWorkflowMarkdown(markdown) {
|
|
11
|
+
const parsed = parseFrontmatter(markdown);
|
|
12
|
+
validateFrontmatter(parsed.data);
|
|
13
|
+
const title = extractWorkflowTitle(parsed.content);
|
|
14
|
+
const parameters = extractWorkflowParameters(parsed.data);
|
|
15
|
+
const tags = extractWorkflowTags(parsed.data, parsed.frontmatter);
|
|
16
|
+
const steps = extractWorkflowSteps(parsed.content);
|
|
17
|
+
return {
|
|
18
|
+
title,
|
|
19
|
+
description: toStringOrUndefined(parsed.data.description),
|
|
20
|
+
...(tags ? { tags } : {}),
|
|
21
|
+
...(parameters ? { parameters } : {}),
|
|
22
|
+
steps,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function validateFrontmatter(data) {
|
|
26
|
+
const unsupported = Object.keys(data).filter((key) => !ALLOWED_FRONTMATTER_KEYS.has(key));
|
|
27
|
+
if (unsupported.length > 0) {
|
|
28
|
+
throw new WorkflowValidationError(`Workflow frontmatter only supports description, tags, and params. Unsupported key(s): ${unsupported.join(", ")}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function extractWorkflowTitle(body) {
|
|
32
|
+
const matches = Array.from(body.matchAll(/^#\s+Workflow:\s+(.+?)\s*$/gm));
|
|
33
|
+
if (matches.length === 0) {
|
|
34
|
+
throw new WorkflowValidationError('Workflow markdown must contain a "# Workflow: <title>" heading.');
|
|
35
|
+
}
|
|
36
|
+
if (matches.length > 1) {
|
|
37
|
+
throw new WorkflowValidationError('Workflow markdown must contain exactly one "# Workflow: <title>" heading.');
|
|
38
|
+
}
|
|
39
|
+
const title = matches[0]?.[1]?.trim() ?? "";
|
|
40
|
+
if (!title) {
|
|
41
|
+
throw new WorkflowValidationError('Workflow markdown must contain a non-empty "# Workflow: <title>" heading.');
|
|
42
|
+
}
|
|
43
|
+
return title;
|
|
44
|
+
}
|
|
45
|
+
function extractWorkflowTags(data, frontmatter) {
|
|
46
|
+
const tags = data.tags;
|
|
47
|
+
if (typeof tags === "undefined")
|
|
48
|
+
return undefined;
|
|
49
|
+
if (typeof tags === "string") {
|
|
50
|
+
const trimmed = tags.trim();
|
|
51
|
+
return trimmed ? [trimmed] : undefined;
|
|
52
|
+
}
|
|
53
|
+
if (frontmatter &&
|
|
54
|
+
typeof tags === "object" &&
|
|
55
|
+
tags !== null &&
|
|
56
|
+
!Array.isArray(tags) &&
|
|
57
|
+
Object.keys(tags).length === 0) {
|
|
58
|
+
const blockTags = extractTagListFromFrontmatter(frontmatter);
|
|
59
|
+
if (blockTags)
|
|
60
|
+
return blockTags;
|
|
61
|
+
}
|
|
62
|
+
if (!Array.isArray(tags) || !tags.every((tag) => typeof tag === "string" && tag.trim().length > 0)) {
|
|
63
|
+
throw new WorkflowValidationError("Workflow frontmatter `tags` must be a string or an array of non-empty strings.");
|
|
64
|
+
}
|
|
65
|
+
return tags.map((tag) => tag.trim());
|
|
66
|
+
}
|
|
67
|
+
function extractWorkflowParameters(data) {
|
|
68
|
+
const params = data.params;
|
|
69
|
+
if (typeof params === "undefined")
|
|
70
|
+
return undefined;
|
|
71
|
+
if (typeof params !== "object" || params === null || Array.isArray(params)) {
|
|
72
|
+
throw new WorkflowValidationError("Workflow frontmatter `params` must be a mapping of parameter names to descriptions.");
|
|
73
|
+
}
|
|
74
|
+
const entries = Object.entries(params);
|
|
75
|
+
if (entries.length === 0)
|
|
76
|
+
return undefined;
|
|
77
|
+
return entries.map(([name, description]) => {
|
|
78
|
+
if (!name.trim()) {
|
|
79
|
+
throw new WorkflowValidationError("Workflow parameter names must be non-empty.");
|
|
80
|
+
}
|
|
81
|
+
if (typeof description !== "string" || !description.trim()) {
|
|
82
|
+
throw new WorkflowValidationError(`Workflow parameter "${name}" must have a non-empty string description in frontmatter params.`);
|
|
83
|
+
}
|
|
84
|
+
return { name: name.trim(), description: description.trim() };
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
function extractWorkflowSteps(body) {
|
|
88
|
+
const lines = normalizeLines(body);
|
|
89
|
+
const titleLineIndex = lines.findIndex((line) => /^#\s+Workflow:\s+/.test(line));
|
|
90
|
+
if (titleLineIndex === -1) {
|
|
91
|
+
throw new WorkflowValidationError('Workflow markdown must contain a "# Workflow: <title>" heading.');
|
|
92
|
+
}
|
|
93
|
+
const steps = [];
|
|
94
|
+
let index = titleLineIndex + 1;
|
|
95
|
+
while (index < lines.length) {
|
|
96
|
+
const line = lines[index] ?? "";
|
|
97
|
+
const trimmed = line.trim();
|
|
98
|
+
if (!trimmed) {
|
|
99
|
+
index++;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (trimmed.startsWith("# ") && !/^#\s+Workflow:\s+/.test(trimmed)) {
|
|
103
|
+
throw new WorkflowValidationError(`Unexpected top-level heading after workflow title: "${trimmed}".`);
|
|
104
|
+
}
|
|
105
|
+
const stepHeader = trimmed.match(/^##\s+Step:\s+(.+?)\s*$/);
|
|
106
|
+
if (!stepHeader) {
|
|
107
|
+
throw new WorkflowValidationError(`Expected a "## Step: <title>" section after the workflow title, but found: "${trimmed}".`);
|
|
108
|
+
}
|
|
109
|
+
const stepTitle = stepHeader[1].trim();
|
|
110
|
+
const sequenceIndex = steps.length;
|
|
111
|
+
index++;
|
|
112
|
+
let stepId;
|
|
113
|
+
let instructions;
|
|
114
|
+
let completionCriteria;
|
|
115
|
+
while (index < lines.length) {
|
|
116
|
+
const current = lines[index] ?? "";
|
|
117
|
+
const currentTrimmed = current.trim();
|
|
118
|
+
if (/^##\s+Step:\s+/.test(currentTrimmed))
|
|
119
|
+
break;
|
|
120
|
+
if (/^#\s+/.test(currentTrimmed)) {
|
|
121
|
+
throw new WorkflowValidationError(`Unexpected heading "${currentTrimmed}" inside step "${stepTitle}". Only step sections and step subsections are allowed.`);
|
|
122
|
+
}
|
|
123
|
+
if (!currentTrimmed) {
|
|
124
|
+
index++;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const stepIdMatch = currentTrimmed.match(/^Step ID:\s+(.+?)\s*$/);
|
|
128
|
+
if (stepIdMatch) {
|
|
129
|
+
if (stepId) {
|
|
130
|
+
throw new WorkflowValidationError(`Step "${stepTitle}" must contain exactly one "Step ID: <id>" line.`);
|
|
131
|
+
}
|
|
132
|
+
stepId = stepIdMatch[1].trim();
|
|
133
|
+
if (!STEP_ID_REGEX.test(stepId)) {
|
|
134
|
+
throw new WorkflowValidationError(`Step "${stepTitle}" has invalid Step ID "${stepId}". Use letters, numbers, ".", "_" or "-".`);
|
|
135
|
+
}
|
|
136
|
+
index++;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const subsection = currentTrimmed.match(/^###\s+(.+?)\s*$/);
|
|
140
|
+
if (!subsection) {
|
|
141
|
+
throw new WorkflowValidationError(`Unexpected content in step "${stepTitle}". Add "Step ID: <id>" before subsections or move text under "### Instructions".`);
|
|
142
|
+
}
|
|
143
|
+
const subsectionName = subsection[1].trim();
|
|
144
|
+
index++;
|
|
145
|
+
const block = collectSectionBlock(lines, index);
|
|
146
|
+
index = block.nextIndex;
|
|
147
|
+
if (subsectionName === "Instructions") {
|
|
148
|
+
if (instructions) {
|
|
149
|
+
throw new WorkflowValidationError(`Step "${stepTitle}" must contain exactly one "### Instructions" section.`);
|
|
150
|
+
}
|
|
151
|
+
instructions = block.text;
|
|
152
|
+
if (!instructions) {
|
|
153
|
+
throw new WorkflowValidationError(`Step "${stepTitle}" must include instructions text.`);
|
|
154
|
+
}
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (subsectionName === "Completion Criteria") {
|
|
158
|
+
if (completionCriteria) {
|
|
159
|
+
throw new WorkflowValidationError(`Step "${stepTitle}" must contain at most one "### Completion Criteria" section.`);
|
|
160
|
+
}
|
|
161
|
+
completionCriteria = block.items;
|
|
162
|
+
if (!completionCriteria || completionCriteria.length === 0) {
|
|
163
|
+
throw new WorkflowValidationError(`Step "${stepTitle}" has an empty "### Completion Criteria" section.`);
|
|
164
|
+
}
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
throw new WorkflowValidationError(`Unknown subsection "### ${subsectionName}" in step "${stepTitle}". Only "### Instructions" and optional "### Completion Criteria" are supported.`);
|
|
168
|
+
}
|
|
169
|
+
if (!stepId) {
|
|
170
|
+
throw new WorkflowValidationError(`Step "${stepTitle}" must contain exactly one "Step ID: <id>" line.`);
|
|
171
|
+
}
|
|
172
|
+
if (!instructions) {
|
|
173
|
+
throw new WorkflowValidationError(`Step "${stepTitle}" must contain a "### Instructions" section.`);
|
|
174
|
+
}
|
|
175
|
+
steps.push({
|
|
176
|
+
id: stepId,
|
|
177
|
+
title: stepTitle,
|
|
178
|
+
instructions,
|
|
179
|
+
...(completionCriteria ? { completionCriteria } : {}),
|
|
180
|
+
sequenceIndex,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
if (steps.length === 0) {
|
|
184
|
+
throw new WorkflowValidationError('Workflow markdown must contain at least one "## Step: <title>" section.');
|
|
185
|
+
}
|
|
186
|
+
const seenStepIds = new Set();
|
|
187
|
+
for (const step of steps) {
|
|
188
|
+
if (seenStepIds.has(step.id)) {
|
|
189
|
+
throw new WorkflowValidationError(`Workflow step IDs must be unique. Duplicate Step ID: "${step.id}".`);
|
|
190
|
+
}
|
|
191
|
+
seenStepIds.add(step.id);
|
|
192
|
+
}
|
|
193
|
+
return steps;
|
|
194
|
+
}
|
|
195
|
+
function normalizeLines(body) {
|
|
196
|
+
return body.replace(/\r\n|\r/g, "\n").split("\n");
|
|
197
|
+
}
|
|
198
|
+
function collectSectionBlock(lines, startIndex) {
|
|
199
|
+
const collected = [];
|
|
200
|
+
let index = startIndex;
|
|
201
|
+
while (index < lines.length) {
|
|
202
|
+
const line = lines[index] ?? "";
|
|
203
|
+
const trimmed = line.trim();
|
|
204
|
+
if (/^##\s+Step:\s+/.test(trimmed) || /^###\s+/.test(trimmed) || /^#\s+/.test(trimmed))
|
|
205
|
+
break;
|
|
206
|
+
collected.push(line);
|
|
207
|
+
index++;
|
|
208
|
+
}
|
|
209
|
+
const text = collected.join("\n").trim();
|
|
210
|
+
const items = text
|
|
211
|
+
? text
|
|
212
|
+
.split("\n")
|
|
213
|
+
.map((line) => line.trim())
|
|
214
|
+
.filter(Boolean)
|
|
215
|
+
.map((line) => line.replace(/^[-*]\s*/, "").trim())
|
|
216
|
+
.filter(Boolean)
|
|
217
|
+
: undefined;
|
|
218
|
+
return { text, items, nextIndex: index };
|
|
219
|
+
}
|
|
220
|
+
function extractTagListFromFrontmatter(frontmatter) {
|
|
221
|
+
const lines = frontmatter.split("\n");
|
|
222
|
+
const tagIndex = lines.findIndex((line) => /^tags:\s*$/.test(line.trim()));
|
|
223
|
+
if (tagIndex === -1)
|
|
224
|
+
return undefined;
|
|
225
|
+
const tags = [];
|
|
226
|
+
for (let index = tagIndex + 1; index < lines.length; index++) {
|
|
227
|
+
const line = lines[index] ?? "";
|
|
228
|
+
const trimmed = line.trim();
|
|
229
|
+
if (!trimmed)
|
|
230
|
+
continue;
|
|
231
|
+
if (!line.startsWith(" "))
|
|
232
|
+
break;
|
|
233
|
+
const match = trimmed.match(/^-\s+(.+?)\s*$/);
|
|
234
|
+
if (!match) {
|
|
235
|
+
throw new WorkflowValidationError("Workflow frontmatter `tags` must contain only dash-prefixed list items when declared as a block list.");
|
|
236
|
+
}
|
|
237
|
+
const tag = stripMatchingQuotes(match[1]?.trim() ?? "");
|
|
238
|
+
if (tag)
|
|
239
|
+
tags.push(tag);
|
|
240
|
+
}
|
|
241
|
+
return tags.length > 0 ? tags : undefined;
|
|
242
|
+
}
|
|
243
|
+
function stripMatchingQuotes(value) {
|
|
244
|
+
if (value.length >= 2) {
|
|
245
|
+
const quote = value[0];
|
|
246
|
+
if ((quote === '"' || quote === "'") && value[value.length - 1] === quote) {
|
|
247
|
+
return value.slice(1, -1).trim();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return value;
|
|
251
|
+
}
|