yaml-flow 5.2.6 → 5.3.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.
Files changed (84) hide show
  1. package/README.md +6 -6
  2. package/board-livecards-server-runtime.js +260 -35
  3. package/browser/board-livegraph-engine.js +61 -33
  4. package/browser/board-livegraph-engine.js.map +1 -1
  5. package/browser/card-compute.js +18 -18
  6. package/browser/live-cards.js +317 -156
  7. package/browser/live-cards.schema.json +15 -10
  8. package/dist/board-livegraph-runtime/index.cjs +61 -33
  9. package/dist/board-livegraph-runtime/index.cjs.map +1 -1
  10. package/dist/board-livegraph-runtime/index.d.cts +1 -1
  11. package/dist/board-livegraph-runtime/index.d.ts +1 -1
  12. package/dist/board-livegraph-runtime/index.js +61 -33
  13. package/dist/board-livegraph-runtime/index.js.map +1 -1
  14. package/dist/card-compute/index.cjs +101 -39
  15. package/dist/card-compute/index.cjs.map +1 -1
  16. package/dist/card-compute/index.d.cts +13 -8
  17. package/dist/card-compute/index.d.ts +13 -8
  18. package/dist/card-compute/index.js +101 -39
  19. package/dist/card-compute/index.js.map +1 -1
  20. package/dist/cli/board-live-cards-cli.cjs +7205 -202
  21. package/dist/cli/board-live-cards-cli.cjs.map +1 -1
  22. package/dist/cli/board-live-cards-cli.d.cts +6 -6
  23. package/dist/cli/board-live-cards-cli.d.ts +6 -6
  24. package/dist/cli/board-live-cards-cli.js +7204 -202
  25. package/dist/cli/board-live-cards-cli.js.map +1 -1
  26. package/dist/continuous-event-graph/index.cjs +59 -31
  27. package/dist/continuous-event-graph/index.cjs.map +1 -1
  28. package/dist/continuous-event-graph/index.d.cts +2 -2
  29. package/dist/continuous-event-graph/index.d.ts +2 -2
  30. package/dist/continuous-event-graph/index.js +59 -31
  31. package/dist/continuous-event-graph/index.js.map +1 -1
  32. package/dist/index.cjs +126 -54
  33. package/dist/index.cjs.map +1 -1
  34. package/dist/index.d.cts +1 -1
  35. package/dist/index.d.ts +1 -1
  36. package/dist/index.js +126 -54
  37. package/dist/index.js.map +1 -1
  38. package/dist/{live-cards-bridge-CeNxiVcm.d.ts → live-cards-bridge-EQjytzI_.d.ts} +10 -5
  39. package/dist/{live-cards-bridge-z_rJCSbi.d.cts → live-cards-bridge-x5XREkXm.d.cts} +10 -5
  40. package/examples/browser/boards/portfolio-tracker/cards/holdings-table.json +1 -1
  41. package/examples/browser/boards/portfolio-tracker/cards/portfolio-form.json +1 -1
  42. package/examples/browser/boards/portfolio-tracker/cards/portfolio-risk-assessment.json +1 -1
  43. package/examples/browser/boards/portfolio-tracker/cards/portfolio-value.json +1 -1
  44. package/examples/browser/boards/portfolio-tracker/cards/price-fetch.json +2 -2
  45. package/examples/browser/boards/portfolio-tracker/cards/rebalancing-strategy.json +1 -1
  46. package/examples/browser/boards/portfolio-tracker/portfolio-tracker.js +10 -10
  47. package/examples/cli/step-machine-cli/portfolio-tracker/cards/holdings-table.json +1 -1
  48. package/examples/cli/step-machine-cli/portfolio-tracker/cards/portfolio-form.json +1 -1
  49. package/examples/cli/step-machine-cli/portfolio-tracker/cards/portfolio-value.json +1 -1
  50. package/examples/cli/step-machine-cli/portfolio-tracker/cards/price-fetch.json +2 -2
  51. package/examples/cli/step-machine-cli/portfolio-tracker/handlers/add-cards-cli.js +1 -1
  52. package/examples/cli/step-machine-cli/portfolio-tracker/handlers/update-holdings-cli.js +1 -1
  53. package/examples/cli/step-machine-cli/portfolio-tracker/portfolio-tracker.flow.yaml +1 -1
  54. package/examples/example-board/agent-instructions-cardlayout.md +29 -1
  55. package/examples/example-board/agent-instructions.md +271 -45
  56. package/examples/example-board/cards/card-concentration.json +8 -5
  57. package/examples/example-board/cards/card-market-prices.json +14 -9
  58. package/examples/example-board/cards/card-my-identity.json +28 -0
  59. package/examples/example-board/cards/card-portfolio-value.json +1 -1
  60. package/examples/example-board/cards/card-portfolio.json +1 -1
  61. package/examples/example-board/cards/card-rebalance-impact.json +65 -0
  62. package/examples/example-board/cards/card-rebalance-sim.json +67 -0
  63. package/examples/example-board/demo-chat-handler.js +2 -1
  64. package/examples/example-board/demo-server-config.json +6 -1
  65. package/examples/example-board/demo-server.js +91 -8
  66. package/examples/example-board/demo-shell-browser.html +6 -6
  67. package/examples/example-board/demo-shell-with-server.html +4 -4
  68. package/examples/example-board/demo-task-executor.js +457 -246
  69. package/examples/example-board/scripts/copilot_wrapper.bat +16 -0
  70. package/examples/example-board/scripts/copilot_wrapper_helper.ps1 +19 -10
  71. package/examples/example-board/scripts/workiq_wrapper.mjs +66 -0
  72. package/examples/npm-libs/continuous-event-graph/live-cards-board.ts +5 -5
  73. package/examples/npm-libs/continuous-event-graph/soc-incident-board.ts +3 -3
  74. package/examples/npm-libs/event-graph/research-pipeline.ts +5 -5
  75. package/examples/npm-libs/graph-of-graphs/multi-stage-etl.ts +9 -9
  76. package/examples/step-machine-cli/portfolio-tracker/cards/holdings-table.json +1 -1
  77. package/examples/step-machine-cli/portfolio-tracker/cards/portfolio-form.json +1 -1
  78. package/examples/step-machine-cli/portfolio-tracker/cards/portfolio-value.json +1 -1
  79. package/examples/step-machine-cli/portfolio-tracker/cards/price-fetch.json +3 -3
  80. package/examples/step-machine-cli/portfolio-tracker/handlers/add-cards-cli.js +1 -1
  81. package/examples/step-machine-cli/portfolio-tracker/handlers/update-holdings-cli.js +1 -1
  82. package/examples/step-machine-cli/portfolio-tracker/portfolio-tracker.flow.yaml +1 -1
  83. package/package.json +2 -2
  84. package/schema/live-cards.schema.json +15 -10
