@tagma/sdk 0.2.4 → 0.2.6

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/README.md CHANGED
@@ -60,7 +60,8 @@ console.log(result.success ? 'Done' : 'Failed');
60
60
  - **Lifecycle hooks** -- `pipeline_start`, `task_start`, `task_success`, `task_failure`, `pipeline_complete`, `pipeline_error`
61
61
  - **Middleware** -- enrich prompts before execution (e.g. inject static context)
62
62
  - **Completion checks** -- validate task output with `exit_code`, `file_exists`, or `output_check` plugins
63
- - **Template expansion** -- reusable task templates with parameterized `use` / `with`
63
+ - **Template expansion** -- reusable task templates with parameterized `use` / `with`; `discoverTemplates()` enumerates installed `@tagma/template-*` packages for editor integrations
64
+ - **Plugin schemas** -- triggers/completions/middlewares can declare a `PluginSchema` so visual editors render typed forms for their config
64
65
 
65
66
  ## Pipeline YAML Reference
66
67
 
@@ -117,7 +118,6 @@ pipeline:
117
118
  trigger:
118
119
  type: manual
119
120
  message: "Approve before running"
120
- options: [approve, reject]
121
121
  timeout: 5m
122
122
  completion:
123
123
  type: exit_code
@@ -223,7 +223,6 @@ Track-level `middlewares` apply to all tasks in the track. Setting task-level `m
223
223
  |---|---|---|---|---|
224
224
  | `type` | `"manual"` | Yes | — | Trigger type |
225
225
  | `message` | `string` | No | `"Manual confirmation required for task \"{taskId}\""` | Message shown to the approver |
226
- | `options` | `string[]` | No | — | Choice options (e.g. `[approve, reject]`) |
227
226
  | `timeout` | `string` | No | — | How long to wait for a decision before timing out |
228
227
  | `metadata` | `object` | No | — | Arbitrary metadata passed to the approval gateway |
229
228
 
@@ -295,6 +294,7 @@ Options:
295
294
  - `onEvent` -- callback for real-time `PipelineEvent` updates:
296
295
  - `pipeline_start` — pipeline began; includes `states: ReadonlyMap<taskId, TaskState>` (initial snapshot of all tasks at `waiting`)
297
296
  - `task_status_change` — a task changed status; includes `state: TaskState` (complete snapshot at the time of change: `startedAt` is populated before the `running` event; `result` and `finishedAt` are populated before any terminal-status event)
297
+ - `task_log` — a structured log line was written to `pipeline.log`. Mirrors every `Logger` call (info/warn/error/debug/section/quiet) and carries `{ taskId: string | null, level, timestamp, text }`. `taskId` is non-null for lines tagged with a `[task:<id>]` prefix (or passed explicitly to `section`/`quiet`) and `null` for pipeline-wide messages such as the configuration dump and DAG topology. Use this to stream the full run process into UIs without tailing the log file.
298
298
  - `pipeline_end` — pipeline finished; includes `success: boolean`
299
299
  - `maxLogRuns` -- number of per-run log directories to keep under `<workDir>/.tagma/logs/` (default: 20)
300
300
 
@@ -333,6 +333,27 @@ Dynamically loads and registers external plugin packages.
333
333
 
334
334
  Registers a plugin handler manually. Idempotent — duplicate registrations are silently ignored.
335
335
 
336
+ Plugin handlers (`TriggerPlugin`, `CompletionPlugin`, `MiddlewarePlugin`) may optionally expose a declarative `schema: PluginSchema` field so visual editors can render a typed form for the plugin's config instead of a raw key/value editor:
337
+
338
+ ```ts
339
+ import type { TriggerPlugin } from '@tagma/types';
340
+
341
+ export const HttpTrigger: TriggerPlugin = {
342
+ name: 'http',
343
+ schema: {
344
+ description: 'Wait for an HTTP endpoint to return 2xx before the task runs.',
345
+ fields: {
346
+ url: { type: 'string', required: true, placeholder: 'https://...' },
347
+ method: { type: 'enum', enum: ['GET', 'POST'], default: 'GET' },
348
+ timeout:{ type: 'duration', description: 'Give up after this long.' },
349
+ },
350
+ },
351
+ async watch(config, ctx) { /* ... */ },
352
+ };
353
+ ```
354
+
355
+ The schema is purely descriptive — plugins still perform their own runtime validation. Supported field types: `string`, `number`, `boolean`, `enum`, `path`, `duration`, `number-or-list`. Each field can declare `required`, `default`, `description`, `enum`, `min`/`max`, `placeholder`. Built-in plugins (`file`/`manual` triggers; `exit_code`/`file_exists`/`output_check` completions; `static_context` middleware) all ship with schemas so editors can generate forms out of the box.
356
+
336
357
  ### `getHandler(category, type): PluginType`
337
358
 
338
359
  Retrieves a registered plugin handler. Throws if the plugin is not registered.
@@ -355,6 +376,25 @@ Use `loadPipeline` for the common parse-and-resolve flow. Use `resolveConfig` di
355
376
 
356
377
  Expands `use:` template references in a task list. Loads template packages (`@tagma/template-*`), resolves parameters, and namespaces task IDs and dependencies. Called internally by `loadPipeline`.
357
378
 
379
+ ### `discoverTemplates(workDir: string): TemplateManifest[]`
380
+
381
+ Scans `<workDir>/node_modules/@tagma/` for installed `template-*` packages and returns their manifests (name, description, params, ref). Intended for editors/UIs that want to render a "pick a template" browser without actually expanding any templates. Silently skips packages whose `template.yaml` is missing or invalid.
382
+
383
+ ```ts
384
+ import { discoverTemplates } from '@tagma/sdk';
385
+
386
+ const templates = discoverTemplates(process.cwd());
387
+ // [{ ref: '@tagma/template-review', name: 'Code Review', description: '...', params: {...}, tasks: [...] }, ...]
388
+ ```
389
+
390
+ Each `TemplateManifest` is a `TemplateConfig` with an extra `ref` field — the value users drop into `task.use`.
391
+
392
+ ### `loadTemplateManifest(ref: string, workDir: string): TemplateManifest | null`
393
+
394
+ Loads a single template's manifest by ref (e.g. `@tagma/template-review`). Returns `null` when the package isn't installed or its manifest fails to parse. Complements `discoverTemplates` when the caller only needs one template.
395
+
396
+ Both functions use Node's `fs` APIs and are safe to call from Node runtimes (unlike the legacy Bun-only `loadTemplate` used internally by `expandTemplates`).
397
+
358
398
  ### `attachStdinApprovalAdapter(gateway): StdinApprovalAdapter`
359
399
 
360
400
  Attaches an interactive stdin-based approval handler.
@@ -465,9 +505,31 @@ logger.warn('[track]', 'message'); // console + file
465
505
  logger.error('[track]', 'message'); // console + file
466
506
  logger.debug('[track]', 'message'); // file only
467
507
  logger.section('Title'); // file only — visual separator
468
- logger.quiet(bulkText); // file only — bulk payload
469
- logger.path; // log file path
470
- logger.dir; // run artifact directory
508
+ logger.quiet(bulkText); // file only — bulk payload
509
+ logger.path; // log file path
510
+ logger.dir; // run artifact directory
511
+ ```
512
+
513
+ Pass an optional third argument to stream every appended line out as a
514
+ structured `LogRecord` — `runPipeline` uses this to emit `task_log` events:
515
+
516
+ ```ts
517
+ import { Logger, type LogRecord } from '@tagma/sdk';
518
+
519
+ const logger = new Logger(workDir, runId, (record: LogRecord) => {
520
+ // record = { level, taskId, timestamp, text }
521
+ // level = 'info' | 'warn' | 'error' | 'debug' | 'section' | 'quiet'
522
+ // taskId is extracted from a '[task:<id>]' prefix, or null for untagged lines
523
+ forwardToUI(record);
524
+ });
525
+ ```
526
+
527
+ `section` and `quiet` carry no prefix, so pass an explicit `taskId` when the
528
+ line logically belongs to a task — the extractor cannot infer one otherwise:
529
+
530
+ ```ts
531
+ logger.section(`Task ${taskId}`, taskId);
532
+ logger.quiet(`--- stdout (${taskId}) ---\n${body}\n--- end stdout ---`, taskId);
471
533
  ```
