@hoplogic/hopjit 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/LICENSE +21 -0
- package/README.md +99 -0
- package/dist/act-body-interpreter.d.ts +24 -0
- package/dist/act-body-interpreter.js +159 -0
- package/dist/act-body-parser.d.ts +10 -0
- package/dist/act-body-parser.js +577 -0
- package/dist/act-builtins.d.ts +12 -0
- package/dist/act-builtins.js +104 -0
- package/dist/ast-helpers.d.ts +26 -0
- package/dist/ast-helpers.js +58 -0
- package/dist/ast-runtime.d.ts +49 -0
- package/dist/ast-runtime.js +224 -0
- package/dist/ast-types.d.ts +260 -0
- package/dist/ast-types.js +4 -0
- package/dist/cli-types.d.ts +190 -0
- package/dist/cli-types.js +4 -0
- package/dist/cli.d.ts +26 -0
- package/dist/cli.js +623 -0
- package/dist/dispatcher.d.ts +92 -0
- package/dist/dispatcher.js +692 -0
- package/dist/doc-ref.d.ts +41 -0
- package/dist/doc-ref.js +214 -0
- package/dist/engine-traverse.d.ts +37 -0
- package/dist/engine-traverse.js +385 -0
- package/dist/engine.d.ts +141 -0
- package/dist/engine.js +1792 -0
- package/dist/errors.d.ts +47 -0
- package/dist/errors.js +34 -0
- package/dist/hoplog.d.ts +120 -0
- package/dist/hoplog.js +501 -0
- package/dist/parser.d.ts +7 -0
- package/dist/parser.js +802 -0
- package/dist/persistence.d.ts +60 -0
- package/dist/persistence.js +208 -0
- package/dist/prompt.d.ts +130 -0
- package/dist/prompt.js +1014 -0
- package/dist/provider-types.d.ts +134 -0
- package/dist/provider-types.js +3 -0
- package/dist/runtime-types.d.ts +84 -0
- package/dist/runtime-types.js +4 -0
- package/dist/tools.d.ts +16 -0
- package/dist/tools.js +141 -0
- package/dist/validator.d.ts +23 -0
- package/dist/validator.js +959 -0
- package/driver/codex/AGENTS.md +54 -0
- package/driver/hopskill-build/SKILL.md +137 -0
- package/driver/hopskill-build/references/spec-skeleton.md +132 -0
- package/driver/hopskill-build/references/step-type-cheatsheet.md +56 -0
- package/driver/hopskill-build/references/three-focus-rules.md +148 -0
- package/driver/hopspec-skill.md +187 -0
- package/driver/references/cli-discovery.md +36 -0
- package/driver/references/discovery.md +45 -0
- package/driver/references/driver-subagent.md +50 -0
- package/driver/references/parallel-worker.md +50 -0
- package/driver/references/step-execution-rules.md +47 -0
- package/package.json +52 -0
package/dist/parser.js
ADDED
|
@@ -0,0 +1,802 @@
|
|
|
1
|
+
// @module: spec-parser ^anc-struct-spec-parser
|
|
2
|
+
// Hand-written line-by-line parser for HopSpec v3 markdown.
|
|
3
|
+
// Remark was evaluated and rejected (nesting, +→ outputs, blockquotes all break).
|
|
4
|
+
import { ALL_STEP_TYPES, CONTAINER_STEP_TYPES } from './ast-helpers.js';
|
|
5
|
+
import { extractHopPythonFence, parseActBody } from './act-body-parser.js';
|
|
6
|
+
import { extractDocRefs } from './doc-ref.js';
|
|
7
|
+
const MAX_INPUT_SIZE = 100 * 1024;
|
|
8
|
+
const MAX_NESTING_DEPTH = 10;
|
|
9
|
+
// ===== Regex patterns =====
|
|
10
|
+
const RE_HEADING = /^(#{1,6})\s+(.*\S)/;
|
|
11
|
+
const RE_STEP = /^(\d+(?:\.\d+)*)\.\s*\[(\w+)(?:\s+([^\]]*))?\]\s*(.*)/;
|
|
12
|
+
const RE_INPUT = /^-\s*←\s*(.+)/;
|
|
13
|
+
const RE_OUTPUT = /^\+\s*→\s*(.+)/;
|
|
14
|
+
const RE_INSTRUCTION = /^>\s?(.*)/;
|
|
15
|
+
const RE_VAR_DECL = /^-\s*(\w+)\s*:\s*(\S+(?:\([^)]*\))?)\s*(?:#\s*(.*))?$/;
|
|
16
|
+
const RE_LIST_ITEM = /^-\s+(.*)/;
|
|
17
|
+
const RE_ID_LINE = /^Id\s*:\s*(\S+)/;
|
|
18
|
+
const RE_FENCE = /^```/;
|
|
19
|
+
const SECTION_KEYWORDS = {
|
|
20
|
+
goal: 'goal',
|
|
21
|
+
constraints: 'constraints',
|
|
22
|
+
types: 'types',
|
|
23
|
+
inputs: 'inputs',
|
|
24
|
+
outputs: 'outputs',
|
|
25
|
+
config: 'config',
|
|
26
|
+
steps: 'steps',
|
|
27
|
+
};
|
|
28
|
+
function identifySections(lines) {
|
|
29
|
+
const sections = [];
|
|
30
|
+
let inFence = false;
|
|
31
|
+
for (let i = 0; i < lines.length; i++) {
|
|
32
|
+
const line = lines[i];
|
|
33
|
+
if (RE_FENCE.test(line.trim())) {
|
|
34
|
+
inFence = !inFence;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (inFence)
|
|
38
|
+
continue;
|
|
39
|
+
const hm = line.match(RE_HEADING);
|
|
40
|
+
if (hm) {
|
|
41
|
+
const depth = hm[1].length;
|
|
42
|
+
const text = hm[2].trim();
|
|
43
|
+
if (depth === 1 && sections.length === 0) {
|
|
44
|
+
sections.push({ kind: 'title', startLine: i, endLine: i + 1 });
|
|
45
|
+
}
|
|
46
|
+
else if (depth === 2) {
|
|
47
|
+
const key = text.toLowerCase().replace(/\s+/g, '');
|
|
48
|
+
const kind = SECTION_KEYWORDS[key];
|
|
49
|
+
// 非关键字的 depth-2 标题(如标题风的 `## Task` 契约分区)静默忽略——不产生 section、
|
|
50
|
+
// 零解析影响。这是有意支持的 Obsidian 折叠分区惯例,非漏网 bug,勿加报错。
|
|
51
|
+
// 见 design ^anc-rule-surface-heading-flavor。// @a: anc-rule-surface-heading-flavor
|
|
52
|
+
if (kind)
|
|
53
|
+
sections.push({ kind, startLine: i, endLine: i + 1 });
|
|
54
|
+
}
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
// Inline label format: "Goal: text", "Inputs:", etc.
|
|
58
|
+
if (sections.length > 0) {
|
|
59
|
+
const labelMatch = line.match(/^(\w+)\s*:/);
|
|
60
|
+
if (labelMatch) {
|
|
61
|
+
const key = labelMatch[1].toLowerCase();
|
|
62
|
+
const kind = SECTION_KEYWORDS[key];
|
|
63
|
+
if (kind && !sections.some(s => s.kind === kind)) {
|
|
64
|
+
sections.push({ kind, startLine: i, endLine: i + 1 });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// Id: line (special case, not a section heading)
|
|
69
|
+
if (RE_ID_LINE.test(line) && !sections.some(s => s.kind === 'id')) {
|
|
70
|
+
sections.push({ kind: 'id', startLine: i, endLine: i + 1 });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Set endLine for each section to next section's start (or EOF)
|
|
74
|
+
for (let i = 0; i < sections.length; i++) {
|
|
75
|
+
const next = sections[i + 1];
|
|
76
|
+
sections[i].endLine = next ? next.startLine : lines.length;
|
|
77
|
+
}
|
|
78
|
+
return sections;
|
|
79
|
+
}
|
|
80
|
+
// ===== Header parsing =====
|
|
81
|
+
function parseHeaderSections(lines, sections, errors) {
|
|
82
|
+
const header = { title: '' };
|
|
83
|
+
const titleSec = sections.find(s => s.kind === 'title');
|
|
84
|
+
if (titleSec) {
|
|
85
|
+
const hm = lines[titleSec.startLine].match(RE_HEADING);
|
|
86
|
+
if (hm)
|
|
87
|
+
header.title = hm[2].trim();
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
errors.push({ kind: 'parse', line: 1, message: 'Missing spec title (# heading)' });
|
|
91
|
+
}
|
|
92
|
+
const idSec = sections.find(s => s.kind === 'id');
|
|
93
|
+
if (idSec) {
|
|
94
|
+
const m = lines[idSec.startLine].match(RE_ID_LINE);
|
|
95
|
+
if (m)
|
|
96
|
+
header.id = m[1].trim();
|
|
97
|
+
}
|
|
98
|
+
const goalSec = sections.find(s => s.kind === 'goal');
|
|
99
|
+
if (goalSec)
|
|
100
|
+
header.goal = parsePlainTextSection(lines, goalSec);
|
|
101
|
+
const conSec = sections.find(s => s.kind === 'constraints');
|
|
102
|
+
if (conSec) {
|
|
103
|
+
header.constraints = parseListSection(lines, conSec);
|
|
104
|
+
const conDocRefs = extractDocRefs(header.constraints.join('\n')); // @a: anc-rule-doc-ref-extract
|
|
105
|
+
if (conDocRefs.length > 0)
|
|
106
|
+
header.doc_refs = conDocRefs;
|
|
107
|
+
}
|
|
108
|
+
const typesSec = sections.find(s => s.kind === 'types');
|
|
109
|
+
if (typesSec)
|
|
110
|
+
header.types = parseTypesSection(lines, typesSec, errors);
|
|
111
|
+
const inputsSec = sections.find(s => s.kind === 'inputs');
|
|
112
|
+
if (inputsSec)
|
|
113
|
+
header.inputs = parseVarDeclSection(lines, inputsSec);
|
|
114
|
+
const outputsSec = sections.find(s => s.kind === 'outputs');
|
|
115
|
+
if (outputsSec)
|
|
116
|
+
header.outputs = parseOutputDeclSection(lines, outputsSec);
|
|
117
|
+
const configSec = sections.find(s => s.kind === 'config');
|
|
118
|
+
if (configSec)
|
|
119
|
+
header.config = parseConfigSection(lines, configSec);
|
|
120
|
+
return header;
|
|
121
|
+
}
|
|
122
|
+
function sectionBodyLines(lines, sec) {
|
|
123
|
+
const start = sec.startLine;
|
|
124
|
+
const firstLine = lines[start];
|
|
125
|
+
// Skip the heading/label line itself; for inline label with content, handle separately
|
|
126
|
+
const isHeading = RE_HEADING.test(firstLine);
|
|
127
|
+
const bodyStart = isHeading ? start + 1 : start;
|
|
128
|
+
// For inline label format "Goal: text", the text after ":" is part of content
|
|
129
|
+
const result = [];
|
|
130
|
+
for (let i = bodyStart; i < sec.endLine; i++) {
|
|
131
|
+
const line = lines[i];
|
|
132
|
+
if (i === start && !isHeading) {
|
|
133
|
+
// Inline label: extract text after ":"
|
|
134
|
+
const colonIdx = line.indexOf(':');
|
|
135
|
+
if (colonIdx >= 0) {
|
|
136
|
+
const rest = line.slice(colonIdx + 1).trim();
|
|
137
|
+
if (rest)
|
|
138
|
+
result.push(rest);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
result.push(line);
|
|
143
|
+
}
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
function parsePlainTextSection(lines, sec) {
|
|
147
|
+
return sectionBodyLines(lines, sec)
|
|
148
|
+
.map(l => l.trim())
|
|
149
|
+
.filter(l => l.length > 0)
|
|
150
|
+
.join('\n');
|
|
151
|
+
}
|
|
152
|
+
function parseListSection(lines, sec) {
|
|
153
|
+
const items = [];
|
|
154
|
+
for (const line of sectionBodyLines(lines, sec)) {
|
|
155
|
+
const m = line.trim().match(RE_LIST_ITEM);
|
|
156
|
+
if (m)
|
|
157
|
+
items.push(m[1].trim());
|
|
158
|
+
}
|
|
159
|
+
return items;
|
|
160
|
+
}
|
|
161
|
+
function parseTypesSection(lines, sec, errors) {
|
|
162
|
+
const types = [];
|
|
163
|
+
const bodyLines = sectionBodyLines(lines, sec);
|
|
164
|
+
let current = null;
|
|
165
|
+
let typeIndent = -1;
|
|
166
|
+
for (let i = 0; i < bodyLines.length; i++) {
|
|
167
|
+
const raw = bodyLines[i];
|
|
168
|
+
const trimmed = raw.trim();
|
|
169
|
+
if (!trimmed)
|
|
170
|
+
continue;
|
|
171
|
+
// New type declaration: "- TypeName:" or "TypeName:"(容忍尾部 `# 注释`)
|
|
172
|
+
const typeMatch = trimmed.match(/^-?\s*(\w+)\s*:\s*(?:#.*)?$/);
|
|
173
|
+
if (typeMatch) {
|
|
174
|
+
if (current)
|
|
175
|
+
types.push(current);
|
|
176
|
+
current = { name: typeMatch[1], fields: {} };
|
|
177
|
+
typeIndent = raw.search(/\S/);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
// Field line (indented under type): "fieldName: typeString"
|
|
181
|
+
if (current) {
|
|
182
|
+
const lineIndent = raw.search(/\S/);
|
|
183
|
+
if (lineIndent > typeIndent) {
|
|
184
|
+
// 容忍字段行首的 `- ` 列表前缀(`- id: line` 与 `id: line` 均可,与 TypeName 行 `-?` 一致)。
|
|
185
|
+
// 旧正则漏了 `-?\s*`,致 `- field: type` 写法的字段被整体丢弃、fields 空(C8 字段校验落空)。
|
|
186
|
+
const fieldMatch = trimmed.match(/^-?\s*(\w+)\s*:\s*(\S+(?:\([^)]*\))?)/);
|
|
187
|
+
if (fieldMatch) {
|
|
188
|
+
current.fields[fieldMatch[1]] = fieldMatch[2];
|
|
189
|
+
}
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
// Not indented under type — finalize current TypeDecl
|
|
193
|
+
types.push(current);
|
|
194
|
+
current = null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (current)
|
|
198
|
+
types.push(current);
|
|
199
|
+
return types;
|
|
200
|
+
}
|
|
201
|
+
function parseVarDeclSection(lines, sec) {
|
|
202
|
+
const vars = [];
|
|
203
|
+
for (const line of sectionBodyLines(lines, sec)) {
|
|
204
|
+
const trimmed = line.trim();
|
|
205
|
+
if (!trimmed)
|
|
206
|
+
continue;
|
|
207
|
+
const m = trimmed.match(RE_VAR_DECL);
|
|
208
|
+
if (m) {
|
|
209
|
+
vars.push({ name: m[1], type: m[2], description: m[3]?.trim() });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return vars;
|
|
213
|
+
}
|
|
214
|
+
function parseOutputDeclSection(lines, sec) {
|
|
215
|
+
const outputs = [];
|
|
216
|
+
for (const line of sectionBodyLines(lines, sec)) {
|
|
217
|
+
const trimmed = line.trim();
|
|
218
|
+
if (!trimmed)
|
|
219
|
+
continue;
|
|
220
|
+
const m = trimmed.match(RE_VAR_DECL);
|
|
221
|
+
if (m) {
|
|
222
|
+
outputs.push({ name: m[1], type: m[2], description: m[3]?.trim() ?? '' });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return outputs;
|
|
226
|
+
}
|
|
227
|
+
function parseConfigSection(lines, sec) {
|
|
228
|
+
const config = {};
|
|
229
|
+
for (const line of sectionBodyLines(lines, sec)) {
|
|
230
|
+
const trimmed = line.trim();
|
|
231
|
+
if (!trimmed)
|
|
232
|
+
continue;
|
|
233
|
+
const m = trimmed.match(/^-?\s*(\w+)\s*:\s*(.+)/);
|
|
234
|
+
if (m) {
|
|
235
|
+
const val = m[2].trim();
|
|
236
|
+
const num = Number(val);
|
|
237
|
+
config[m[1]] = Number.isFinite(num) ? num : val;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return config;
|
|
241
|
+
}
|
|
242
|
+
function parseStepSection(lines, sec, errors) {
|
|
243
|
+
const flatSteps = [];
|
|
244
|
+
let current = null;
|
|
245
|
+
let inFence = false;
|
|
246
|
+
// 标题风裸 fence 捕获态:进入裸 ```hop_python 且属 heading-flavor 步骤时置真,
|
|
247
|
+
// fence 开/闭行与 body 行捕获进 current.instruction(body 按开栏缩进 de-indent)。
|
|
248
|
+
// 见 design ^anc-rule-surface-heading-flavor。
|
|
249
|
+
let captureFence = false;
|
|
250
|
+
let fenceIndent = 0;
|
|
251
|
+
for (let i = sec.startLine + 1; i < sec.endLine; i++) {
|
|
252
|
+
const line = lines[i];
|
|
253
|
+
const trimmed = line.trim();
|
|
254
|
+
if (RE_FENCE.test(trimmed)) {
|
|
255
|
+
// heading-flavor 步骤的裸 fence:捕获而非丢弃。开栏进捕获态、记缩进;闭栏出捕获态。
|
|
256
|
+
// 开/闭行归一(无缩进)推入 instruction,供 extractHopPythonFence 提取。
|
|
257
|
+
if (!inFence && current?.headingFlavor && !captureFence) {
|
|
258
|
+
captureFence = true;
|
|
259
|
+
fenceIndent = line.length - line.trimStart().length;
|
|
260
|
+
current.instruction.push(trimmed);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (captureFence) {
|
|
264
|
+
current.instruction.push(trimmed);
|
|
265
|
+
captureFence = false;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
inFence = !inFence;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (captureFence) {
|
|
272
|
+
// body 行:按开栏缩进 de-indent(act body 缩进敏感),不足则整体 trimStart 兜底。
|
|
273
|
+
current.instruction.push(line.length >= fenceIndent ? line.slice(fenceIndent) : line.trimStart());
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (inFence)
|
|
277
|
+
continue;
|
|
278
|
+
if (!trimmed)
|
|
279
|
+
continue;
|
|
280
|
+
if (trimmed.startsWith('%%'))
|
|
281
|
+
continue;
|
|
282
|
+
// Try step line。标题风:先剥行首 `#` 前缀(`### 1. [reason] …`),再匹配 RE_STEP。
|
|
283
|
+
// 剥掉过前缀 = heading-flavor 步骤,启用下方段落吸收。`#` 个数不参与树构建(树由 step ID
|
|
284
|
+
// 数字编码),故深过 h6 的 `#######` 照常解析。见 design ^anc-rule-surface-heading-flavor。
|
|
285
|
+
const stepBody = trimmed.replace(/^#+\s+/, '');
|
|
286
|
+
const isHeadingFlavor = stepBody !== trimmed;
|
|
287
|
+
const stepMatch = stepBody.match(RE_STEP);
|
|
288
|
+
if (stepMatch) {
|
|
289
|
+
if (current) {
|
|
290
|
+
current.source_location.line_end = i;
|
|
291
|
+
flatSteps.push(current);
|
|
292
|
+
}
|
|
293
|
+
const [, stepId, stepType, attrStr, summaryRaw] = stepMatch;
|
|
294
|
+
const attrs = attrStr ? parseStepAttrs(attrStr) : {};
|
|
295
|
+
if (!ALL_STEP_TYPES.has(stepType)) {
|
|
296
|
+
errors.push({ kind: 'parse', line: i + 1, message: `Unknown step type: ${stepType}` });
|
|
297
|
+
}
|
|
298
|
+
current = {
|
|
299
|
+
step_id: stepId,
|
|
300
|
+
step_type: stepType,
|
|
301
|
+
summary: summaryRaw.trim(),
|
|
302
|
+
attrs,
|
|
303
|
+
inputs: [],
|
|
304
|
+
outputs: [],
|
|
305
|
+
rawOutputs: [],
|
|
306
|
+
instruction: [],
|
|
307
|
+
...(isHeadingFlavor ? { headingFlavor: true } : {}),
|
|
308
|
+
source_location: { line_start: i + 1, line_end: i + 1 },
|
|
309
|
+
};
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (!current)
|
|
313
|
+
continue;
|
|
314
|
+
// Input line: - ← var1, var2
|
|
315
|
+
const inputMatch = trimmed.match(RE_INPUT);
|
|
316
|
+
if (inputMatch) {
|
|
317
|
+
current.inputs.push(...parseInputExpr(inputMatch[1].trim()));
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
// Output line: + → ...
|
|
321
|
+
const outputMatch = trimmed.match(RE_OUTPUT);
|
|
322
|
+
if (outputMatch) {
|
|
323
|
+
const outBody = outputMatch[1].trim();
|
|
324
|
+
// 单行 for-each:`+ → <item> : for-each <list>`——左段=元素绑定输出(itemVar),右段=遍历列表(listVar)。
|
|
325
|
+
// 必须先剥离 for-each 段再 parseOutputExpr,否则 `<item> : for-each` 会被单输出正则误读为 name:type。
|
|
326
|
+
const forEachInline = outBody.match(/^(.*?)\s*:\s*for-each\s+(\S+)\s*$/);
|
|
327
|
+
if (forEachInline) {
|
|
328
|
+
const itemDecl = parseOutputExpr(forEachInline[1].trim());
|
|
329
|
+
current.outputs.push(...itemDecl);
|
|
330
|
+
current.rawOutputs.push(forEachInline[1].trim());
|
|
331
|
+
// itemVar = 左段声明的输出名(元素绑定);listVar = for-each 后的列表变量
|
|
332
|
+
current.forEach = { listVar: forEachInline[2], itemVar: itemDecl[0]?.name ?? forEachInline[1].trim() };
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
current.outputs.push(...parseOutputExpr(outBody));
|
|
336
|
+
current.rawOutputs.push(outBody);
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
// 旧两行式 for-each 已废弃(硬替换):残留独立 `for-each X → Y` 行 → 友好报错,提示改单行式。
|
|
340
|
+
const staleForEach = trimmed.match(/^for-each\s+(\S+)\s*(?:→|->)\s*(\S+)$/);
|
|
341
|
+
if (staleForEach) {
|
|
342
|
+
errors.push({
|
|
343
|
+
kind: 'parse',
|
|
344
|
+
line: i + 1,
|
|
345
|
+
message: `for-each 已改为单行语法,请写 \`+ → ${staleForEach[2]} : for-each ${staleForEach[1]}\`(旧的独立 \`for-each X → Y\` 行已废弃)`,
|
|
346
|
+
});
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
// Instruction line: > text
|
|
350
|
+
const instrMatch = trimmed.match(RE_INSTRUCTION);
|
|
351
|
+
if (instrMatch) {
|
|
352
|
+
current.instruction.push(instrMatch[1]);
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
// 末尾兜底:标题风步骤的纯文本行(标题下自然段落)吸收进 instruction,等价内联风 `>` 指令。
|
|
356
|
+
// 内联风(非 heading-flavor)维持现状——未知行静默丢弃,零回归。
|
|
357
|
+
// 见 design ^anc-rule-surface-heading-flavor。// @a: anc-rule-surface-heading-flavor
|
|
358
|
+
if (current.headingFlavor) {
|
|
359
|
+
current.instruction.push(trimmed);
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (current) {
|
|
364
|
+
current.source_location.line_end = sec.endLine;
|
|
365
|
+
flatSteps.push(current);
|
|
366
|
+
}
|
|
367
|
+
return buildStepTree(flatSteps, errors);
|
|
368
|
+
}
|
|
369
|
+
function parseStepAttrs(attrStr) {
|
|
370
|
+
const attrs = {};
|
|
371
|
+
const parts = attrStr.trim().split(/\s+/);
|
|
372
|
+
for (const part of parts) {
|
|
373
|
+
const eqIdx = part.indexOf('=');
|
|
374
|
+
if (eqIdx >= 0) {
|
|
375
|
+
attrs[part.slice(0, eqIdx)] = part.slice(eqIdx + 1);
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
attrs[part] = true;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return attrs;
|
|
382
|
+
}
|
|
383
|
+
function parseInputExpr(text) {
|
|
384
|
+
// 先剥行尾 `# 就近注释`(标题风建议输入加 # 说明,见 design ^anc-rule-surface-heading-flavor)——
|
|
385
|
+
// 手法对齐 parseOutputExpr 的 hashIdx,注释不剥会污染变量名/映射来源。// @a: anc-rule-surface-heading-flavor
|
|
386
|
+
const hashIdx = text.indexOf('#');
|
|
387
|
+
const body = hashIdx >= 0 ? text.slice(0, hashIdx) : text;
|
|
388
|
+
// 支持冒号映射 "target: source"(call 输入映射 子参数: 父变量);无冒号则 name=source 同名
|
|
389
|
+
return body.split(',').map(s => s.trim()).filter(Boolean).map(item => {
|
|
390
|
+
const colonIdx = item.indexOf(':');
|
|
391
|
+
if (colonIdx > 0) {
|
|
392
|
+
const name = item.slice(0, colonIdx).trim();
|
|
393
|
+
const source = item.slice(colonIdx + 1).trim();
|
|
394
|
+
return { name, source };
|
|
395
|
+
}
|
|
396
|
+
return { name: item, source: item };
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
/** 解析 `= 初值` 字面量为运行时值:Null/None/null→null;[]→空数组;{}→空对象;true/false→bool;
|
|
400
|
+
* 数字→number;带引号或裸文本→字符串。按字面量形态解析,不依赖声明 type。见 exec-engine
|
|
401
|
+
* ^anc-exec-output-init。 // @a: anc-exec-output-init */
|
|
402
|
+
function parseInitValue(raw) {
|
|
403
|
+
const s = raw.trim();
|
|
404
|
+
if (/^(null|none)$/i.test(s))
|
|
405
|
+
return null;
|
|
406
|
+
if (s === '[]')
|
|
407
|
+
return [];
|
|
408
|
+
if (s === '{}')
|
|
409
|
+
return {};
|
|
410
|
+
if (s === 'true')
|
|
411
|
+
return true;
|
|
412
|
+
if (s === 'false')
|
|
413
|
+
return false;
|
|
414
|
+
if (/^-?\d+(\.\d+)?$/.test(s))
|
|
415
|
+
return Number(s);
|
|
416
|
+
// 带引号字符串剥引号;裸文本原样作字符串
|
|
417
|
+
const q = s.match(/^["'](.*)["']$/);
|
|
418
|
+
return q ? q[1] : s;
|
|
419
|
+
}
|
|
420
|
+
function parseOutputExpr(text) {
|
|
421
|
+
if (text.toLowerCase() === 'none')
|
|
422
|
+
return [];
|
|
423
|
+
// Single output with type + optional init: "name: type = 初值 # description"
|
|
424
|
+
const singleMatch = text.match(/^(\w+)\s*:\s*(\S+(?:\([^)]*\))?)\s*(?:=\s*([^#]*?))?\s*(?:#\s*(.*))?$/);
|
|
425
|
+
if (singleMatch) {
|
|
426
|
+
const decl = {
|
|
427
|
+
name: singleMatch[1],
|
|
428
|
+
type: singleMatch[2],
|
|
429
|
+
description: singleMatch[4]?.trim() ?? '',
|
|
430
|
+
};
|
|
431
|
+
if (singleMatch[3] !== undefined && singleMatch[3].trim() !== '') {
|
|
432
|
+
decl.default = parseInitValue(singleMatch[3]);
|
|
433
|
+
}
|
|
434
|
+
return [decl];
|
|
435
|
+
}
|
|
436
|
+
// Comma-separated names (简写引用,无类型): "var1, var2 # 注释"
|
|
437
|
+
// 先剥离行尾 # 注释——更新模式 `+ → findings # 更新...` 走此分支,注释不剥离会污染变量名
|
|
438
|
+
const hashIdx = text.indexOf('#');
|
|
439
|
+
const namesPart = hashIdx >= 0 ? text.slice(0, hashIdx) : text;
|
|
440
|
+
return namesPart.split(',').map(s => s.trim()).filter(Boolean).map(name => ({
|
|
441
|
+
name,
|
|
442
|
+
type: 'text',
|
|
443
|
+
description: '',
|
|
444
|
+
}));
|
|
445
|
+
}
|
|
446
|
+
// ===== Tree building =====
|
|
447
|
+
function getParentId(stepId) {
|
|
448
|
+
const lastDot = stepId.lastIndexOf('.');
|
|
449
|
+
return lastDot >= 0 ? stepId.slice(0, lastDot) : null;
|
|
450
|
+
}
|
|
451
|
+
function buildStepTree(flatSteps, errors) {
|
|
452
|
+
const nodeMap = new Map();
|
|
453
|
+
const topLevel = [];
|
|
454
|
+
// First pass: create all nodes
|
|
455
|
+
for (const flat of flatSteps) {
|
|
456
|
+
const node = flatToStepNode(flat, errors);
|
|
457
|
+
nodeMap.set(flat.step_id, node);
|
|
458
|
+
}
|
|
459
|
+
// Second pass: establish parent-child relationships
|
|
460
|
+
for (const flat of flatSteps) {
|
|
461
|
+
const parentId = getParentId(flat.step_id);
|
|
462
|
+
if (!parentId) {
|
|
463
|
+
topLevel.push(nodeMap.get(flat.step_id));
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
const parent = nodeMap.get(parentId);
|
|
467
|
+
if (!parent) {
|
|
468
|
+
errors.push({
|
|
469
|
+
kind: 'parse',
|
|
470
|
+
line: flat.source_location.line_start,
|
|
471
|
+
message: `Step ${flat.step_id} references non-existent parent ${parentId}`,
|
|
472
|
+
});
|
|
473
|
+
topLevel.push(nodeMap.get(flat.step_id));
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
if (CONTAINER_STEP_TYPES.has(parent.step_type)) {
|
|
477
|
+
const container = parent;
|
|
478
|
+
if (!container.children)
|
|
479
|
+
container.children = [];
|
|
480
|
+
container.children.push(nodeMap.get(flat.step_id));
|
|
481
|
+
}
|
|
482
|
+
else {
|
|
483
|
+
errors.push({
|
|
484
|
+
kind: 'parse',
|
|
485
|
+
line: flat.source_location.line_start,
|
|
486
|
+
message: `Step ${flat.step_id} is nested under non-container step ${parentId} (type: ${parent.step_type})`,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return topLevel;
|
|
491
|
+
}
|
|
492
|
+
const MODEL_RE = /^@model\s+(\S+)$/;
|
|
493
|
+
function extractModelAnnotation(lines) {
|
|
494
|
+
let model_override;
|
|
495
|
+
const filtered = [];
|
|
496
|
+
for (const line of lines) {
|
|
497
|
+
const m = MODEL_RE.exec(line.trim());
|
|
498
|
+
if (m) {
|
|
499
|
+
model_override = m[1];
|
|
500
|
+
}
|
|
501
|
+
else {
|
|
502
|
+
filtered.push(line);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return { instruction: filtered, model_override };
|
|
506
|
+
}
|
|
507
|
+
/** 取布尔修饰属性(true / 'true' 都算真)。adaptive/finally/require_human 共用,消 5 处复制。 */
|
|
508
|
+
function boolAttr(attrs, key) {
|
|
509
|
+
const v = attrs[key];
|
|
510
|
+
return v === true || v === 'true';
|
|
511
|
+
}
|
|
512
|
+
/** 解析 subtask/case 共有的 retry/adaptive 修饰符(两分支原逐字复制)。 */
|
|
513
|
+
function parseRetryAdaptive(attrs) {
|
|
514
|
+
return {
|
|
515
|
+
retry: attrs['retry'] ? Number(attrs['retry']) : undefined,
|
|
516
|
+
adaptive: boolAttr(attrs, 'adaptive'),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
function flatToStepNode(flat, errors) {
|
|
520
|
+
const { instruction: rawInstruction, model_override } = extractModelAnnotation(flat.instruction);
|
|
521
|
+
const instructionText = rawInstruction.length > 0 ? rawInstruction.join('\n') : undefined;
|
|
522
|
+
const docRefs = instructionText ? extractDocRefs(instructionText) : []; // @a: anc-rule-doc-ref-extract
|
|
523
|
+
const base = {
|
|
524
|
+
step_id: flat.step_id,
|
|
525
|
+
step_type: flat.step_type,
|
|
526
|
+
summary: flat.summary,
|
|
527
|
+
inputs: flat.inputs.length > 0 ? flat.inputs : undefined,
|
|
528
|
+
outputs: flat.outputs.length > 0 ? flat.outputs : undefined,
|
|
529
|
+
instruction: instructionText,
|
|
530
|
+
...(docRefs.length > 0 ? { doc_refs: docRefs } : {}),
|
|
531
|
+
...(model_override ? { model_override } : {}),
|
|
532
|
+
source_location: flat.source_location,
|
|
533
|
+
};
|
|
534
|
+
switch (flat.step_type) {
|
|
535
|
+
case 'subtask': {
|
|
536
|
+
const { retry, adaptive } = parseRetryAdaptive(flat.attrs);
|
|
537
|
+
return { ...base, step_type: 'subtask', retry, adaptive, children: [] };
|
|
538
|
+
}
|
|
539
|
+
case 'parallel':
|
|
540
|
+
return { ...base, children: [], ...(flat.forEach ? { forEach: flat.forEach } : {}) };
|
|
541
|
+
case 'branch':
|
|
542
|
+
return { ...base, children: [] };
|
|
543
|
+
case 'loop': {
|
|
544
|
+
const maxIter = flat.attrs['max_iterations'] ? Number(flat.attrs['max_iterations']) : undefined;
|
|
545
|
+
return { ...base, step_type: 'loop', max_iterations: maxIter, children: [] };
|
|
546
|
+
}
|
|
547
|
+
case 'case': {
|
|
548
|
+
// case 条件解析(见 design/spec-parser.md ^anc-rule-case-condition):
|
|
549
|
+
// 仅行尾最后一对半角括号 (...) 内是机器求值的 condition;括号前文字为人读描述、丢弃;
|
|
550
|
+
// 无尾括号则整行作 condition(兼容 [case] severity == 'fatal' 纯条件写法);
|
|
551
|
+
// 结果为空或 default → 'default'(默认 case)。
|
|
552
|
+
// @a: anc-rule-case-condition
|
|
553
|
+
const condition = extractCaseCondition(flat.summary);
|
|
554
|
+
// case 就是 branch 下的 subtask:解析 retry/adaptive 修饰符,语义同 subtask
|
|
555
|
+
const { retry: caseRetry, adaptive: caseAdaptive } = parseRetryAdaptive(flat.attrs);
|
|
556
|
+
return { ...base, step_type: 'case', condition, retry: caseRetry, adaptive: caseAdaptive, children: [] };
|
|
557
|
+
}
|
|
558
|
+
case 'call': {
|
|
559
|
+
let callee_spec_id;
|
|
560
|
+
let summary = flat.summary;
|
|
561
|
+
const colonIdx = summary.indexOf(' : ');
|
|
562
|
+
if (colonIdx >= 0) {
|
|
563
|
+
callee_spec_id = summary.slice(0, colonIdx).trim();
|
|
564
|
+
summary = summary.slice(colonIdx + 3).trim();
|
|
565
|
+
}
|
|
566
|
+
const param_mapping = buildParamMapping(flat.inputs);
|
|
567
|
+
const output_mapping = buildOutputMapping(flat.rawOutputs);
|
|
568
|
+
// call 的 - ←/+ → 是双向映射,不是 VarBinding/OutputDecl——清除 base.inputs/outputs 避免 V4 把映射当类型校验
|
|
569
|
+
return { ...base, step_type: 'call', summary, inputs: undefined, outputs: undefined, callee_spec_id, param_mapping, output_mapping };
|
|
570
|
+
}
|
|
571
|
+
case 'act': {
|
|
572
|
+
const { body, instruction } = parseActBodyFromFlat(flat, errors);
|
|
573
|
+
return { ...base, step_type: 'act', instruction, ...(body ? { body } : {}) };
|
|
574
|
+
}
|
|
575
|
+
case 'commit': {
|
|
576
|
+
const { body, instruction } = parseActBodyFromFlat(flat, errors);
|
|
577
|
+
const irreversible_action = instruction || flat.summary;
|
|
578
|
+
return { ...base, step_type: 'commit', irreversible_action, instruction, ...(body ? { body } : {}) };
|
|
579
|
+
}
|
|
580
|
+
case 'check': {
|
|
581
|
+
const is_finally = boolAttr(flat.attrs, 'finally');
|
|
582
|
+
return { ...base, step_type: 'check', ...(is_finally ? { is_finally: true } : {}) };
|
|
583
|
+
}
|
|
584
|
+
case 'confirm': {
|
|
585
|
+
const require_human = boolAttr(flat.attrs, 'require_human');
|
|
586
|
+
return { ...base, step_type: 'confirm', ...(require_human ? { require_human: true } : {}) };
|
|
587
|
+
}
|
|
588
|
+
case 'ask': {
|
|
589
|
+
const require_human = boolAttr(flat.attrs, 'require_human');
|
|
590
|
+
// present_inputs: 逗号分隔的变量名列表,如 [ask present_inputs=raw_outline,other]。
|
|
591
|
+
// 见 design/spec-parser.md ^anc-rule-p14。// @a: anc-rule-p14
|
|
592
|
+
const piRaw = flat.attrs['present_inputs'];
|
|
593
|
+
const present_inputs = typeof piRaw === 'string' && piRaw.length > 0
|
|
594
|
+
? piRaw.split(',').map(s => s.trim()).filter(Boolean)
|
|
595
|
+
: undefined;
|
|
596
|
+
return { ...base, step_type: 'ask',
|
|
597
|
+
...(require_human ? { require_human: true } : {}),
|
|
598
|
+
...(present_inputs ? { present_inputs } : {}),
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
case 'exit': {
|
|
602
|
+
return { ...base, step_type: 'exit' };
|
|
603
|
+
}
|
|
604
|
+
default:
|
|
605
|
+
return base;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
// act/commit:从 instruction 行提取 ```hop_python 围栏 body,剩余自然语言行(去 @model)作 instruction。
|
|
609
|
+
function parseActBodyFromFlat(flat, errors) {
|
|
610
|
+
const { bodyLines, bodyStartIndex, narrative } = extractHopPythonFence(flat.instruction);
|
|
611
|
+
const { instruction: narrativeNoModel } = extractModelAnnotation(narrative);
|
|
612
|
+
const instruction = narrativeNoModel.length > 0 ? narrativeNoModel.join('\n') : undefined;
|
|
613
|
+
if (!bodyLines)
|
|
614
|
+
return { instruction };
|
|
615
|
+
// 围栏行在原文的绝对行号:步骤起始 + body 在 instruction 中的偏移(近似,错误定位用)
|
|
616
|
+
const baseLine = (flat.source_location?.line_start ?? 0) + bodyStartIndex;
|
|
617
|
+
const body = parseActBody(bodyLines, baseLine, errors) ?? undefined;
|
|
618
|
+
return { body, instruction };
|
|
619
|
+
}
|
|
620
|
+
function buildParamMapping(inputs) {
|
|
621
|
+
if (inputs.length === 0)
|
|
622
|
+
return undefined;
|
|
623
|
+
// 输入映射:from=来源(父变量=source), to=目标(子 Input 名=name)
|
|
624
|
+
return inputs.map(b => ({ from: b.source, to: b.name }));
|
|
625
|
+
}
|
|
626
|
+
// case 条件提取(见 design/spec-parser.md ^anc-rule-case-condition)。
|
|
627
|
+
// 仅行尾最后一对半角括号 (...) 内是机器求值的 condition;括号前文字为人读描述、丢弃。
|
|
628
|
+
// 无尾括号则整行作 condition(兼容 `[case] severity == 'fatal'` 纯条件写法)。
|
|
629
|
+
// 结果为空或 'default' → 'default'(默认 case,与 engine-traverse 的 isDefault 判定对齐)。
|
|
630
|
+
// 取**最后一对**括号而非首个,避免描述里的括号(如全角「(含未知)」+ 半角条件)误截。
|
|
631
|
+
// @a: anc-rule-case-condition
|
|
632
|
+
function extractCaseCondition(summary) {
|
|
633
|
+
const s = (summary ?? '').trim();
|
|
634
|
+
if (!s)
|
|
635
|
+
return 'default';
|
|
636
|
+
// 尾部最后一对半角括号
|
|
637
|
+
const m = s.match(/\(([^()]*)\)\s*$/);
|
|
638
|
+
const raw = (m ? m[1] : s).trim();
|
|
639
|
+
if (!raw || raw.toLowerCase() === 'default')
|
|
640
|
+
return 'default';
|
|
641
|
+
return raw;
|
|
642
|
+
}
|
|
643
|
+
// call 输出映射:+ → parent_var: callee_output(目标父变量: 来源子输出)。同名省略。
|
|
644
|
+
function buildOutputMapping(rawOutputs) {
|
|
645
|
+
const items = rawOutputs.flatMap(raw => raw.split(',').map(s => s.trim()).filter(Boolean));
|
|
646
|
+
if (items.length === 0)
|
|
647
|
+
return undefined;
|
|
648
|
+
if (items.length === 1 && items[0].toLowerCase() === 'none')
|
|
649
|
+
return undefined;
|
|
650
|
+
return items.map(item => {
|
|
651
|
+
// 去掉行尾 # 注释
|
|
652
|
+
const noComment = item.split('#')[0].trim();
|
|
653
|
+
const colonIdx = noComment.indexOf(':');
|
|
654
|
+
if (colonIdx > 0) {
|
|
655
|
+
const to = noComment.slice(0, colonIdx).trim(); // 父变量(目标)
|
|
656
|
+
const from = noComment.slice(colonIdx + 1).trim(); // 子输出(来源)
|
|
657
|
+
return { from, to };
|
|
658
|
+
}
|
|
659
|
+
return { from: noComment, to: noComment }; // 同名
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
// ===== Depth check =====
|
|
663
|
+
function getMaxNesting(steps) {
|
|
664
|
+
let max = 0;
|
|
665
|
+
for (const step of steps) {
|
|
666
|
+
const depth = step.step_id.split('.').length;
|
|
667
|
+
if (depth > max)
|
|
668
|
+
max = depth;
|
|
669
|
+
if ('children' in step && Array.isArray(step.children)) {
|
|
670
|
+
const childMax = getMaxNesting(step.children);
|
|
671
|
+
if (childMax > max)
|
|
672
|
+
max = childMax;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return max;
|
|
676
|
+
}
|
|
677
|
+
// ===== Main export =====
|
|
678
|
+
export function parseSpec(markdown) {
|
|
679
|
+
const errors = [];
|
|
680
|
+
if (new TextEncoder().encode(markdown).length > MAX_INPUT_SIZE) {
|
|
681
|
+
errors.push({ kind: 'parse', line: 0, message: `Input exceeds ${MAX_INPUT_SIZE / 1024}KB limit` });
|
|
682
|
+
return { ast: { header: { title: '' } }, errors };
|
|
683
|
+
}
|
|
684
|
+
const lines = markdown.split('\n');
|
|
685
|
+
const sections = identifySections(lines);
|
|
686
|
+
const header = parseHeaderSections(lines, sections, errors);
|
|
687
|
+
const stepsSec = sections.find(s => s.kind === 'steps');
|
|
688
|
+
let steps;
|
|
689
|
+
if (stepsSec) {
|
|
690
|
+
steps = parseStepSection(lines, stepsSec, errors);
|
|
691
|
+
const maxDepth = getMaxNesting(steps);
|
|
692
|
+
if (maxDepth > MAX_NESTING_DEPTH) {
|
|
693
|
+
errors.push({
|
|
694
|
+
kind: 'parse', line: stepsSec.startLine + 1,
|
|
695
|
+
message: `Step nesting depth ${maxDepth} exceeds limit of ${MAX_NESTING_DEPTH}`,
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
return { ast: { header, steps }, errors };
|
|
700
|
+
}
|
|
701
|
+
// ===== Serializer =====
|
|
702
|
+
export function serializeSpec(ast) {
|
|
703
|
+
const lines = [];
|
|
704
|
+
lines.push(`# ${ast.header.title}`);
|
|
705
|
+
if (ast.header.id)
|
|
706
|
+
lines.push(`Id: ${ast.header.id}`);
|
|
707
|
+
lines.push('');
|
|
708
|
+
if (ast.header.goal) {
|
|
709
|
+
lines.push('## Goal');
|
|
710
|
+
lines.push(ast.header.goal);
|
|
711
|
+
lines.push('');
|
|
712
|
+
}
|
|
713
|
+
if (ast.header.constraints?.length) {
|
|
714
|
+
lines.push('## Constraints');
|
|
715
|
+
for (const c of ast.header.constraints)
|
|
716
|
+
lines.push(`- ${c}`);
|
|
717
|
+
lines.push('');
|
|
718
|
+
}
|
|
719
|
+
if (ast.header.types?.length) {
|
|
720
|
+
lines.push('## Types');
|
|
721
|
+
for (const t of ast.header.types) {
|
|
722
|
+
lines.push(`- ${t.name}:`);
|
|
723
|
+
for (const [field, type] of Object.entries(t.fields)) {
|
|
724
|
+
lines.push(` ${field}: ${type}`);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
lines.push('');
|
|
728
|
+
}
|
|
729
|
+
if (ast.header.inputs?.length) {
|
|
730
|
+
lines.push('## Inputs');
|
|
731
|
+
for (const v of ast.header.inputs) {
|
|
732
|
+
let line = `- ${v.name}: ${v.type}`;
|
|
733
|
+
if (v.description)
|
|
734
|
+
line += ` # ${v.description}`;
|
|
735
|
+
lines.push(line);
|
|
736
|
+
}
|
|
737
|
+
lines.push('');
|
|
738
|
+
}
|
|
739
|
+
if (ast.header.outputs?.length) {
|
|
740
|
+
lines.push('## Outputs');
|
|
741
|
+
for (const o of ast.header.outputs) {
|
|
742
|
+
let line = `- ${o.name}: ${o.type}`;
|
|
743
|
+
if (o.description)
|
|
744
|
+
line += ` # ${o.description}`;
|
|
745
|
+
lines.push(line);
|
|
746
|
+
}
|
|
747
|
+
lines.push('');
|
|
748
|
+
}
|
|
749
|
+
if (ast.header.config) {
|
|
750
|
+
lines.push('## Config');
|
|
751
|
+
for (const [key, val] of Object.entries(ast.header.config)) {
|
|
752
|
+
lines.push(`- ${key}: ${val}`);
|
|
753
|
+
}
|
|
754
|
+
lines.push('');
|
|
755
|
+
}
|
|
756
|
+
if (ast.steps?.length) {
|
|
757
|
+
lines.push('## Steps');
|
|
758
|
+
serializeSteps(ast.steps, lines);
|
|
759
|
+
}
|
|
760
|
+
return lines.join('\n') + '\n';
|
|
761
|
+
}
|
|
762
|
+
function serializeSteps(steps, lines) {
|
|
763
|
+
for (const step of steps) {
|
|
764
|
+
const depth = step.step_id.split('.').length - 1;
|
|
765
|
+
const indent = ' '.repeat(depth);
|
|
766
|
+
let stepLine = `${indent}${step.step_id}. [${step.step_type}`;
|
|
767
|
+
if (step.step_type === 'subtask') {
|
|
768
|
+
const st = step;
|
|
769
|
+
if (st.retry !== undefined)
|
|
770
|
+
stepLine += ` retry=${st.retry}`;
|
|
771
|
+
if (st.adaptive)
|
|
772
|
+
stepLine += ' adaptive';
|
|
773
|
+
}
|
|
774
|
+
if (step.step_type === 'loop') {
|
|
775
|
+
const lt = step;
|
|
776
|
+
if (lt.max_iterations !== undefined)
|
|
777
|
+
stepLine += ` max_iterations=${lt.max_iterations}`;
|
|
778
|
+
}
|
|
779
|
+
stepLine += `] ${step.summary}`;
|
|
780
|
+
lines.push(stepLine);
|
|
781
|
+
const bodyIndent = ' '.repeat(depth + 1);
|
|
782
|
+
if (step.inputs?.length) {
|
|
783
|
+
lines.push(`${bodyIndent}- ← ${step.inputs.map(i => i.name).join(', ')}`);
|
|
784
|
+
}
|
|
785
|
+
if (step.outputs?.length) {
|
|
786
|
+
for (const o of step.outputs) {
|
|
787
|
+
let outLine = `${bodyIndent}+ → ${o.name}: ${o.type}`;
|
|
788
|
+
if (o.description)
|
|
789
|
+
outLine += ` # ${o.description}`;
|
|
790
|
+
lines.push(outLine);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
if (step.instruction) {
|
|
794
|
+
for (const iline of step.instruction.split('\n')) {
|
|
795
|
+
lines.push(`${bodyIndent}> ${iline}`);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
if ('children' in step && Array.isArray(step.children)) {
|
|
799
|
+
serializeSteps(step.children, lines);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|