@@ -3,35 +3,54 @@
3
3
  /**
4
4
  * demo-task-executor.js — Simple mock source executor for example-board.
5
5
  *
6
- * Protocol (invoked by board-live-cards-cli):
7
- * node demo-task-executor.js run-source-fetch --in <source.json> --out <result.json> [--err <error.txt>]
6
+ * Subcommands:
7
+ * run-source-fetch fetch data for one source entry
8
+ * describe-capabilities — print supported source kinds + schemas to stdout (JSON)
8
9
  *
9
- * Expected source definition (--in payload):
10
+ * CLI args:
11
+ * --in <source.json> Required. Path to a temp JSON file containing the source definition.
12
+ * --out <result.json> Required. Path where this executor must write its JSON result.
13
+ * --err <error.txt> Optional. Path where this executor writes an error message on failure.
14
+ * --extra <base64json> Optional. Base64-encoded JSON with board topology context
15
+ * (baked into .task-executor at board init time, passed blindly by the CLI).
16
+ *
17
+ * --in payload (source definition):
10
18
  * {
11
- * "bindTo": "...",
12
- * "outputFile": "...",
13
- * // custom fields authored on the source entry (e.g. mock, copilot, http, prompt_template, etc.)
14
- * "cwd": "<card directory>",
15
- * "boardDir": "<board runtime directory>",
16
- * "_requires": { }, // upstream token data (from card requires[])
17
- * "_sourcesData": { }, // already-fetched sources on this card
18
- * "_computed_values": { } // computed_values from the card's compute stage
19
+ * "bindTo": "token_name",
20
+ * "outputFile": "relative/path.json",
21
+ * "cwd": "<card directory>", // injected by CLI
22
+ * "boardDir":"<board runtime directory>", // injected by CLI
23
+ * "_projections": { "refKey": <resolvedValue> }, // named projections from card_data/requires,
24
+ * // declared in source_defs[].projections and resolved
25
+ * // by the engine before invoking the executor
26
+ * // ...plus any custom fields authored on the source entry (bindTo, outputFile, projections, etc.)
19
27
  * }
20
28
  *
21
- * Supported source kinds (based on custom fields):
29
+ * --extra (decoded):
30
+ * {
31
+ * "boardSetupRoot": "<abs path>", // board root (parent of runtime/, surface/, runtime-out/)
32
+ * "boardId": "<board id>", // e.g. "default"
33
+ * "boardRuntimeDir": "<relative>", // e.g. "runtime"
34
+ * "runtimeStatusDir": "<relative>", // e.g. "runtime-out"
35
+ * "cardsDir": "<relative>" // e.g. "surface/tmp-cards"
36
+ * }
37
+ *
38
+ * Supported source kinds (based on custom fields in --in):
22
39
  * - { mock: "key" } → look up key in MOCK_DB (hardcoded below)
23
40
  * - { copilot: { prompt_template, args? } } → call Copilot CLI with interpolated prompt
24
41
  * - { prompt_template: "..." } → shorthand copilot call (top-level template)
25
- * - { http: { url, method?, headers?, args? }, tickersFrom? } HTTP fetch (Node 18+ fetch)
26
- * - { chartApi: { url, headers? }, tickersFrom } → Yahoo Finance chart API, one request per ticker;
27
- * returns { quoteResponse: { result: [...] } } compatible with the quote API shape
42
+ * - { workiq: { query_template, args? } } call WorkIQ (M365 Copilot) with interpolated query
43
+ * - { "url": { url, method?, headers?, args?, cacheTimeout? }, tickersFrom? }
44
+ * single URL fetch via curl with {{key}} interpolation from _projections
45
+ * - { "url-list": { method?, headers?, cacheTimeout? } }
46
+ * → fan-out over _projections.url_list (string[]); returns array of responses.
47
+ * Build url_list in projections: e.g. `requires.holdings.ticker.('https://host/' & $ & '?q=1')`
48
+ * - { chartApi: { url, headers? }, tickersFrom } → removed; use url-list instead
49
+ * prefer url-list for new sources
28
50
  * A real executor can also handle: graphapi, teams, mail, incidentdb, script, etc.
29
51
  *
30
- * http / chartApi source notes:
31
- * - URL supports {{key}} interpolation (http) or {{ticker}} (chartApi)
32
- * - tickersFrom: "tokenName.fieldName" extracts tickers from a _requires array
33
- * - http and chartApi results are cached in os.tmpdir()/demo-executor-cache/ for 1 hour
34
- * so Yahoo Finance is not hammered on every card refresh during demos
52
+ * url / url-list notes:
53
+ * - Results cached in os.tmpdir()/demo-executor-cache/ per URL (default 1 hour, override via cacheTimeout)
35
54
  */
36
55
 
37
56
  import fs from 'node:fs';
@@ -62,7 +81,7 @@ const MOCK_DB = {
62
81
  };
63
82
 
64
83
  // ---------------------------------------------------------------------------
65
- // Simple 1-hour file cache for HTTP / chartApi results.
84
+ // Simple file cache for url / url-list results.
66
85
  // Stored in os.tmpdir()/demo-executor-cache/<hash>.json
67
86
  // ---------------------------------------------------------------------------
68
87
  const CACHE_DIR = path.join(os.tmpdir(), 'demo-executor-cache');
@@ -72,17 +91,32 @@ function cacheKey(str) {
72
91
  return crypto.createHash('sha1').update(str).digest('hex');
73
92
  }
74
93
 