472
534
 
473
535
  ### `tailLines(text: string, n: number): string`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tagma/sdk",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,117 +1,106 @@
1
- import * as readline from 'readline';
2
- import type { ApprovalGateway, ApprovalRequest } from '../approval';
3
-
4
- // ═══ CLI Stdin Adapter ═══
5
- //
6
- // Subscribes to the gateway's 'requested' events, prompts the user on stdout,
7
- // reads a line from stdin, and calls gateway.resolve(). Handles at most one
8
- // prompt at a time — additional requests queue up.
9
-
10
- export interface StdinApprovalAdapter {
11
- readonly detach: () => void;
12
- }
13
-
14
- export function attachStdinApprovalAdapter(gateway: ApprovalGateway): StdinApprovalAdapter {
15
- const queue: ApprovalRequest[] = [];
16
- let processing = false;
17
- let rl: readline.Interface | null = null;
18
-
19
- function ensureReadline(): readline.Interface {
20
- if (!rl) {
21
- rl = readline.createInterface({ input: process.stdin, terminal: false });
22
- }
23
- return rl;
24
- }
25
-
26
- function readOneLine(): Promise<string> {
27
- return new Promise((resolvePromise) => {
28
- const reader = ensureReadline();
29
- const handler = (line: string): void => {
30
- reader.off('line', handler);
31
- resolvePromise(line);
32
- };
33
- reader.on('line', handler);
34
- });
35
- }
36
-
37
- async function processNext(): Promise<void> {
38
- if (processing) return;
39
- processing = true;
40
- try {
41
- while (queue.length > 0) {
42
- const req = queue.shift()!;
43
- // If the request was already resolved by another path while queued, skip it.
44
- if (!gateway.pending().some((p) => p.id === req.id)) continue;
45
-
46
- const optionsStr = req.options.join(' / ');
47
- process.stdout.write(
48
- `\n[APPROVAL REQUIRED] ${req.message}\n` +
49
- ` id: ${req.id}\n` +
50
- ` task: ${req.taskId}${req.trackId ? ` (track: ${req.trackId})` : ''}\n` +
51
- ` options: ${optionsStr}\n` +
52
- ` > `,
53
- );
54
-
55
- const input = (await readOneLine()).trim().toLowerCase();
56
-
57
- const approveAliases = new Set(['approve', 'yes', 'y', 'ok', 'true', '1']);
58
- const rejectAliases = new Set(['reject', 'no', 'n', 'deny', 'false', '0']);
59
- const matchedOption = req.options.find((o) => o.toLowerCase() === input);
60
-
61
- if (matchedOption) {
62
- const isReject = rejectAliases.has(matchedOption.toLowerCase());
63
- gateway.resolve(req.id, {
64
- outcome: isReject ? 'rejected' : 'approved',
65
- choice: matchedOption,
66
- actor: 'cli',
67
- });
68
- } else if (approveAliases.has(input)) {
69
- gateway.resolve(req.id, { outcome: 'approved', choice: input, actor: 'cli' });
70
- } else if (rejectAliases.has(input)) {
71
- gateway.resolve(req.id, {
72
- outcome: 'rejected',
73
- choice: input,
74
- actor: 'cli',
75
- reason: 'user rejected via CLI',
76
- });
77
- } else {
78
- process.stdout.write(` unrecognized input "${input}" — treating as rejection\n`);
79
- gateway.resolve(req.id, {
80
- outcome: 'rejected',
81
- actor: 'cli',
82
- reason: `unrecognized CLI input: ${input}`,
83
- });
84
- }
85
- }
86
- } finally {
87
- processing = false;
88
- }
89
- }
90
-
91
- const unsubscribe = gateway.subscribe((event) => {
92
- switch (event.type) {
93
- case 'requested':
94
- queue.push(event.request);
95
- void processNext();
96
- return;
97
- case 'resolved':
98
- case 'expired':
99
- case 'aborted': {
100
- // Drop from queue if it's still waiting its turn.
101
- const idx = queue.findIndex((r) => r.id === event.request.id);
102
- if (idx >= 0) queue.splice(idx, 1);
103
- return;
104
- }
105
- }
106
- });
107
-
108
- return {
109
- detach: () => {
110
- unsubscribe();
111
- if (rl) {
112
- rl.close();
113
- rl = null;
114
- }
115
- },
116
- };
117
- }
1
+ import * as readline from 'readline';
2
+ import type { ApprovalGateway, ApprovalRequest } from '../approval';
3
+
4
+ // ═══ CLI Stdin Adapter ═══
5
+ //
6
+ // Subscribes to the gateway's 'requested' events, prompts the user on stdout,
7
+ // reads a line from stdin, and calls gateway.resolve(). Handles at most one
8
+ // prompt at a time — additional requests queue up.
9
+
10
+ export interface StdinApprovalAdapter {
11
+ readonly detach: () => void;
12
+ }
13
+
14
+ export function attachStdinApprovalAdapter(gateway: ApprovalGateway): StdinApprovalAdapter {
15
+ const queue: ApprovalRequest[] = [];
16
+ let processing = false;
17
+ let rl: readline.Interface | null = null;
18
+
19
+ function ensureReadline(): readline.Interface {
20
+ if (!rl) {
21
+ rl = readline.createInterface({ input: process.stdin, terminal: false });
22
+ }
23
+ return rl;
24
+ }
25
+
26
+ function readOneLine(): Promise<string> {
27
+ return new Promise((resolvePromise) => {
28
+ const reader = ensureReadline();
29
+ const handler = (line: string): void => {
30
+ reader.off('line', handler);
31
+ resolvePromise(line);
32
+ };
33
+ reader.on('line', handler);
34
+ });
35
+ }
36
+
37
+ async function processNext(): Promise<void> {
38
+ if (processing) return;
39
+ processing = true;
40
+ try {
41
+ while (queue.length > 0) {
42
+ const req = queue.shift()!;
43
+ // If the request was already resolved by another path while queued, skip it.
44
+ if (!gateway.pending().some((p) => p.id === req.id)) continue;
45
+
46
+ process.stdout.write(
47
+ `\n[APPROVAL REQUIRED] ${req.message}\n` +
48
+ ` id: ${req.id}\n` +
49
+ ` task: ${req.taskId}${req.trackId ? ` (track: ${req.trackId})` : ''}\n` +
50
+ ` approve / reject > `,
51
+ );
52
+
53
+ const input = (await readOneLine()).trim().toLowerCase();
54
+
55
+ const approveAliases = new Set(['approve', 'yes', 'y', 'ok', 'true', '1']);
56
+ const rejectAliases = new Set(['reject', 'no', 'n', 'deny', 'false', '0']);
57
+
58
+ if (approveAliases.has(input)) {
59
+ gateway.resolve(req.id, { outcome: 'approved', actor: 'cli' });
60
+ } else if (rejectAliases.has(input)) {
61
+ gateway.resolve(req.id, {
62
+ outcome: 'rejected',
63
+ actor: 'cli',
64
+ reason: 'user rejected via CLI',
65
+ });
66
+ } else {
67
+ process.stdout.write(` unrecognized input "${input}" — treating as rejection\n`);
68
+ gateway.resolve(req.id, {
69
+ outcome: 'rejected',
70
+ actor: 'cli',
71
+ reason: `unrecognized CLI input: ${input}`,
72
+ });
73
+ }
74
+ }
75
+ } finally {
76
+ processing = false;
77
+ }
78
+ }
79
+
80
+ const unsubscribe = gateway.subscribe((event) => {
81
+ switch (event.type) {
82
+ case 'requested':
83
+ queue.push(event.request);
84
+ void processNext();
85
+ return;
86
+ case 'resolved':
87
+ case 'expired':
88
+ case 'aborted': {
89
+ // Drop from queue if it's still waiting its turn.
90
+ const idx = queue.findIndex((r) => r.id === event.request.id);
91
+ if (idx >= 0) queue.splice(idx, 1);
92
+ return;
93
+ }
94
+ }
95
+ });
96
+
97
+ return {
98
+ detach: () => {
99
+ unsubscribe();
100
+ if (rl) {
101
+ rl.close();
102
+ rl = null;
103
+ }
104
+ },
105
+ };
106
+ }
@@ -16,7 +16,7 @@ import type { ApprovalGateway, ApprovalEvent } from '../approval';
16
16
  //
