chore: add ion package, codesight wiki, work plans, ascli config
New @boocode/ion package (v0.0.1) for inference optimization network. .codesight/ wiki artifacts for codebase documentation. .omo/ work plans for openspec cleanup and enhanced file panel.
This commit is contained in:
22
packages/ion/src/format/index.ts
Normal file
22
packages/ion/src/format/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Ion workflow engine — format module.
|
||||
*
|
||||
* Re-exports the SOP markdown parser, YAML converter, and file discovery
|
||||
* utilities so consumers can import from a single entry point:
|
||||
*
|
||||
* ```ts
|
||||
* import { parseSopContent, convertSopToWorkflowYaml, discoverSopFiles } from '@boocode/ion/format';
|
||||
* ```
|
||||
*/
|
||||
|
||||
export {
|
||||
parseSopContent,
|
||||
type SopDocument,
|
||||
type SopParameter,
|
||||
type SopStep,
|
||||
} from './sop-parser.js';
|
||||
|
||||
export { convertSopToWorkflowYaml } from './sop-to-yaml.js';
|
||||
|
||||
export { discoverSopFiles } from './sop-discovery.js';
|
||||
export type { GlobFn } from './sop-discovery.js';
|
||||
78
packages/ion/src/format/sop-discovery.ts
Normal file
78
packages/ion/src/format/sop-discovery.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* SOP file discovery for the Ion workflow engine.
|
||||
*
|
||||
* Locates `.sop.md` files by delegating file-system traversal to a
|
||||
* caller-provided glob function. This keeps the module pure (no Node
|
||||
* dependency) and easily testable.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A function that resolves a glob pattern to an array of absolute paths.
|
||||
*
|
||||
* The caller typically provides an implementation backed by `node:fs/promises`
|
||||
* or a test double.
|
||||
*/
|
||||
export type GlobFn = (pattern: string) => Promise<string[]>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Default search directories (in priority order, relative to cwd). */
|
||||
const SEARCH_DIRS = ['.archon/workflows', '.'];
|
||||
|
||||
/** Glob pattern for SOP markdown files. */
|
||||
const SOP_GLOB = '**/*.sop.md';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Discover all `.sop.md` files in the given working directory.
|
||||
*
|
||||
* Searches `.archon/workflows/` first, then the project root, and returns
|
||||
* absolute paths to every matching file. Duplicate paths are de-duplicated.
|
||||
*
|
||||
* @param cwd - The working directory to search from.
|
||||
* @param globFn - A function that resolves a glob pattern to file paths.
|
||||
* Typically backed by `glob` from `node:fs/promises` or a
|
||||
* similar library.
|
||||
* @returns An array of absolute file paths to discovered `.sop.md` files,
|
||||
* sorted deterministically.
|
||||
*/
|
||||
export async function discoverSopFiles(
|
||||
cwd: string,
|
||||
globFn: GlobFn,
|
||||
): Promise<string[]> {
|
||||
const seen = new Set<string>();
|
||||
const results: string[] = [];
|
||||
|
||||
for (const dir of SEARCH_DIRS) {
|
||||
const pattern =
|
||||
dir === '.' ? `${cwd}/${SOP_GLOB}` : `${cwd}/${dir}/${SOP_GLOB}`;
|
||||
|
||||
let paths: string[];
|
||||
try {
|
||||
paths = await globFn(pattern);
|
||||
} catch {
|
||||
// Glob errors (e.g. directory doesn't exist) are non-fatal.
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sort for deterministic output and de-duplicate
|
||||
paths.sort();
|
||||
for (const p of paths) {
|
||||
if (!seen.has(p)) {
|
||||
seen.add(p);
|
||||
results.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
203
packages/ion/src/format/sop-parser.ts
Normal file
203
packages/ion/src/format/sop-parser.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* SOP Markdown parser for the Ion workflow engine.
|
||||
*
|
||||
* Parses `.sop.md` files (Agent SOP format) into structured `SopDocument`
|
||||
* objects that can be converted to YAML workflow definitions.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A single parameter declared in the SOP's Parameters section. */
|
||||
export interface SopParameter {
|
||||
/** Parameter name (camelCase by convention). */
|
||||
name: string;
|
||||
/** Whether the parameter is required or optional. */
|
||||
type: 'required' | 'optional';
|
||||
/** Default value (only present when type is 'optional'). */
|
||||
default?: string;
|
||||
/** Human-readable description of the parameter. */
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** A single step declared in the SOP's Steps section. */
|
||||
export interface SopStep {
|
||||
/** Step number (1-based). */
|
||||
number: number;
|
||||
/** Short human-readable step name. */
|
||||
name: string;
|
||||
/** Full body text of the step (may be multi-line). */
|
||||
body: string;
|
||||
/** Constraints text extracted from the step, if any. */
|
||||
constraints?: string;
|
||||
}
|
||||
|
||||
/** The fully-parsed SOP document. */
|
||||
export interface SopDocument {
|
||||
/** Title extracted from the first `# heading`. */
|
||||
title: string;
|
||||
/** Overview section content. */
|
||||
overview: string;
|
||||
/** Parsed parameters (empty array if section absent). */
|
||||
parameters: SopParameter[];
|
||||
/** Parsed steps (empty array if section absent). */
|
||||
steps: SopStep[];
|
||||
/** Optional examples section content. */
|
||||
examples?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Extract a section body from markdown text.
|
||||
*
|
||||
* A section starts with `## <Title>` and ends at the next `## ` or `# `
|
||||
* heading (or end of string).
|
||||
*/
|
||||
function extractSection(markdown: string, heading: string): string | null {
|
||||
const pattern = new RegExp(
|
||||
`^##\\s+${escapeRegex(heading)}\\s*\\n([\\s\\S]*?)(?=\\n##|\\n#|$)`,
|
||||
'm',
|
||||
);
|
||||
const match = markdown.match(pattern);
|
||||
return match?.[1]?.trim() ?? null;
|
||||
}
|
||||
|
||||
/** Escape special regex characters in a literal string. */
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section parsers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Parse the Parameters section into structured `SopParameter` objects. */
|
||||
function parseParameters(raw: string): SopParameter[] {
|
||||
const parameters: SopParameter[] = [];
|
||||
// Match lines like: - **paramName** (required): Description here
|
||||
// - **paramName** (optional, default: value): Description here
|
||||
const paramRegex =
|
||||
/^-\s+\*\*(\w+)\*\*\s+\((required|optional)(?:,\s*default:\s*([^)]+))?\):\s+(.+)$/gm;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = paramRegex.exec(raw)) !== null) {
|
||||
const name = match[1]!;
|
||||
const type = match[2]! as 'required' | 'optional';
|
||||
const defaultVal = match[3]; // may be undefined (optional group)
|
||||
const description = match[4]!;
|
||||
|
||||
const param: SopParameter = {
|
||||
name,
|
||||
type,
|
||||
description,
|
||||
};
|
||||
if (defaultVal !== undefined) {
|
||||
param.default = defaultVal.trim();
|
||||
}
|
||||
parameters.push(param);
|
||||
}
|
||||
|
||||
return parameters;
|
||||
}
|
||||
|
||||
/** Parse the Steps section into structured `SopStep` objects. */
|
||||
function parseSteps(raw: string): SopStep[] {
|
||||
const steps: SopStep[] = [];
|
||||
|
||||
// Find all ### sub-headings like "### 1. Step Name"
|
||||
const stepHeadingRegex = /^###\s+(\d+)\.\s+(.+)$/gm;
|
||||
|
||||
// Collect heading positions: [startIndex, endIndex, number, name]
|
||||
const headings: { number: number; name: string; start: number; end: number }[] = [];
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = stepHeadingRegex.exec(raw)) !== null) {
|
||||
headings.push({
|
||||
number: parseInt(match[1]!, 10),
|
||||
name: match[2]!.trim(),
|
||||
start: match.index,
|
||||
end: -1, // filled in below
|
||||
});
|
||||
}
|
||||
|
||||
// Set end positions: each heading ends where the next one starts, or at EOF
|
||||
for (let i = 0; i < headings.length; i++) {
|
||||
const heading = headings[i]!;
|
||||
heading.end =
|
||||
i + 1 < headings.length ? headings[i + 1]!.start : raw.length;
|
||||
}
|
||||
|
||||
for (const heading of headings) {
|
||||
// The body starts after the heading line itself
|
||||
const headingLineEnd = raw.indexOf('\n', heading.start);
|
||||
const bodyStart = headingLineEnd === -1 ? raw.length : headingLineEnd + 1;
|
||||
const sectionText = raw.slice(bodyStart, heading.end).trim();
|
||||
|
||||
// Extract constraints if present
|
||||
const constraintsMatch = sectionText.match(
|
||||
/\*\*Constraints:\*\*\s*\n([\s\S]*?)(?=\n###|\n##|$)/,
|
||||
);
|
||||
const constraints = constraintsMatch?.[1]?.trim();
|
||||
|
||||
// Body is everything before the Constraints heading (or the whole text)
|
||||
let body: string;
|
||||
if (constraintsMatch?.index !== undefined) {
|
||||
body = sectionText.slice(0, constraintsMatch.index).trim();
|
||||
} else {
|
||||
body = sectionText;
|
||||
}
|
||||
|
||||
steps.push({
|
||||
number: heading.number,
|
||||
name: heading.name,
|
||||
body,
|
||||
...(constraints ? { constraints } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse a `.sop.md` markdown string into a structured `SopDocument`.
|
||||
*
|
||||
* @param markdown - The raw markdown content of a `.sop.md` file.
|
||||
* @returns A parsed `SopDocument` with title, overview, parameters, steps,
|
||||
* and optional examples.
|
||||
*/
|
||||
export function parseSopContent(markdown: string): SopDocument {
|
||||
// --- Title (first h1) ---
|
||||
const titleMatch = markdown.match(/^#\s+(.+)$/m);
|
||||
const title = titleMatch?.[1]?.trim() ?? 'Untitled SOP';
|
||||
|
||||
// --- Overview ---
|
||||
const overviewRaw = extractSection(markdown, 'Overview');
|
||||
const overview = overviewRaw ?? '';
|
||||
|
||||
// --- Parameters ---
|
||||
const parametersRaw = extractSection(markdown, 'Parameters');
|
||||
const parameters = parametersRaw ? parseParameters(parametersRaw) : [];
|
||||
|
||||
// --- Steps ---
|
||||
const stepsRaw = extractSection(markdown, 'Steps');
|
||||
const steps = stepsRaw ? parseSteps(stepsRaw) : [];
|
||||
|
||||
// --- Examples (optional) ---
|
||||
const examplesRaw = extractSection(markdown, 'Examples');
|
||||
|
||||
return {
|
||||
title,
|
||||
overview,
|
||||
parameters,
|
||||
steps,
|
||||
...(examplesRaw !== null ? { examples: examplesRaw } : {}),
|
||||
};
|
||||
}
|
||||
102
packages/ion/src/format/sop-to-yaml.ts
Normal file
102
packages/ion/src/format/sop-to-yaml.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* SOP-to-YAML converter for the Ion workflow engine.
|
||||
*
|
||||
* Converts a parsed `SopDocument` into a YAML workflow definition string
|
||||
* that can be fed back into the Ion YAML loader.
|
||||
*/
|
||||
|
||||
import type { SopDocument } from './sop-parser.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Convert a title string to kebab-case for use as a YAML identifier. */
|
||||
function toKebabCase(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-') // non-alphanumeric → hyphen
|
||||
.replace(/^-+|-+$/g, ''); // strip leading/trailing hyphens
|
||||
}
|
||||
|
||||
/**
|
||||
* Indent every line of a multi-line string by the given number of spaces.
|
||||
* Empty lines are preserved without extra indentation.
|
||||
*/
|
||||
function indentBlock(text: string, spaces: number): string {
|
||||
const prefix = ' '.repeat(spaces);
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => (line.length > 0 ? prefix + line : line))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Convert a parsed `SopDocument` into a YAML workflow definition string.
|
||||
*
|
||||
* The output is valid YAML that can be loaded by the Ion YAML loader.
|
||||
* Steps become sequential prompt nodes with `depends_on` linking.
|
||||
* Constraints are appended to the prompt body as plain text.
|
||||
* Only `prompt:` nodes are emitted — SOP is conversation-only.
|
||||
*
|
||||
* @param sop - The parsed SOP document.
|
||||
* @returns A YAML string representing the workflow.
|
||||
*/
|
||||
export function convertSopToWorkflowYaml(sop: SopDocument): string {
|
||||
const name = toKebabCase(sop.title);
|
||||
const lines: string[] = [];
|
||||
|
||||
// --- Header comment with parameter info ---
|
||||
if (sop.parameters.length > 0) {
|
||||
lines.push('# Parameters:');
|
||||
for (const param of sop.parameters) {
|
||||
const tag = param.type === 'required' ? 'required' : 'optional';
|
||||
const defaultPart = param.default ? `, default: ${param.default}` : '';
|
||||
lines.push(`# ${param.name} (${tag}${defaultPart}): ${param.description}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// --- Top-level fields ---
|
||||
lines.push(`name: ${name}`);
|
||||
lines.push(`description: |`);
|
||||
lines.push(indentBlock(sop.overview || 'No description provided.', 2));
|
||||
lines.push('');
|
||||
|
||||
// --- Nodes ---
|
||||
lines.push('nodes:');
|
||||
|
||||
for (let i = 0; i < sop.steps.length; i++) {
|
||||
const step = sop.steps[i]!;
|
||||
const stepId = `step_${step.number}`;
|
||||
const isFirst = i === 0;
|
||||
|
||||
// Build the prompt body: step body + constraints (if any)
|
||||
let promptBody = step.body;
|
||||
if (step.constraints) {
|
||||
promptBody += `\n\nConstraints:\n${step.constraints}`;
|
||||
}
|
||||
|
||||
lines.push(` - id: ${stepId}`);
|
||||
lines.push(` prompt: |`);
|
||||
lines.push(indentBlock(promptBody, 6));
|
||||
|
||||
if (isFirst) {
|
||||
lines.push(` depends_on: []`);
|
||||
} else {
|
||||
const prevStep = sop.steps[i - 1]!;
|
||||
lines.push(` depends_on: [step_${prevStep.number}]`);
|
||||
}
|
||||
|
||||
// Blank line between nodes (but not after the last one)
|
||||
if (i < sop.steps.length - 1) {
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
Reference in New Issue
Block a user