75
- function readCache(key) {
94
+ function readCache(key, ttlMs = CACHE_TTL_MS) {
76
95
  const file = path.join(CACHE_DIR, `${key}.json`);
77
96
  try {
78
97
  const stat = fs.statSync(file);
79
- if (Date.now() - stat.mtimeMs < CACHE_TTL_MS) {
98
+ if (Date.now() - stat.mtimeMs < ttlMs) {
80
99
  return JSON.parse(fs.readFileSync(file, 'utf-8'));
81
100
  }
82
101
  } catch {}
83
102
  return null;
84
103
  }
85
104
 
105
+ // Shared single-URL fetch helper used by both url and url-list.
106
+ // cacheTimeoutSec: override TTL in seconds (null → use CACHE_TTL_MS default).
107
+ function doFetchApi(url, method, headers, cacheTimeoutSec, errFile) {
108
+ const ttlMs = cacheTimeoutSec != null ? cacheTimeoutSec * 1000 : CACHE_TTL_MS;
109
+ const k = cacheKey(`url:${method}:${url}`);
110
+ const cached = readCache(k, ttlMs);
111
+ if (cached) {
112
+ console.warn(`[demo-task-executor] url: cache hit for ${url}`);
113
+ return cached;
114
+ }
115
+ const data = curlFetchJson(url, method, headers);
116
+ writeCache(k, data);
117
+ return data;
118
+ }
119
+
86
120
  function writeCache(key, value) {
87
121
  try {
88
122
  fs.mkdirSync(CACHE_DIR, { recursive: true });
@@ -103,6 +137,26 @@ function interpolatePrompt(template, args) {
103
137
  });
104
138
  }
105
139
 
140
+ // Reusable prompt fragments available to all copilot source templates.
141
+ // Source definitions can interpolate them with {{view_kind_guidance}} and {{card_layout_guidance}}.
142
+ const COPILOT_PROMPT_CONTEXT = {
143
+ view_kind_guidance: [
144
+ 'VIEW KIND GUIDANCE (for dynamic ref rendering):',
145
+ '- Return a _view object whenever your output data is meant for a ref element.',
146
+ '- Allowed _view.kind values only: table, editable-table, chart, metric, list, badge, text, narrative, markdown, form, filter, todo, alert.',
147
+ '- If uncertain, use "table".',
148
+ '- For array rows that users should edit, prefer "editable-table" and set _view.data.writeTo to a card_data path.',
149
+ '- For chart, set _view.data.chartType and _view.data.columns with [labelField, valueField].',
150
+ '- Keep _view.data minimal and valid JSON (no comments, no trailing text).',
151
+ ].join('\n'),
152
+ card_layout_guidance: [
153
+ 'CARD LAYOUT GUIDANCE:',
154
+ '- Prefer compact outputs that fit a card: one primary structure plus concise rationale text.',
155
+ '- Avoid repeating values already present in upstream inputs.',
156
+ '- If you produce both machine-readable and human-readable content, keep machine-readable fields top-level and concise prose in a separate field.',
157
+ ].join('\n'),
158
+ };
159
+
106
160
  /**
107
161
  * Fetch a URL using the system curl binary (synchronous, no Node event-loop handles).
108
162
  * Throws if curl exits non-zero (e.g. HTTP 4xx/5xx with -f, or network error).
@@ -123,52 +177,18 @@ function curlFetchJson(url, method, headers) {
123
177
  return JSON.parse(raw);
124
178
  }
125
179
 
126
- function stripCopilotFooter(rawText) {
127
- const lines = String(rawText ?? '').split(/\r?\n/);
128
-
129
- // Strip CLI-level tool-call telemetry lines emitted by the copilot binary when
130
- // --allow-all activates MCP tools. These are NOT model output — the prompt cannot
131
- // suppress them. They look like:
132
- // ● Web Search (MCP: github-mcp-server) · ...
133
- // └ {"type":"output_text",...}
134
- const filtered = lines.filter(line => {
135
- const t = line.trimStart();
136
- return !(
137
- /^[●•]\s+/.test(t) || // tool invocation lines
138
- /^└\s+/.test(t) // tool result lines
139
- );
140
- });
141
-
142
- // Remove trailing blank lines.
143
- while (filtered.length > 0 && filtered[filtered.length - 1].trim() === '') filtered.pop();
144
-
145
- // Remove the standard trailing Copilot metadata footer, if present.
146
- if (
147
- filtered.length >= 3 &&
148
- /^Changes\b/i.test(filtered[filtered.length - 3]) &&
149
- /^Requests\b/i.test(filtered[filtered.length - 2]) &&
150
- /^Tokens\b/i.test(filtered[filtered.length - 1])
151
- ) {
152
- filtered.splice(filtered.length - 3, 3);
153
- }
154
-
155
- while (filtered.length > 0 && filtered[filtered.length - 1].trim() === '') filtered.pop();
156
- return filtered.join('\n');
157
- }
158
-
159
180
  function resolveCopilotPrompt(sourceDef) {
160
181
  const cfg = sourceDef?.copilot && typeof sourceDef.copilot === 'object' ? sourceDef.copilot : {};
161
182
  const template = cfg.prompt_template ?? sourceDef.prompt_template;
162
183
  const args = cfg.args ?? cfg.prompt_args ?? sourceDef.prompt_args ?? sourceDef.args ?? {};
163
184
 
164
- // Merge all injected context for template interpolation.
165
- // _requires = upstream token data, _computed_values = card compute stage outputs,
166
- // _sourcesData = already-fetched sources on this card.
185
+ // Merge _projections into template interpolation context.
186
+ // _projections contains the named data projections declared in source_defs[].projections,
187
+ // evaluated by the engine from card_data/requires before invoking this executor.
167
188
  // Explicit args defined on the source take highest precedence.
168
189
  const interpolationContext = {
169
- ...sourceDef._requires,
170
- ...sourceDef._sourcesData,
171
- ...sourceDef._computed_values,
190
+ ...COPILOT_PROMPT_CONTEXT,
191
+ ...sourceDef._projections,
172
192
  ...args,
173
193
  };
174
194
 
@@ -176,93 +196,63 @@ function resolveCopilotPrompt(sourceDef) {
176
196
  return interpolatePrompt(template, interpolationContext);
177
197
  }
178
198
 
179
- function resolveCopilotExecutable() {
180
- const envBin = process.env.COPILOT_BIN;
181
- if (envBin && fs.existsSync(envBin)) {
182
- return envBin;
183
- }
184
-
185
- if (process.platform === 'win32') {
186
- try {
187
- const out = execFileSync('where.exe', ['copilot'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
188
- const candidates = out
189
- .split(/\r?\n/)
190
- .map((s) => s.trim())
191
- .filter(Boolean);
192
- const preferred = candidates.find((p) => /\.(cmd|exe|bat)$/i.test(p));
193
- if (preferred) return preferred;
194
- if (candidates[0]) return candidates[0];
195
- } catch {}
196
- } else {
197
- try {
198
- const out = execFileSync('which', ['copilot'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
199
- const first = out.split(/\r?\n/).map((s) => s.trim()).find(Boolean);
200
- if (first) return first;
201
- } catch {}
199
+ /**
200
+ * Run a copilot prompt via copilot_wrapper.bat (Windows only).
201
+ *
202
+ * The wrapper handles:
203
+ * - Session management (--resume UUID for multi-turn continuity)
204
+ * - Noise/footer stripping (via copilot_wrapper_helper.ps1)
205
+ * - JSON mode extraction with optional result_shape key matching
206
+ * - Agentic retry: if the first response isn't valid JSON, the wrapper calls
207
+ * copilot again in the same session with a correction prompt, then re-extracts.
208
+ *
209
+ * @param {string} prompt - interpolated prompt string
210
+ * @param {object} sourceDef - source definition (may contain copilot.result_shape)
211
+ * @param {string} wrapperOutFile - path the wrapper writes its JSON output to
212
+ * @param {string} sessionDir - persistent dir for session UUID (enables --resume)
213
+ * @param {string} cwd - working directory for copilot (boardSetupRoot)
214
+ * @returns {unknown} parsed JSON result value
215
+ */
216
+ function runCopilotViaWrapper(prompt, sourceDef, wrapperOutFile, sessionDir, cwd) {
217
+ const wrapperPath = path.join(__dirname, 'scripts', 'copilot_wrapper.bat');
218
+
219
+ const promptFile = wrapperOutFile + '.prompt.txt';
220
+ fs.writeFileSync(promptFile, prompt, 'utf-8');
221
+
222
+ // Optional result_shape_file: top-level keys the response JSON must contain.
223
+ // Sourced from sourceDef.copilot.result_shape or sourceDef.result_shape.
224
+ let shapeFile = '';
225
+ const shape = sourceDef?.copilot?.result_shape ?? sourceDef?.result_shape;
226
+ if (shape && typeof shape === 'object') {
227
+ shapeFile = wrapperOutFile + '.shape.json';
228
+ fs.writeFileSync(shapeFile, JSON.stringify(shape), 'utf-8');
202
229
  }
203
230
 
204
- return 'copilot';
205
- }
206
-
207
- function runCopilotPrompt(prompt) {
208
- const copilotBin = resolveCopilotExecutable();
209
- const copilotArgs = ['--allow-all'];
231
+ fs.mkdirSync(sessionDir, { recursive: true });
210
232
 
211
233
  try {
212
- // Prefer stdin prompt delivery to avoid shell/path quoting issues.
213
- return execFileSync(copilotBin, copilotArgs, {
214
- input: String(prompt),
234
+ execFileSync('cmd.exe', [
235
+ '/d', '/c',
236
+ wrapperPath,
237
+ wrapperOutFile, // OUTPUT_FILE
238
+ sessionDir, // SESSION_DIR
239
+ cwd || process.cwd(), // WORKING_DIR
240
+ '@' + promptFile, // REQUEST_OR_FILE (@ prefix = file path)
241
+ 'json', // RESULT_TYPE — wrapper extracts JSON + retries
242
+ sourceDef.bindTo || 'executor', // AGENT_NAME (for log file naming)
243
+ '', // MODEL (empty = wrapper default)
244
+ shapeFile, // RESULT_SHAPE_FILE (empty = accept any JSON)
245
+ ], {
215
246
  encoding: 'utf-8',
216
- stdio: ['pipe', 'pipe', 'pipe'],
247
+ stdio: ['ignore', 'pipe', 'pipe'],
217
248
  maxBuffer: 10 * 1024 * 1024,
218
249
  });
219
- } catch (directErr) {
220
- // Fallback for Git Bash / Windows wrapper path quoting issues.
221
- if (process.platform === 'win32') {
222
- const isCmdShim = /\.(bat|cmd)$/i.test(copilotBin);
223
-
224
- if (isCmdShim) {
225
- try {
226
- return execFileSync(copilotBin, copilotArgs, {
227
- input: String(prompt),
228
- encoding: 'utf-8',
229
- stdio: ['pipe', 'pipe', 'pipe'],
230
- maxBuffer: 10 * 1024 * 1024,
231
- shell: true,
232
- });
233
- } catch {}
234
- }
235
-
236
- try {
237
- // Final fallback: resolve through cmd PATH lookup, still piping prompt on stdin.
238
- return execFileSync('cmd.exe', ['/d', '/c', 'copilot --allow-all'], {
239
- input: String(prompt),
240
- encoding: 'utf-8',
241
- stdio: ['pipe', 'pipe', 'pipe'],
242
- maxBuffer: 10 * 1024 * 1024,
243
- });
244
- } catch (cmdErr) {
245
- const stderrDirect = directErr && typeof directErr === 'object' && 'stderr' in directErr
246
- ? String(directErr.stderr || '')
247
- : '';
248
- const stderrCmd = cmdErr && typeof cmdErr === 'object' && 'stderr' in cmdErr
249
- ? String(cmdErr.stderr || '')
250
- : '';
251
- const msg = [stderrDirect.trim(), stderrCmd.trim(), String(cmdErr && cmdErr.message || cmdErr)]
252
- .filter(Boolean)
253
- .join(' | ');
254
- throw new Error(msg || 'copilot invocation failed');
255
- }
256
- }
257
-
258
- const stderrDirect = directErr && typeof directErr === 'object' && 'stderr' in directErr
259
- ? String(directErr.stderr || '')
260
- : '';
261
- const msg = [stderrDirect.trim(), String(directErr && directErr.message || directErr)]
262
- .filter(Boolean)
263
- .join(' | ');
264
- throw new Error(msg || 'copilot invocation failed');
250
+ } finally {
251
+ try { fs.unlinkSync(promptFile); } catch {}
252
+ if (shapeFile) { try { fs.unlinkSync(shapeFile); } catch {} }
265
253
  }
254
+
255
+ return JSON.parse(fs.readFileSync(wrapperOutFile, 'utf-8').replace(/^\uFEFF/, ''));
266
256
  }
267
257
 
268
258
  function fail(msg, errFile) {
@@ -279,9 +269,17 @@ function runSourceFetchSubcommand(argv) {
279
269
  const inIdx = argv.indexOf('--in');
280
270
  const outIdx = argv.indexOf('--out');
281
271
  const errIdx = argv.indexOf('--err');
272
+ const extraIdx = argv.indexOf('--extra');
282
273
  const inFile = inIdx !== -1 ? argv[inIdx + 1] : undefined;
283
274
  const outFile = outIdx !== -1 ? argv[outIdx + 1] : undefined;
284
275
  const errFile = errIdx !== -1 ? argv[errIdx + 1] : undefined;
276
+ const extraB64 = extraIdx !== -1 ? argv[extraIdx + 1] : undefined;
277
+
278
+ let extra = {};
279
+ if (extraB64) {
280
+ try { extra = JSON.parse(Buffer.from(extraB64, 'base64').toString('utf-8')); }
281
+ catch { console.warn('[demo-task-executor] bad --extra base64, ignoring'); }
282
+ }
285
283
 
286
284
  if (!inFile || !outFile) {
287
285
  fail('Usage: run-source-fetch --in <source.json> --out <result.json> [--err <error.txt>]', errFile);
@@ -300,119 +298,67 @@ function runSourceFetchSubcommand(argv) {
300
298
 
301
299
  let resultValue;
302
300
 
303
- if (sourceDef.chartApi) {
301
+ if (sourceDef['url']) {
304
302
  // ---------------------------------------------------------------------------
305
- // chartApi source kind Yahoo Finance v8/finance/chart (free, per-ticker)
306
- // Uses curl (synchronous subprocess) to avoid Node.js libuv handle issues.
303
+ // urlsingle URL fetch via curl
304
+ // {{key}} interpolation applied to url from _projections and optional args.
305
+ // cacheTimeout: seconds to cache the response (default: CACHE_TTL_MS / 1000).
307
306
  // ---------------------------------------------------------------------------
308
- const chartCfg = sourceDef.chartApi;
309
- const headers = { ...(chartCfg.headers || {}) };
307
+ const cfg = sourceDef['url'];
308
+ const method = (cfg.method || 'GET').toUpperCase();
309
+ const headers = { ...(cfg.headers || {}) };
310
+ const cacheTimeoutSec = cfg.cacheTimeout != null ? Number(cfg.cacheTimeout) : null;
310
311
 
311
- // Extract tickers array from _requires via tickersFrom
312
- let tickers = [];
312
+ const fetchArgs = { ...(cfg.args || {}) };
313
313
  if (sourceDef.tickersFrom) {
314
314
  const dotIdx = sourceDef.tickersFrom.indexOf('.');
315
315
  if (dotIdx > 0) {
316
- const tokenName = sourceDef.tickersFrom.slice(0, dotIdx);
316
+ const refKey = sourceDef.tickersFrom.slice(0, dotIdx);
317
317
  const fieldName = sourceDef.tickersFrom.slice(dotIdx + 1);
318
- const arr = sourceDef._requires?.[tokenName];
318
+ const arr = sourceDef._projections?.[refKey];
319
319
  if (Array.isArray(arr)) {
320
- tickers = arr.map(h => h[fieldName]).filter(Boolean);
320
+ fetchArgs.tickers = arr.map(h => h[fieldName]).filter(Boolean).join(',');
321
321
  }
322
322
  }
323
323
  }
324
-
325
- if (tickers.length === 0) {
326
- console.warn('[demo-task-executor] chartApi: tickersFrom resolved to empty list — falling back to mock');
327
- } else {
328
- const chartCacheKey = cacheKey('chartApi:' + tickers.sort().join(',') + chartCfg.url);
329
- const cached = readCache(chartCacheKey);
330
- if (cached) {
331
- console.warn(`[demo-task-executor] chartApi: cache hit for [${tickers.join(', ')}]`);
332
- resultValue = cached;
333
- } else {
334
- try {
335
- const results = [];
336
- for (const ticker of tickers) {
337
- const url = interpolatePrompt(chartCfg.url, { ticker });
338
- const data = curlFetchJson(url, 'GET', headers);
339
- const meta = data?.chart?.result?.[0]?.meta;
340
- if (!meta) throw new Error(`No chart meta for ${ticker}`);
341
- // Map to quote-compatible shape; compute change from chartPreviousClose
342
- const price = meta.regularMarketPrice ?? 0;
343
- const prevClose = meta.chartPreviousClose ?? price;
344
- const change = price - prevClose;
345
- const changePct = prevClose !== 0 ? (change / prevClose) * 100 : 0;
346
- results.push({
347
- symbol: meta.symbol ?? ticker,
348
- shortName: meta.shortName ?? meta.longName ?? ticker,
349
- regularMarketPrice: price,
350
- regularMarketChange: change,
351
- regularMarketChangePercent: changePct,
352
- });
353
- }
354
- resultValue = { quoteResponse: { result: results, error: null } };
355
- writeCache(chartCacheKey, resultValue);
356
- } catch (chartErr) {
357
- fail(`chartApi fetch failed: ${chartErr.message}`, errFile);
358
- }
359
- }
324
+ if (sourceDef.tickersFrom && !fetchArgs.tickers) {
325
+ fail('url: tickersFrom resolved to empty list — skipping fetch', errFile);
360
326
  }
361
-
362
- if (resultValue === undefined) {
363
- fail('chartApi: no tickers resolved — cannot fetch', errFile);
327
+ const urlContext = { ...(sourceDef._projections || {}), ...fetchArgs };
328
+ const url = interpolatePrompt(cfg.url, urlContext);
329
+ try {
330
+ resultValue = doFetchApi(url, method, headers, cacheTimeoutSec, errFile);
331
+ } catch (err) {
332
+ fail(`url failed: ${err.message}`, errFile);
364
333
  }
365
334
 
366
- } else if (sourceDef.http) {
335
+ } else if (sourceDef['url-list']) {
367
336
  // ---------------------------------------------------------------------------
368
- // HTTP source kind uses curl (synchronous subprocess)
337
+ // url-list fan-out over a URL list, calling url logic per URL.
338
+ // url_list must be a string[] pre-resolved in _projections.url_list.
339
+ // cacheTimeout: seconds to cache each individual response.
369
340
  // ---------------------------------------------------------------------------
370
- const httpCfg = sourceDef.http;
341
+ const cfg = sourceDef['url-list'];
342
+ const method = (cfg.method || 'GET').toUpperCase();
343
+ const headers = { ...(cfg.headers || {}) };
344
+ const cacheTimeoutSec = cfg.cacheTimeout != null ? Number(cfg.cacheTimeout) : null;
371
345
 
372
- // Build tickers string if tickersFrom is specified on the source
373
- // e.g. tickersFrom: "holdings.ticker" → joins _requires.holdings[*].ticker with ','
374
- const httpArgs = { ...(httpCfg.args || {}) };
375
- if (sourceDef.tickersFrom) {
376
- const dotIdx = sourceDef.tickersFrom.indexOf('.');
377
- if (dotIdx > 0) {
378
- const tokenName = sourceDef.tickersFrom.slice(0, dotIdx);
379
- const fieldName = sourceDef.tickersFrom.slice(dotIdx + 1);
380
- const arr = sourceDef._requires?.[tokenName];
381
- if (Array.isArray(arr)) {
382
- httpArgs.tickers = arr.map(h => h[fieldName]).filter(Boolean).join(',');
383
- }
384
- }
346
+ const urlList = Array.isArray(sourceDef._projections?.url_list)
347
+ ? sourceDef._projections.url_list : null;
348
+
349
+ if (!urlList || urlList.length === 0) {
350
+ fail('url-list: _projections.url_list must be a non-empty string array', errFile);
385
351
  }
386
352
 
387
- // Interpolate URL template with all available context
388
- const urlContext = {
389
- ...(sourceDef._requires || {}),
390
- ...(sourceDef._computed_values || {}),
391
- ...httpArgs,
392
- };
393
- const url = interpolatePrompt(httpCfg.url, urlContext);
394
- const method = (httpCfg.method || 'GET').toUpperCase();
395
- const headers = { ...(httpCfg.headers || {}) };
396
-
397
- // Skip fetch entirely if tickers ended up empty (guard against empty ?symbols=)
398
- const httpFetchSkipped = sourceDef.tickersFrom && !httpArgs.tickers;
399
-
400
- const httpCacheKey = cacheKey(`http:${method}:${url}`);
401
- const httpCached = readCache(httpCacheKey);
402
- if (httpCached && !httpFetchSkipped) {
403
- console.warn(`[demo-task-executor] http: cache hit for ${url}`);
404
- resultValue = httpCached;
405
- } else {
353
+ const results = [];
354
+ for (const u of urlList) {
406
355
  try {
407
- if (httpFetchSkipped) {
408
- throw new Error('tickersFrom resolved to empty list — skipping fetch');
409
- }
410
- resultValue = curlFetchJson(url, method, headers);
411
- writeCache(httpCacheKey, resultValue);
412
- } catch (httpErr) {
413
- fail(`HTTP fetch failed: ${httpErr.message}`, errFile);
356
+ results.push(doFetchApi(u, method, headers, cacheTimeoutSec, errFile));
357
+ } catch (err) {
358
+ fail(`url-list fetch failed for ${u}: ${err.message}`, errFile);
414
359
  }
415
360
  }
361
+ resultValue = results;
416
362
 
417
363
  } else if (sourceDef.copilot || sourceDef.prompt_template) {
418
364
  const prompt = resolveCopilotPrompt(sourceDef);
@@ -420,22 +366,88 @@ function runSourceFetchSubcommand(argv) {
420
366
  fail('Source definition missing copilot.prompt_template (or prompt_template)', errFile);
421
367
  }
422
368
 
423
- let rawOutput = '';
424
- try {
425
- rawOutput = runCopilotPrompt(prompt);
426
- } catch (err) {
427
- const msg = String(err && err.message || err);
428
- fail(`copilot invocation failed: ${msg}`, errFile);
369
+ // Use boardSetupRoot (from --extra) as copilot working directory
370
+ const copilotCwd = extra.boardSetupRoot || undefined;
371
+
372
+ // On Windows, delegate entirely to copilot_wrapper.bat which handles:
373
+ // - session management (--resume UUID for multi-turn continuity)
374
+ // - noise/footer stripping, JSON extraction, agentic retry on bad shape
375
+ // On non-Windows, fall back to a basic direct invocation (no retry).
376
+ const wrapperPath = path.join(__dirname, 'scripts', 'copilot_wrapper.bat');
377
+ const useWrapper = process.platform === 'win32' && fs.existsSync(wrapperPath);
378
+
379
+ if (useWrapper) {
380
+ // Session dir is stable across refreshes so --resume continues the conversation.
381
+ const sessionDir = path.join(
382
+ extra.boardSetupRoot || os.tmpdir(),
383
+ 'copilot-sessions',
384
+ String(sourceDef.bindTo || 'default').replace(/[^a-zA-Z0-9_-]/g, '_'),
385
+ );
386
+ const wrapperOutFile = outFile + '.wrapper-out.json';
387
+ try {
388
+ resultValue = runCopilotViaWrapper(prompt, sourceDef, wrapperOutFile, sessionDir, copilotCwd);
389
+ } catch (err) {
390
+ fail(`copilot invocation failed: ${String(err && err.message || err)}`, errFile);
391
+ } finally {
392
+ try { fs.unlinkSync(wrapperOutFile); } catch {}
393
+ }
394
+ } else {
395
+ // Non-Windows fallback: call copilot directly via cmd.exe and do basic JSON extraction.
396
+ let rawOutput = '';
397
+ try {
398
+ rawOutput = execFileSync('cmd.exe', ['/d', '/c', 'copilot --allow-all'], {
399
+ input: String(prompt),
400
+ encoding: 'utf-8',
401
+ stdio: ['pipe', 'pipe', 'pipe'],
402
+ maxBuffer: 10 * 1024 * 1024,
403
+ ...(copilotCwd ? { cwd: copilotCwd } : {}),
404
+ });
405
+ } catch (err) {
406
+ fail(`copilot invocation failed: ${String(err && err.message || err)}`, errFile);
407
+ }
408
+ // Basic JSON extraction: find first { or [ in output
409
+ const firstBrace = rawOutput.indexOf('{');
410
+ const firstBracket = rawOutput.indexOf('[');
411
+ const jsonStart = (firstBrace === -1) ? firstBracket
412
+ : (firstBracket === -1) ? firstBrace
413
+ : Math.min(firstBrace, firstBracket);
414
+ if (jsonStart !== -1) {
415
+ try {
416
+ const parsed = JSON.parse(rawOutput.slice(jsonStart));
417
+ resultValue = (parsed && typeof parsed === 'object') ? parsed : rawOutput;
418
+ } catch {
419
+ resultValue = rawOutput;
420
+ }
421
+ } else {
422
+ resultValue = rawOutput;
423
+ }
424
+ }
425
+ } else if (sourceDef.workiq) {
426
+ const cfg = typeof sourceDef.workiq === 'object' ? sourceDef.workiq : {};
427
+ if (!cfg.query_template || typeof cfg.query_template !== 'string') {
428
+ fail('Source definition missing workiq.query_template', errFile);
429
429
  }
430
+ const interpolationContext = { ...sourceDef._projections, ...(cfg.args ?? {}) };
431
+ const query = interpolatePrompt(cfg.query_template, interpolationContext);
430
432
 
431
- const cleaned = stripCopilotFooter(rawOutput);
432
- // If the response is a JSON object/array, parse it so downstream compute
433
- // can reference fields directly (e.g. fetched_sources.analysis.mix).
433
+ const wrapperPath = path.join(__dirname, 'scripts', 'workiq_wrapper.mjs');
434
+ if (!fs.existsSync(wrapperPath)) {
435
+ fail('workiq source kind requires workiq_wrapper.js in scripts/', errFile);
436
+ }
434
437
  try {
435
- const parsed = JSON.parse(cleaned);
436
- resultValue = (parsed && typeof parsed === 'object') ? parsed : cleaned;
437
- } catch {
438
- resultValue = cleaned;
438
+ execFileSync(process.execPath, [wrapperPath, outFile], {
439
+ encoding: 'utf-8',
440
+ stdio: ['inherit', 'pipe', 'pipe'],
441
+ maxBuffer: 10 * 1024 * 1024,
442
+ env: {
443
+ ...process.env,
444
+ WORKIQ_QUERY: query,
445
+ ...(extra.serverUrl ? { WORKIQ_SERVER_URL: extra.serverUrl } : {}),
446
+ },
447
+ });
448
+ return; // wrapper wrote directly to outFile
449
+ } catch (err) {
450
+ fail(`workiq invocation failed: ${String(err && err.message || err)}`, errFile);
439
451
  }
440
452
  } else if (sourceDef.mock) {
441
453
  // MOCK_DB lookup — data hardcoded at the top of this file
@@ -444,7 +456,7 @@ function runSourceFetchSubcommand(argv) {
444
456
  fail(`Key "${sourceDef.mock}" not found in MOCK_DB`, errFile);
445
457
  }
446
458
  } else {
447
- fail('Source definition has no recognised kind (copilot, http, chartApi, mock)', errFile);
459
+ fail('Source definition has no recognised kind (url, url-list, copilot, workiq, mock)', errFile);
448
460
  }
449
461
 
450
462
  // Write result to --out as JSON payload, same contract as current mock mode.
@@ -456,12 +468,211 @@ function runSourceFetchSubcommand(argv) {
456
468
 
457
469
  }
458
470
 
471
+ // ---------------------------------------------------------------------------
472
+ // validate-source-def — structural validation of a source definition
473
+ // ---------------------------------------------------------------------------
474
+ function validateSourceDefSubcommand(argv) {
475
+ const inIdx = argv.indexOf('--in');
476
+ const inFile = inIdx !== -1 ? argv[inIdx + 1] : undefined;
477
+
478
+ if (!inFile) {
479
+ console.error('[demo-task-executor] Usage: validate-source-def --in <source.json>');
480
+ process.exit(1);
481
+ }
482
+
483
+ if (!fs.existsSync(inFile)) {
484
+ console.log(JSON.stringify({ ok: false, errors: [`Input file not found: ${inFile}`] }));
485
+ process.exit(1);
486
+ }
487
+
488
+ let sourceDef;
489
+ try {
490
+ sourceDef = readJson(inFile);
491
+ } catch (err) {
492
+ console.log(JSON.stringify({ ok: false, errors: [`Cannot parse source file: ${err && err.message || err}`] }));
493
+ process.exit(1);
494
+ }
495
+
496
+ const errors = [];
497
+
498
+ // Determine source kind and validate required fields
499
+ const hasUrl = !!sourceDef['url'];
500
+ const hasUrlList = !!sourceDef['url-list'];
501
+ const hasCopilot = !!sourceDef.copilot;
502
+ const hasPromptTemplate = typeof sourceDef.prompt_template === 'string';
503
+ const hasWorkiq = !!sourceDef.workiq;
504
+ const hasMock = sourceDef.mock !== undefined;
505
+
506
+ const kindCount = [hasUrl, hasUrlList, hasCopilot || hasPromptTemplate, hasWorkiq, hasMock].filter(Boolean).length;
507
+
508
+ if (kindCount === 0) {
509
+ errors.push('No recognised source kind (url, url-list, copilot, workiq, mock). Add one of these fields.');
510
+ } else if (kindCount > 1) {
511
+ const kinds = [];
512
+ if (hasUrl) kinds.push('url');
513
+ if (hasUrlList) kinds.push('url-list');
514
+ if (hasCopilot || hasPromptTemplate) kinds.push('copilot');
515
+ if (hasWorkiq) kinds.push('workiq');
516
+ if (hasMock) kinds.push('mock');
517
+ errors.push(`Multiple source kinds specified: [${kinds.join(', ')}]. Use exactly one.`);
518
+ }
519
+
520
+ if (hasUrl) {
521
+ if (typeof sourceDef['url'] !== 'object') {
522
+ errors.push('url must be an object.');
523
+ } else if (!sourceDef['url'].url || typeof sourceDef['url'].url !== 'string') {
524
+ errors.push('url.url is required and must be a string.');
525
+ }
526
+ }
527
+
528
+ if (hasUrlList) {
529
+ if (typeof sourceDef['url-list'] !== 'object') {
530
+ errors.push('url-list must be an object.');
531
+ }
532
+ // url_list is supplied via _projections at runtime — no static validation needed.
533
+ }
534
+
535
+ if (hasCopilot) {
536
+ if (typeof sourceDef.copilot !== 'object') {
537
+ errors.push('copilot must be an object.');
538
+ } else {
539
+ if (!sourceDef.copilot.prompt_template && !hasPromptTemplate) {
540
+ errors.push('copilot.prompt_template is required (or use top-level prompt_template).');
541
+ }
542
+ }
543
+ }
544
+
545
+ if (hasWorkiq) {
546
+ if (typeof sourceDef.workiq !== 'object') {
547
+ errors.push('workiq must be an object.');
548
+ } else if (!sourceDef.workiq.query_template || typeof sourceDef.workiq.query_template !== 'string') {
549
+ errors.push('workiq.query_template is required and must be a string.');
550
+ }
551
+ }
552
+
553
+ if (hasMock) {
554
+ if (typeof sourceDef.mock !== 'string') {
555
+ errors.push('mock must be a string key.');
556
+ }
557
+ }
558
+
559
+ const result = { ok: errors.length === 0, errors };
560
+ console.log(JSON.stringify(result));
561
+ process.exit(errors.length === 0 ? 0 : 1);
562
+ }
563
+
564
+ // ---------------------------------------------------------------------------
565
+ // describe-capabilities — introspection metadata for this executor
566
+ // ---------------------------------------------------------------------------
567
+ const CAPABILITIES = {
568
+ version: '1.0',
569
+ executor: 'demo-task-executor',
570
+ subcommands: ['run-source-fetch', 'describe-capabilities', 'validate-source-def'],
571
+ sourceKinds: {
572
+ mock: {
573
+ description: 'Look up a key in a hardcoded MOCK_DB dictionary.',
574
+ inputSchema: {
575
+ mock: { type: 'string', required: true, description: 'Key in MOCK_DB (e.g. "quotes").' },
576
+ },
577
+ outputShape: 'Arbitrary JSON — depends on the mock key.',
578
+ example: {
579
+ input: { mock: 'quotes' },
580
+ output: { quoteResponse: { result: [{ symbol: 'AAPL', regularMarketPrice: 198.15 }], error: null } },
581
+ },
582
+ },
583
+ copilot: {
584
+ description: 'Invoke GitHub Copilot CLI with an interpolated prompt template.',
585
+ inputSchema: {
586
+ copilot: {
587
+ type: 'object', required: false,
588
+ description: 'Object with prompt_template (string) and optional args (object).',
589
+ properties: {
590
+ prompt_template: { type: 'string', required: true, description: 'Prompt with {{key}} placeholders.' },
591
+ args: { type: 'object', required: false, description: 'Extra interpolation args (highest precedence).' },
592
+ },
593
+ },
594
+ prompt_template: { type: 'string', required: false, description: 'Shorthand — top-level prompt template (alternative to copilot.prompt_template).' },
595
+ },
596
+ outputShape: 'string | object — raw Copilot text, or parsed JSON if the response is valid JSON.',
597
+ },
598
+ workiq: {
599
+ description: 'Query WorkIQ (Microsoft 365 Copilot) with an interpolated query template. Returns raw text response.',
600
+ inputSchema: {
601
+ workiq: {
602
+ type: 'object', required: true,
603
+ properties: {
604
+ query_template: { type: 'string', required: true, description: 'Query with {{key}} placeholders interpolated from _projections and args.' },
605
+ args: { type: 'object', required: false, description: 'Extra interpolation args (highest precedence).' },
606
+ },
607
+ },
608
+ },
609
+ outputShape: 'string — raw M365 Copilot response text.',
610
+ note: 'Requires workiq CLI installed and Azure CLI logged in (az login).',
611
+ },
612
+ 'url': {
613
+ description: 'Single URL fetch via curl with {{key}} interpolation from _projections. Supports cacheTimeout.',
614
+ inputSchema: {
615
+ 'url': {
616
+ type: 'object', required: true,
617
+ properties: {
618
+ url: { type: 'string', required: true, description: 'URL template with {{key}} placeholders.' },
619
+ method: { type: 'string', required: false, description: 'HTTP method (default: GET).' },
620
+ headers: { type: 'object', required: false, description: 'Request headers.' },
621
+ args: { type: 'object', required: false, description: 'Extra interpolation args (highest precedence).' },
622
+ cacheTimeout: { type: 'number', required: false, description: 'Cache TTL in seconds (default: 3600).' },
623
+ },
624
+ },
625
+ tickersFrom: { type: 'string', required: false, description: '"refKey.fieldName" — join tickers from _projections into {{tickers}}.' },
626
+ },
627
+ outputShape: 'Arbitrary JSON from the fetched URL.',
628
+ },
629
+ 'url-list': {
630
+ description: 'Fan-out over a pre-resolved URL list — calls url logic per URL and returns an array of responses. url_list must be a string[] in _projections.url_list (built via projections JSONata).',
631
+ inputSchema: {
632
+ 'url-list': {
633
+ type: 'object', required: true,
634
+ properties: {
635
+ method: { type: 'string', required: false, description: 'HTTP method (default: GET).' },
636
+ headers: { type: 'object', required: false, description: 'Request headers.' },
637
+ cacheTimeout: { type: 'number', required: false, description: 'Cache TTL per URL in seconds (default: 3600).' },
638
+ },
639
+ },
640
+ },
641
+ outputShape: 'Array of raw JSON responses, one per URL in _projections.url_list.',
642
+ urlListNote: 'Declare `"projections": { "url_list": "<JSONata producing string[]>" }` on the source def. Example: `requires.holdings.ticker.(\'https://api.example.com/\' & $ & \'?q=1\')`',
643
+ },
644
+ },
645
+ extraSchema: {
646
+ description: 'Board topology context passed via --extra (base64-encoded JSON, baked at init).',
647
+ properties: {
648
+ boardSetupRoot: { type: 'string', description: 'Absolute path to board root.' },
649
+ boardId: { type: 'string', description: 'Board identifier.' },
650
+ boardRuntimeDir: { type: 'string', description: 'Relative path to runtime dir.' },
651
+ runtimeStatusDir: { type: 'string', description: 'Relative path to runtime-out dir.' },
652
+ cardsDir: { type: 'string', description: 'Relative path to cards dir.' },
653
+ serverUrl: { type: 'string', description: 'Base URL of the hosting server (e.g. http://127.0.0.1:7799). Used by source kinds that call server-side proxy endpoints.' },
654
+ },
655
+ },
656
+ };
657
+
658
+ function describeCapabilities() {
659
+ console.log(JSON.stringify(CAPABILITIES, null, 2));
660
+ }
661
+
459
662
  async function main() {
460
663
  const sub = process.argv[2];
461
664
  if (sub === 'run-source-fetch') {
462
665
  runSourceFetchSubcommand(process.argv.slice(3));
463
666
  return;
464
667
  }
668
+ if (sub === 'describe-capabilities') {
669
+ describeCapabilities();
670
+ return;
671
+ }
672
+ if (sub === 'validate-source-def') {
673
+ validateSourceDefSubcommand(process.argv.slice(3));
674
+ return;
675
+ }
465
676
 
466
677
  console.warn(`[demo-task-executor] Unknown subcommand: ${sub}`);
467
678
  process.exit(0);