17
17
  // Protocol — client → server:
18
18
  // { type: 'resolve', approvalId: string, outcome: 'approved'|'rejected',
19
- // choice?: string, actor?: string, reason?: string }
19
+ // actor?: string, reason?: string }
20
20
 
21
21
  export interface WebSocketApprovalAdapterOptions {
22
22
  port?: number; // default: 3000
@@ -123,7 +123,6 @@ export function attachWebSocketApprovalAdapter(
123
123
 
124
124
  const ok = gateway.resolve(msg.approvalId, {
125
125
  outcome: msg.outcome,
126
- choice: msg.choice,
127
126
  actor: msg.actor ?? 'websocket',
128
127
  reason: msg.reason,
129
128
  });
@@ -159,7 +158,6 @@ interface ResolveMessage {
159
158
  type: 'resolve';
160
159
  approvalId: string;
161
160
  outcome: 'approved' | 'rejected';
162
- choice?: string;
163
161
  actor?: string;
164
162
  reason?: string;
165
163
  }
package/src/approval.ts CHANGED
@@ -16,9 +16,6 @@ export type {
16
16
  ApprovalListener, ApprovalGateway,
17
17
  } from '@tagma/types';
18
18
 
19
- // Default options presented to the approver when the caller does not specify any.
20
- const DEFAULT_APPROVAL_OPTIONS = ['approve', 'reject'] as const;
21
-
22
19
  // ═══ Default In-Memory Implementation ═══
23
20
 
24
21
  interface PendingEntry {
@@ -32,7 +29,7 @@ export class InMemoryApprovalGateway implements ApprovalGateway {
32
29
  private readonly listeners = new Set<ApprovalListener>();
33
30
 
34
31
  request(
35
- req: Omit<ApprovalRequest, 'id' | 'createdAt' | 'options'> & { options?: readonly string[] },
32
+ req: Omit<ApprovalRequest, 'id' | 'createdAt'>,
36
33
  ): Promise<ApprovalDecision> {
37
34
  const full: ApprovalRequest = {
38
35
  id: randomUUID(),
@@ -40,7 +37,6 @@ export class InMemoryApprovalGateway implements ApprovalGateway {
40
37
  taskId: req.taskId,
41
38
  trackId: req.trackId,
42
39
  message: req.message,
43
- options: req.options && req.options.length > 0 ? req.options : DEFAULT_APPROVAL_OPTIONS,
44
40
  timeoutMs: req.timeoutMs,
45
41
  metadata: req.metadata,
46
42
  };
@@ -80,7 +76,6 @@ export class InMemoryApprovalGateway implements ApprovalGateway {
80
76
  const full: ApprovalDecision = {
81
77
  approvalId,
82
78
  outcome: decision.outcome,
83
- choice: decision.choice,
84
79
  actor: decision.actor,
85
80
  reason: decision.reason,
86
81
  decidedAt: nowISO(),
@@ -1,19 +1,30 @@
1
- import type { CompletionPlugin, CompletionContext, TaskResult } from '../types';
2
-
3
- export const ExitCodeCompletion: CompletionPlugin = {
4
- name: 'exit_code',
5
-
6
- async check(config: Record<string, unknown>, result: TaskResult, _ctx: CompletionContext): Promise<boolean> {
7
- const expected = config.expect ?? 0;
8
-
9
- if (typeof expected === 'number') {
10
- return result.exitCode === expected;
11
- }
12
- if (Array.isArray(expected) && expected.every((v) => typeof v === 'number')) {
13
- return expected.includes(result.exitCode);
14
- }
15
- throw new Error(
16
- `exit_code completion: "expect" must be a number or number[], got ${typeof expected}`
17
- );
18
- },
19
- };
1
+ import type { CompletionPlugin, CompletionContext, TaskResult } from '../types';
2
+
3
+ export const ExitCodeCompletion: CompletionPlugin = {
4
+ name: 'exit_code',
5
+ schema: {
6
+ description: 'Mark the task successful when the exit code matches.',
7
+ fields: {
8
+ expect: {
9
+ type: 'number-or-list',
10
+ default: 0,
11
+ description: 'Expected exit code, or list of acceptable codes (e.g. 0 or [0, 2]).',
12
+ placeholder: '0',
13
+ },
14
+ },
15
+ },
16
+
17
+ async check(config: Record<string, unknown>, result: TaskResult, _ctx: CompletionContext): Promise<boolean> {
18
+ const expected = config.expect ?? 0;
19
+
20
+ if (typeof expected === 'number') {
21
+ return result.exitCode === expected;
22
+ }
23
+ if (Array.isArray(expected) && expected.every((v) => typeof v === 'number')) {
24
+ return expected.includes(result.exitCode);
25
+ }
26
+ throw new Error(
27
+ `exit_code completion: "expect" must be a number or number[], got ${typeof expected}`
28
+ );
29
+ },
30
+ };
@@ -1,39 +1,60 @@
1
- import { stat } from 'node:fs/promises';
2
- import type { CompletionPlugin, CompletionContext, TaskResult } from '../types';
3
- import { validatePath } from '../utils';
4
-
5
- type Kind = 'file' | 'dir' | 'any';
6
-
7
- export const FileExistsCompletion: CompletionPlugin = {
8
- name: 'file_exists',
9
-
10
- async check(config: Record<string, unknown>, _result: TaskResult, ctx: CompletionContext): Promise<boolean> {
11
- const filePath = config.path as string;
12
- if (!filePath) throw new Error('file_exists completion: "path" is required');
13
-
14
- const safePath = validatePath(filePath, ctx.workDir);
15
-
16
- const kind = (config.kind as Kind | undefined) ?? 'any';
17
- if (kind !== 'file' && kind !== 'dir' && kind !== 'any') {
18
- throw new Error(`file_exists completion: "kind" must be "file" | "dir" | "any", got "${kind}"`);
19
- }
20
-
21
- const minSize = config.min_size;
22
- if (minSize != null && (typeof minSize !== 'number' || minSize < 0)) {
23
- throw new Error(`file_exists completion: "min_size" must be a non-negative number`);
24
- }
25
-
26
- try {
27
- const st = await stat(safePath);
28
- if (kind === 'file' && !st.isFile()) return false;
29
- if (kind === 'dir' && !st.isDirectory()) return false;
30
- if (typeof minSize === 'number' && st.isFile() && st.size < minSize) return false;
31
- return true;
32
- } catch (err: unknown) {
33
- const code = (err as NodeJS.ErrnoException).code;
34
- if (code === 'ENOENT' || code === 'ENOTDIR') return false;
35
- // Permission / IO errors should surface, not silently mean "missing"
36
- throw err;
37
- }
38
- },
39
- };
1
+ import { stat } from 'node:fs/promises';
2
+ import type { CompletionPlugin, CompletionContext, TaskResult } from '../types';
3
+ import { validatePath } from '../utils';
4
+
5
+ type Kind = 'file' | 'dir' | 'any';
6
+
7
+ export const FileExistsCompletion: CompletionPlugin = {
8
+ name: 'file_exists',
9
+ schema: {
10
+ description: 'Mark the task successful when a target file or directory exists.',
11
+ fields: {
12
+ path: {
13
+ type: 'path',
14
+ required: true,
15
+ description: 'Path to check (relative to workDir or absolute).',
16
+ },
17
+ kind: {
18
+ type: 'enum',
19
+ enum: ['file', 'dir', 'any'],
20
+ default: 'any',
21
+ description: 'Restrict to a file, directory, or accept either.',
22
+ },
23
+ min_size: {
24
+ type: 'number',
25
+ min: 0,
26
+ description: 'Optional minimum size in bytes (files only).',
27
+ },
28
+ },
29
+ },
30
+
31
+ async check(config: Record<string, unknown>, _result: TaskResult, ctx: CompletionContext): Promise<boolean> {
32
+ const filePath = config.path as string;
33
+ if (!filePath) throw new Error('file_exists completion: "path" is required');
34
+
35
+ const safePath = validatePath(filePath, ctx.workDir);
36
+
37
+ const kind = (config.kind as Kind | undefined) ?? 'any';
38
+ if (kind !== 'file' && kind !== 'dir' && kind !== 'any') {
39
+ throw new Error(`file_exists completion: "kind" must be "file" | "dir" | "any", got "${kind}"`);
40
+ }
41
+
42
+ const minSize = config.min_size;
43
+ if (minSize != null && (typeof minSize !== 'number' || minSize < 0)) {
44
+ throw new Error(`file_exists completion: "min_size" must be a non-negative number`);
45
+ }
46
+
47
+ try {
48
+ const st = await stat(safePath);
49
+ if (kind === 'file' && !st.isFile()) return false;
50
+ if (kind === 'dir' && !st.isDirectory()) return false;
51
+ if (typeof minSize === 'number' && st.isFile() && st.size < minSize) return false;
52
+ return true;
53
+ } catch (err: unknown) {
54
+ const code = (err as NodeJS.ErrnoException).code;
55
+ if (code === 'ENOENT' || code === 'ENOTDIR') return false;
56
+ // Permission / IO errors should surface, not silently mean "missing"
57
+ throw err;
58
+ }
59
+ },
60
+ };
@@ -5,6 +5,23 @@ const DEFAULT_TIMEOUT_MS = 30_000;
5
5
 
6
6
  export const OutputCheckCompletion: CompletionPlugin = {
7
7
  name: 'output_check',
8
+ schema: {
9
+ description: 'Pipe task stdout into a shell command; mark success when that command exits 0.',
10
+ fields: {
11
+ check: {
12
+ type: 'string',
13
+ required: true,
14
+ description: 'Shell command to run. Task stdout is piped to its stdin.',
15
+ placeholder: "grep -q 'PASS'",
16
+ },
17
+ timeout: {
18
+ type: 'duration',
19
+ default: '30s',
20
+ description: 'Maximum time to wait for the check command.',
21
+ placeholder: '30s',
22
+ },
23
+ },
24
+ },
8
25
 
9
26
  async check(config: Record<string, unknown>, result: TaskResult, ctx: CompletionContext): Promise<boolean> {
10
27
  const checkCmd = config.check as string;