smart-context-mcp 1.18.1 → 1.20.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/src/index.js CHANGED
@@ -4,8 +4,9 @@ import path from 'node:path';
4
4
  import ts from 'typescript';
5
5
  import { isBinaryBuffer } from './utils/fs.js';
6
6
  import { IGNORED_DIRS } from './config/ignored-paths.js';
7
+ import { getParser } from './parsers/registry.js';
7
8
 
8
- const INDEX_VERSION = 6;
9
+ const INDEX_VERSION = 7;
9
10
 
10
11
  const MAX_SIGNATURE_LEN = 200;
11
12
  const MAX_SNIPPET_LEN = 280;
@@ -231,31 +232,78 @@ const extractJsImportsExports = (sourceFile) => {
231
232
  // ---------------------------------------------------------------------------
232
233
 
233
234
  const PYTHON_SYMBOL_RE = /^(class|def|async\s+def)\s+(\w+)/;
235
+ const PYTHON_TYPE_ALIAS_RE = /^(\w+)\s*:\s*(?:TypeAlias|"TypeAlias")\s*=/;
236
+ const PYTHON_TYPEVAR_RE = /^(\w+)\s*=\s*(?:TypeVar|NewType|ParamSpec|TypeVarTuple)\s*\(/;
234
237
 
235
238
  const extractPySymbols = (content) => {
236
239
  const symbols = [];
237
240
  const lines = content.split('\n');
238
241
  let currentClass = null;
242
+ let currentClassIndent = -1;
243
+ let pendingDecorators = [];
239
244
 
240
245
  for (let i = 0; i < lines.length; i++) {
241
246
  const trimmed = lines[i].trimStart();
242
247
  const indent = lines[i].length - trimmed.length;
248
+
249
+ if (trimmed.startsWith('@')) {
250
+ pendingDecorators.push(trimmed.slice(1).split('(')[0].trim());
251
+ continue;
252
+ }
253
+
254
+ if (currentClass !== null && trimmed !== '' && indent <= currentClassIndent) {
255
+ currentClass = null;
256
+ currentClassIndent = -1;
257
+ }
258
+
259
+ const aliasMatch = PYTHON_TYPE_ALIAS_RE.exec(trimmed) ?? PYTHON_TYPEVAR_RE.exec(trimmed);
260
+ if (aliasMatch) {
261
+ symbols.push({
262
+ name: aliasMatch[1],
263
+ kind: 'type',
264
+ line: i + 1,
265
+ signature: trimSignature(trimmed),
266
+ });
267
+ pendingDecorators = [];
268
+ continue;
269
+ }
270
+
243
271
  const match = PYTHON_SYMBOL_RE.exec(trimmed);
244
- if (!match) continue;
272
+ if (!match) {
273
+ if (trimmed !== '') pendingDecorators = [];
274
+ continue;
275
+ }
245
276
 
246
277
  const keyword = match[1].replace(/\s+/g, ' ');
247
278
  const name = match[2];
248
279
  const line = i + 1;
249
280
  const signature = trimSignature(trimmed.replace(/:$/, ''));
281
+ const decorators = pendingDecorators.length > 0 ? [...pendingDecorators] : undefined;
282
+ pendingDecorators = [];
250
283
 
251
284
  if (keyword === 'class') {
252
285
  currentClass = name;
253
- symbols.push({ name, kind: 'class', line, signature });
254
- } else if (indent > 0 && currentClass) {
255
- symbols.push({ name, kind: 'method', line, parent: currentClass, signature });
286
+ currentClassIndent = indent;
287
+ symbols.push({ name, kind: 'class', line, signature, ...(decorators && { decorators }) });
288
+ } else if (currentClass !== null && indent > currentClassIndent) {
289
+ symbols.push({
290
+ name,
291
+ kind: keyword === 'async def' ? 'async-method' : 'method',
292
+ line,
293
+ parent: currentClass,
294
+ signature,
295
+ ...(decorators && { decorators }),
296
+ });
256
297
  } else {
257
298
  currentClass = null;
258
- symbols.push({ name, kind: 'function', line, signature });
299
+ currentClassIndent = -1;
300
+ symbols.push({
301
+ name,
302
+ kind: keyword === 'async def' ? 'async-function' : 'function',
303
+ line,
304
+ signature,
305
+ ...(decorators && { decorators }),
306
+ });
259
307
  }
260
308
  }
261
309
 
@@ -277,8 +325,10 @@ const extractPyImports = (content) => {
277
325
  // Go extraction
278
326
  // ---------------------------------------------------------------------------
279
327
 
280
- const GO_FUNC_RE = /^func\s+(?:\([\w\s*]+\)\s+)?(\w+)\s*\(/;
281
- const GO_TYPE_RE = /^type\s+(\w+)\s+/;
328
+ const GO_METHOD_RE = /^func\s+\(\s*\w+\s+\*?(\w+)\s*\)\s+(\w+)\s*\(/;
329
+ const GO_FUNC_RE = /^func\s+(\w+)\s*\(/;
330
+ const GO_TYPE_RE = /^type\s+(\w+)\s+(struct|interface|\w+)/;
331
+ const GO_CONST_RE = /^(?:const|var)\s+(\w+)\s*(?:=|\s+\w)/;
282
332
 
283
333
  const extractGoSymbols = (content) => {
284
334
  const symbols = [];
@@ -286,6 +336,17 @@ const extractGoSymbols = (content) => {
286
336
 
287
337
  for (let i = 0; i < lines.length; i++) {
288
338
  const trimmed = lines[i].trimStart();
339
+ const methodMatch = GO_METHOD_RE.exec(trimmed);
340
+ if (methodMatch) {
341
+ symbols.push({
342
+ name: methodMatch[2],
343
+ kind: 'method',
344
+ line: i + 1,
345
+ parent: methodMatch[1],
346
+ signature: trimSignature(trimmed),
347
+ });
348
+ continue;
349
+ }
289
350
  const funcMatch = GO_FUNC_RE.exec(trimmed);
290
351
  if (funcMatch) {
291
352
  symbols.push({ name: funcMatch[1], kind: 'function', line: i + 1, signature: trimSignature(trimmed) });
@@ -293,7 +354,14 @@ const extractGoSymbols = (content) => {
293
354
  }
294
355
  const typeMatch = GO_TYPE_RE.exec(trimmed);
295
356
  if (typeMatch) {
296
- symbols.push({ name: typeMatch[1], kind: 'type', line: i + 1, signature: trimSignature(trimmed) });
357
+ const kind = typeMatch[2] === 'interface' ? 'interface' : 'type';
358
+ symbols.push({ name: typeMatch[1], kind, line: i + 1, signature: trimSignature(trimmed) });
359
+ continue;
360
+ }
361
+ const constMatch = GO_CONST_RE.exec(trimmed);
362
+ if (constMatch) {
363
+ const kind = trimmed.startsWith('const') ? 'const' : 'var';
364
+ symbols.push({ name: constMatch[1], kind, line: i + 1, signature: trimSignature(trimmed) });
297
365
  }
298
366
  }
299
367
 
@@ -677,6 +745,20 @@ export const extractAdrSymbols = (content, fullPath) => {
677
745
  const extractFileInfo = (fullPath, content) => {
678
746
  const ext = path.extname(fullPath).toLowerCase();
679
747
 
748
+ const pluggable = getParser(ext);
749
+ if (pluggable) {
750
+ try {
751
+ const result = pluggable({ fullPath, content }) ?? {};
752
+ return {
753
+ symbols: enrichSymbolsWithSnippets(content, result.symbols ?? []),
754
+ imports: result.imports ?? [],
755
+ exports: result.exports ?? [],
756
+ };
757
+ } catch {
758
+ return { symbols: [], imports: [], exports: [] };
759
+ }
760
+ }
761
+
680
762
  let info;
681
763
 
682
764
  if (['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'].includes(ext)) {
@@ -16,6 +16,7 @@ import {
16
16
  export const DEFAULT_ORCHESTRATION_EVENT = 'session_end';
17
17
  export const DEFAULT_START_MAX_TOKENS = 350;
18
18
  export const DEFAULT_END_MAX_TOKENS = 350;
19
+ const SIMPLE_TASK_SKIP_MAX_LENGTH = 40;
19
20
 
20
21
  const buildContextLines = (startResult) => {
21
22
  const context = buildOperationalContextLines(startResult, {
@@ -53,11 +54,31 @@ const buildFreshSessionUpdate = (prompt) => {
53
54
  };
54
55
  };
55
56
 
57
+ const buildSimpleTaskStartResult = (prompt) => ({
58
+ phase: 'start',
59
+ skipSmartTurn: true,
60
+ continuity: {
61
+ state: 'simple_task_skip',
62
+ shouldReuseContext: false,
63
+ reason: 'Simple task heuristic skipped persisted continuity setup to avoid overhead.',
64
+ },
65
+ recommendedPath: {
66
+ phase: 'start',
67
+ mode: 'simple_task_skip',
68
+ nextTools: ['smart_read', 'smart_search'],
69
+ nextActions: [],
70
+ next: 'smart_read: Skip smart_turn for this simple task and use lightweight read/search directly.',
71
+ },
72
+ message: 'Simple task heuristic skipped smart_turn(start); use lightweight read/search flow unless the task grows.',
73
+ ...(prompt ? { promptPreview: truncate(prompt, MAX_FOCUS_LENGTH) } : {}),
74
+ });
75
+
56
76
  const ensureIsolatedSession = async ({
57
77
  prompt,
58
78
  sessionId,
59
79
  startResult,
60
80
  startMaxTokens = DEFAULT_START_MAX_TOKENS,
81
+ tokenBudget,
61
82
  summaryTool = smartSummary,
62
83
  startTurn = smartTurn,
63
84
  }) => {
@@ -96,6 +117,7 @@ const ensureIsolatedSession = async ({
96
117
  prompt,
97
118
  ensureSession: false,
98
119
  maxTokens: startMaxTokens,
120
+ tokenBudget,
99
121
  });
100
122
 
101
123
  return {
@@ -112,11 +134,23 @@ export const resolveManagedStart = async ({
112
134
  ensureSession = true,
113
135
  allowIsolation = false,
114
136
  startMaxTokens = DEFAULT_START_MAX_TOKENS,
137
+ tokenBudget,
115
138
  startTurn = smartTurn,
116
139
  summaryTool = smartSummary,
117
140
  enableFastPath = true,
118
141
  }) => {
119
- const simpleTask = enableFastPath && isSimpleTask(prompt);
142
+ const simpleTask = enableFastPath && isSimpleTask(prompt) && normalizeWhitespace(prompt).length <= SIMPLE_TASK_SKIP_MAX_LENGTH;
143
+
144
+ if (simpleTask && !preparedStartResult && !sessionId) {
145
+ const startResult = buildSimpleTaskStartResult(prompt);
146
+ return {
147
+ startResult,
148
+ isolated: false,
149
+ previousSessionId: null,
150
+ autoStarted: false,
151
+ fastPath: true,
152
+ };
153
+ }
120
154
 
121
155
  const startResult = preparedStartResult ?? await startTurn({
122
156
  phase: 'start',
@@ -124,6 +158,7 @@ export const resolveManagedStart = async ({
124
158
  prompt,
125
159
  ensureSession: simpleTask ? false : ensureSession,
126
160
  maxTokens: startMaxTokens,
161
+ tokenBudget,
127
162
  });
128
163
 
129
164
  if (!allowIsolation || simpleTask) {
@@ -141,6 +176,7 @@ export const resolveManagedStart = async ({
141
176
  sessionId,
142
177
  startResult,
143
178
  startMaxTokens,
179
+ tokenBudget,
144
180
  summaryTool,
145
181
  startTurn,
146
182
  });
@@ -0,0 +1,26 @@
1
+ const parsers = new Map();
2
+ const importExtractors = new Map();
3
+
4
+ export const registerParser = (extension, parser) => {
5
+ if (!extension || typeof parser !== 'function') return;
6
+ parsers.set(extension.toLowerCase(), parser);
7
+ };
8
+
9
+ export const registerImportExtractor = (extension, extractor) => {
10
+ if (!extension || typeof extractor !== 'function') return;
11
+ importExtractors.set(extension.toLowerCase(), extractor);
12
+ };
13
+
14
+ export const getParser = (extension) => parsers.get(extension?.toLowerCase()) ?? null;
15
+
16
+ export const getImportExtractor = (extension) => importExtractors.get(extension?.toLowerCase()) ?? null;
17
+
18
+ export const listRegisteredExtensions = () => ({
19
+ symbols: [...parsers.keys()],
20
+ imports: [...importExtractors.keys()],
21
+ });
22
+
23
+ export const clearRegistry = () => {
24
+ parsers.clear();
25
+ importExtractors.clear();
26
+ };
@@ -0,0 +1,17 @@
1
+ name: debug-flake
2
+ description: Recover last red test, gather curated debug context, list affected tests
3
+ defaults: {}
4
+ stopOnFail: false
5
+ steps:
6
+ - tool: smart_test
7
+ label: lastFailure
8
+ args:
9
+ action: last_failure
10
+ - tool: smart_context
11
+ args:
12
+ task: "Debug last failing test"
13
+ intent: debug
14
+ maxTokens: 8000
15
+ - tool: smart_test
16
+ args:
17
+ action: affected
@@ -0,0 +1,16 @@
1
+ name: doc-sync
2
+ description: Surface ADR/spec sections then build curated docs context
3
+ defaults:
4
+ topic: "architecture"
5
+ stopOnFail: false
6
+ steps:
7
+ - tool: smart_search
8
+ args:
9
+ query: "{{args.topic}}"
10
+ kinds:
11
+ - adr
12
+ - adr-section
13
+ - tool: smart_context
14
+ args:
15
+ task: "Sync docs for {{args.topic}}"
16
+ intent: docs
@@ -0,0 +1,20 @@
1
+ name: preflight-merge
2
+ description: Review preflight + affected tests + milestone checkpoint
3
+ defaults:
4
+ ref: HEAD
5
+ stopOnFail: true
6
+ steps:
7
+ - tool: smart_review
8
+ label: review
9
+ args:
10
+ ref: "{{args.ref}}"
11
+ includeBlame: false
12
+ - tool: smart_test
13
+ label: affected
14
+ args:
15
+ action: affected
16
+ diff: "{{args.ref}}"
17
+ - tool: smart_turn
18
+ args:
19
+ phase: end
20
+ event: milestone
@@ -0,0 +1,14 @@
1
+ name: ramp-up
2
+ description: First contact with the project — status, doctor, and ADR overview
3
+ defaults: {}
4
+ stopOnFail: false
5
+ steps:
6
+ - tool: smart_status
7
+ args: {}
8
+ - tool: smart_doctor
9
+ args: {}
10
+ - tool: smart_search
11
+ args:
12
+ query: "architecture decisions"
13
+ kinds:
14
+ - adr
@@ -0,0 +1,18 @@
1
+ name: refactor-safe
2
+ description: Curated context + outline reads + affected tests, ending with checkpoint
3
+ defaults:
4
+ task: "Refactor target"
5
+ stopOnFail: true
6
+ steps:
7
+ - tool: smart_context
8
+ args:
9
+ task: "{{args.task}}"
10
+ intent: refactor
11
+ detail: balanced
12
+ - tool: smart_test
13
+ args:
14
+ action: affected
15
+ - tool: smart_turn
16
+ args:
17
+ phase: end
18
+ event: milestone
@@ -0,0 +1,123 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { projectRoot } from '../utils/runtime-config.js';
5
+ import { parseYamlMini } from './yaml-mini.js';
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+ const BUILTIN_DIR = path.join(__dirname, 'builtin');
9
+ const PROJECT_DIR_NAME = '.devctx/playbooks';
10
+
11
+ const SUPPORTED_EXT = new Set(['.yaml', '.yml', '.json']);
12
+ const SAFE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
13
+
14
+ const listFilesIn = (dir) => {
15
+ if (!fs.existsSync(dir)) return [];
16
+ return fs.readdirSync(dir, { withFileTypes: true })
17
+ .filter((entry) => entry.isFile() && SUPPORTED_EXT.has(path.extname(entry.name).toLowerCase()))
18
+ .map((entry) => path.join(dir, entry.name));
19
+ };
20
+
21
+ const parseContent = (content, ext) => {
22
+ if (ext === '.json') return JSON.parse(content);
23
+ return parseYamlMini(content);
24
+ };
25
+
26
+ const nameFromFile = (file) => {
27
+ const base = path.basename(file).replace(/\.(ya?ml|json)$/i, '');
28
+ return base;
29
+ };
30
+
31
+ const validatePlaybook = (raw, sourceFile) => {
32
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
33
+ throw new Error(`Playbook ${sourceFile}: root must be a mapping`);
34
+ }
35
+ if (!raw.name || typeof raw.name !== 'string' || !SAFE_NAME_RE.test(raw.name)) {
36
+ throw new Error(`Playbook ${sourceFile}: missing/invalid 'name'`);
37
+ }
38
+ if (!Array.isArray(raw.steps) || raw.steps.length === 0) {
39
+ throw new Error(`Playbook ${raw.name}: 'steps' must be a non-empty array`);
40
+ }
41
+ for (const [i, step] of raw.steps.entries()) {
42
+ if (!step || typeof step !== 'object') {
43
+ throw new Error(`Playbook ${raw.name}: step ${i} must be an object`);
44
+ }
45
+ if (typeof step.tool !== 'string' || step.tool.length === 0) {
46
+ throw new Error(`Playbook ${raw.name}: step ${i} missing 'tool'`);
47
+ }
48
+ if (step.args !== undefined && step.args !== null && (typeof step.args !== 'object' || Array.isArray(step.args))) {
49
+ throw new Error(`Playbook ${raw.name}: step ${i} 'args' must be an object`);
50
+ }
51
+ }
52
+ return {
53
+ name: raw.name,
54
+ description: typeof raw.description === 'string' ? raw.description : '',
55
+ defaults: raw.defaults && typeof raw.defaults === 'object' && !Array.isArray(raw.defaults) ? raw.defaults : {},
56
+ stopOnFail: raw.stopOnFail !== false,
57
+ steps: raw.steps.map((step) => ({
58
+ tool: step.tool,
59
+ args: (step.args && typeof step.args === 'object' && !Array.isArray(step.args)) ? step.args : {},
60
+ when: typeof step.when === 'string' ? step.when : null,
61
+ label: typeof step.label === 'string' ? step.label : null,
62
+ })),
63
+ source: sourceFile,
64
+ };
65
+ };
66
+
67
+ const collectFromDir = (dir, source) => {
68
+ const out = new Map();
69
+ for (const file of listFilesIn(dir)) {
70
+ try {
71
+ const ext = path.extname(file).toLowerCase();
72
+ const raw = parseContent(fs.readFileSync(file, 'utf-8'), ext);
73
+ const playbook = validatePlaybook(raw, source);
74
+ if (playbook.name !== nameFromFile(file)) {
75
+ playbook.fileName = nameFromFile(file);
76
+ }
77
+ out.set(playbook.name, playbook);
78
+ } catch (err) {
79
+ out.set(`__error__${path.basename(file)}`, { error: err.message, source, file });
80
+ }
81
+ }
82
+ return out;
83
+ };
84
+
85
+ export const loadPlaybooks = ({ root = projectRoot } = {}) => {
86
+ const builtin = collectFromDir(BUILTIN_DIR, 'builtin');
87
+ const projectDir = path.join(root, PROJECT_DIR_NAME);
88
+ const project = collectFromDir(projectDir, 'project');
89
+
90
+ const merged = new Map(builtin);
91
+ for (const [name, pb] of project) merged.set(name, pb);
92
+
93
+ return {
94
+ playbooks: merged,
95
+ sources: {
96
+ builtinDir: BUILTIN_DIR,
97
+ projectDir,
98
+ projectExists: fs.existsSync(projectDir),
99
+ },
100
+ };
101
+ };
102
+
103
+ export const listPlaybookSummaries = ({ root = projectRoot } = {}) => {
104
+ const { playbooks, sources } = loadPlaybooks({ root });
105
+ const summaries = [];
106
+ const errors = [];
107
+ for (const [, value] of playbooks) {
108
+ if (value.error) {
109
+ errors.push({ file: value.file, error: value.error, source: value.source });
110
+ continue;
111
+ }
112
+ summaries.push({
113
+ name: value.name,
114
+ description: value.description,
115
+ source: value.source,
116
+ steps: value.steps.length,
117
+ defaults: value.defaults,
118
+ });
119
+ }
120
+ return { summaries, errors, sources };
121
+ };
122
+
123
+ export const _internal = { validatePlaybook, parseContent, nameFromFile };
@@ -0,0 +1,182 @@
1
+ import { smartContext } from '../tools/smart-context.js';
2
+ import { smartSearch } from '../tools/smart-search.js';
3
+ import { smartRead } from '../tools/smart-read.js';
4
+ import { smartReadBatch } from '../tools/smart-read-batch.js';
5
+ import { smartTest } from '../tools/smart-test.js';
6
+ import { smartReview } from '../tools/smart-review.js';
7
+ import { smartShell } from '../tools/smart-shell.js';
8
+ import { smartSummary } from '../tools/smart-summary.js';
9
+ import { smartTurn } from '../tools/smart-turn.js';
10
+ import { smartStatus } from '../tools/smart-status.js';
11
+ import { smartDoctor } from '../tools/smart-doctor.js';
12
+ import { smartMetrics } from '../tools/smart-metrics.js';
13
+
14
+ const TOOL_REGISTRY = {
15
+ smart_context: smartContext,
16
+ smart_search: smartSearch,
17
+ smart_read: smartRead,
18
+ smart_read_batch: smartReadBatch,
19
+ smart_test: smartTest,
20
+ smart_review: smartReview,
21
+ smart_shell: smartShell,
22
+ smart_summary: smartSummary,
23
+ smart_turn: smartTurn,
24
+ smart_status: smartStatus,
25
+ smart_doctor: smartDoctor,
26
+ smart_metrics: smartMetrics,
27
+ };
28
+
29
+ const TEMPLATE_RE = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s*\}\}/g;
30
+
31
+ const resolvePath = (obj, dottedKey) => {
32
+ const segments = dottedKey.split('.');
33
+ let current = obj;
34
+ for (const seg of segments) {
35
+ if (current === null || current === undefined) return undefined;
36
+ current = current[seg];
37
+ }
38
+ return current;
39
+ };
40
+
41
+ export const interpolate = (value, scope) => {
42
+ if (typeof value === 'string') {
43
+ const full = value.match(/^\{\{\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s*\}\}$/);
44
+ if (full) {
45
+ const resolved = resolvePath(scope, full[1]);
46
+ return resolved === undefined ? value : resolved;
47
+ }
48
+ return value.replace(TEMPLATE_RE, (match, key) => {
49
+ const resolved = resolvePath(scope, key);
50
+ if (resolved === undefined || resolved === null) return '';
51
+ if (typeof resolved === 'object') return JSON.stringify(resolved);
52
+ return String(resolved);
53
+ });
54
+ }
55
+ if (Array.isArray(value)) return value.map((item) => interpolate(item, scope));
56
+ if (value && typeof value === 'object') {
57
+ const out = {};
58
+ for (const [k, v] of Object.entries(value)) out[k] = interpolate(v, scope);
59
+ return out;
60
+ }
61
+ return value;
62
+ };
63
+
64
+ const evaluateWhen = (whenExpr, scope) => {
65
+ if (!whenExpr) return true;
66
+ const expanded = interpolate(whenExpr, scope);
67
+ if (typeof expanded === 'boolean') return expanded;
68
+ if (expanded === null || expanded === undefined) return false;
69
+ if (typeof expanded === 'string') {
70
+ const lower = expanded.trim().toLowerCase();
71
+ if (lower === 'false' || lower === '0' || lower === '' || lower === 'no') return false;
72
+ return true;
73
+ }
74
+ return Boolean(expanded);
75
+ };
76
+
77
+ export const runPlaybook = async (playbook, args = {}, { dryRun = false, toolRegistry = TOOL_REGISTRY } = {}) => {
78
+ const scope = { args: { ...playbook.defaults, ...args } };
79
+ const steps = [];
80
+ const startedAt = Date.now();
81
+ let failed = false;
82
+
83
+ for (const [i, rawStep] of playbook.steps.entries()) {
84
+ const stepStart = Date.now();
85
+ const skipped = !evaluateWhen(rawStep.when, scope);
86
+
87
+ if (skipped) {
88
+ steps.push({
89
+ index: i,
90
+ tool: rawStep.tool,
91
+ label: rawStep.label,
92
+ args: rawStep.args,
93
+ ok: true,
94
+ skipped: true,
95
+ result: null,
96
+ elapsedMs: 0,
97
+ });
98
+ continue;
99
+ }
100
+
101
+ const tool = toolRegistry[rawStep.tool];
102
+ if (!tool) {
103
+ steps.push({
104
+ index: i,
105
+ tool: rawStep.tool,
106
+ label: rawStep.label,
107
+ args: rawStep.args,
108
+ ok: false,
109
+ skipped: false,
110
+ error: `Tool not allowed in playbooks: ${rawStep.tool}`,
111
+ elapsedMs: 0,
112
+ });
113
+ failed = true;
114
+ if (playbook.stopOnFail) break;
115
+ continue;
116
+ }
117
+
118
+ const resolvedArgs = interpolate(rawStep.args, scope);
119
+
120
+ if (dryRun) {
121
+ steps.push({
122
+ index: i,
123
+ tool: rawStep.tool,
124
+ label: rawStep.label,
125
+ args: resolvedArgs,
126
+ ok: true,
127
+ skipped: false,
128
+ dryRun: true,
129
+ elapsedMs: 0,
130
+ });
131
+ continue;
132
+ }
133
+
134
+ try {
135
+ const result = await tool(resolvedArgs);
136
+ const ok = result?.success !== false;
137
+ steps.push({
138
+ index: i,
139
+ tool: rawStep.tool,
140
+ label: rawStep.label,
141
+ args: resolvedArgs,
142
+ ok,
143
+ skipped: false,
144
+ result,
145
+ elapsedMs: Date.now() - stepStart,
146
+ });
147
+ if (!ok) {
148
+ failed = true;
149
+ if (playbook.stopOnFail) break;
150
+ }
151
+ scope[`step${i}`] = result;
152
+ if (rawStep.label) scope[rawStep.label] = result;
153
+ } catch (err) {
154
+ steps.push({
155
+ index: i,
156
+ tool: rawStep.tool,
157
+ label: rawStep.label,
158
+ args: resolvedArgs,
159
+ ok: false,
160
+ skipped: false,
161
+ error: err?.message ?? String(err),
162
+ elapsedMs: Date.now() - stepStart,
163
+ });
164
+ failed = true;
165
+ if (playbook.stopOnFail) break;
166
+ }
167
+ }
168
+
169
+ return {
170
+ name: playbook.name,
171
+ description: playbook.description,
172
+ success: !failed,
173
+ steps,
174
+ stepCount: playbook.steps.length,
175
+ executed: steps.filter((s) => !s.skipped && !s.dryRun).length,
176
+ skipped: steps.filter((s) => s.skipped).length,
177
+ totalElapsedMs: Date.now() - startedAt,
178
+ dryRun: !!dryRun,
179
+ };
180
+ };
181
+
182
+ export const _internal = { TOOL_REGISTRY, resolvePath, evaluateWhen };