@hilbras/omninode 2.0.0-alpha.1 → 2.0.0-alpha.11

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/dist/cli/index.js CHANGED
@@ -6,7 +6,7 @@ import { realpathSync } from "fs";
6
6
  import { Command } from "commander";
7
7
 
8
8
  // src/version.ts
9
- var OMNINODE_VERSION = "2.0.0-alpha.1";
9
+ var OMNINODE_VERSION = "2.0.0-alpha.11";
10
10
 
11
11
  // src/errors/index.ts
12
12
  var OmniNodeError = class extends Error {
@@ -44,11 +44,6 @@ var PipelineError = class extends OmniNodeError {
44
44
  super(code, message, options);
45
45
  }
46
46
  };
47
- var ProtocolError = class extends OmniNodeError {
48
- constructor(message, options = {}) {
49
- super("PROTOCOL_VIOLATION", message, options);
50
- }
51
- };
52
47
  var PlannerError = class extends OmniNodeError {
53
48
  constructor(code, message, options = {}) {
54
49
  super(code, message, options);
@@ -73,6 +68,7 @@ var providerConfigSchema = z.object({
73
68
  type: z.enum(["openai-compatible", "omnihilbras", "openrouter", "local", "custom"]),
74
69
  base_url: z.string().url(),
75
70
  api_key_env_var: z.string().min(1).optional(),
71
+ timeout_ms: z.number().int().positive().optional(),
76
72
  headers: z.record(z.string()).optional(),
77
73
  enabled: z.boolean().optional(),
78
74
  metadata: z.record(z.unknown()).optional()
@@ -88,6 +84,11 @@ var agentConfigSchema = z.object({
88
84
  input_mode: z.enum(["stdin", "arg", "protocol"]).default("stdin"),
89
85
  timeout_ms: z.number().int().positive().optional(),
90
86
  inherit_env: z.boolean().optional(),
87
+ env_policy: z.enum(["inherit", "allowlist", "denylist", "explicit"]).optional(),
88
+ env_allowlist: z.array(z.string()).optional(),
89
+ env_denylist: z.array(z.string()).optional(),
90
+ max_output_bytes: z.number().int().positive().optional(),
91
+ allow_external_cwd: z.boolean().optional(),
91
92
  metadata: z.record(z.unknown()).optional()
92
93
  }).strict();
93
94
  var roleConfigSchema = z.object({
@@ -108,7 +109,9 @@ var pipelineStepConfigSchema = z.object({
108
109
  model: z.string().optional(),
109
110
  depends_on: z.array(z.string()).optional(),
110
111
  retries: z.number().int().min(0).max(10).optional(),
111
- condition: z.enum(["always", "on-success", "on-failure"]).optional()
112
+ condition: z.enum(["always", "on-success", "on-failure"]).optional(),
113
+ /** Constraints passed to the planner for plan steps (§15). */
114
+ constraints: z.array(z.string()).optional()
112
115
  }).strict();
113
116
  var pipelineConfigSchema = z.object({
114
117
  id: z.string().min(1).regex(/^[a-z0-9][a-z0-9-]*$/, "pipeline id must be kebab-case"),
@@ -132,7 +135,9 @@ var projectConfigSchema = z.object({
132
135
  memory: z.object({
133
136
  provider: z.string().min(1).default("local"),
134
137
  base_url: z.string().url().optional(),
135
- api_key_env_var: z.string().min(1).optional()
138
+ api_key_env_var: z.string().min(1).optional(),
139
+ /** When true, memory failures fail tasks instead of being tolerated (§12). */
140
+ required: z.boolean().optional()
136
141
  }).strict().optional()
137
142
  }).strict();
138
143
  var appConfigSchema = z.object({
@@ -214,7 +219,8 @@ function toAppConfig(yaml) {
214
219
  memory: {
215
220
  provider: yaml.project.memory.provider,
216
221
  baseUrl: yaml.project.memory.base_url,
217
- apiKeyEnvVar: yaml.project.memory.api_key_env_var
222
+ apiKeyEnvVar: yaml.project.memory.api_key_env_var,
223
+ required: yaml.project.memory.required
218
224
  }
219
225
  } : {}
220
226
  },
@@ -235,7 +241,8 @@ function toPipelineDefinition(raw) {
235
241
  model: step.model,
236
242
  dependsOn: step.depends_on,
237
243
  retries: step.retries,
238
- condition: step.condition
244
+ condition: step.condition,
245
+ constraints: step.constraints
239
246
  }))
240
247
  };
241
248
  }
@@ -245,6 +252,7 @@ function toProviderConfig(raw) {
245
252
  type: raw.type,
246
253
  baseUrl: raw.base_url,
247
254
  apiKeyEnvVar: raw.api_key_env_var,
255
+ timeoutMs: raw.timeout_ms,
248
256
  headers: raw.headers,
249
257
  enabled: raw.enabled,
250
258
  metadata: raw.metadata
@@ -262,6 +270,11 @@ function toAgentConfig(raw) {
262
270
  inputMode: raw.input_mode,
263
271
  timeoutMs: raw.timeout_ms,
264
272
  inheritEnv: raw.inherit_env,
273
+ envPolicy: raw.env_policy,
274
+ envAllowlist: raw.env_allowlist,
275
+ envDenylist: raw.env_denylist,
276
+ maxOutputBytes: raw.max_output_bytes,
277
+ allowExternalCwd: raw.allow_external_cwd,
265
278
  metadata: raw.metadata
266
279
  };
267
280
  }
@@ -358,35 +371,32 @@ function authHeaders(config, env = process.env) {
358
371
  }
359
372
 
360
373
  // src/providers/http.ts
374
+ var HttpResponseError = class extends Error {
375
+ constructor(message, status, cause) {
376
+ super(message, { cause });
377
+ this.status = status;
378
+ this.name = "HttpResponseError";
379
+ }
380
+ status;
381
+ };
361
382
  var DEFAULT_TIMEOUT_MS = 3e4;
362
383
  async function requestJson(url, options = {}) {
363
384
  const { method = "GET", headers = {}, body, timeoutMs = DEFAULT_TIMEOUT_MS } = options;
364
- let response;
365
- try {
366
- response = await fetch(url, {
367
- method,
368
- headers: {
369
- accept: "application/json",
370
- ...body !== void 0 ? { "content-type": "application/json" } : {},
371
- ...headers
372
- },
373
- ...body !== void 0 ? { body: JSON.stringify(body) } : {},
374
- signal: AbortSignal.timeout(timeoutMs)
375
- });
376
- } catch (error) {
377
- const reason = error instanceof Error && error.name === "TimeoutError" ? `timed out after ${timeoutMs}ms` : error instanceof Error ? error.message : String(error);
378
- throw new ProviderError("PROVIDER_UNAVAILABLE", `Request to ${url} failed: ${reason}.`, {
379
- cause: error
380
- });
381
- }
385
+ const response = await fetch(url, {
386
+ method,
387
+ headers: {
388
+ accept: "application/json",
389
+ ...body !== void 0 ? { "content-type": "application/json" } : {},
390
+ ...headers
391
+ },
392
+ ...body !== void 0 ? { body: JSON.stringify(body) } : {},
393
+ signal: AbortSignal.timeout(timeoutMs)
394
+ });
382
395
  let parsed;
383
396
  try {
384
397
  parsed = await response.json();
385
398
  } catch (error) {
386
- throw new ProviderError("PROVIDER_UNAVAILABLE", `Response from ${url} was not valid JSON.`, {
387
- cause: error,
388
- details: { status: response.status }
389
- });
399
+ throw new HttpResponseError(`Response from ${url} was not valid JSON.`, response.status, error);
390
400
  }
391
401
  return { status: response.status, body: parsed };
392
402
  }
@@ -447,24 +457,167 @@ var Logger = class _Logger {
447
457
  };
448
458
  var logger = new Logger();
449
459
 
460
+ // src/providers/errors.ts
461
+ function kindToCode(kind) {
462
+ switch (kind) {
463
+ case "AUTHENTICATION_ERROR":
464
+ return "PROVIDER_AUTH_FAILED";
465
+ case "MODEL_NOT_FOUND":
466
+ return "MODEL_NOT_FOUND";
467
+ default:
468
+ return "PROVIDER_UNAVAILABLE";
469
+ }
470
+ }
471
+ function isRetryable(kind) {
472
+ return kind === "RATE_LIMIT_ERROR" || kind === "SERVER_ERROR" || kind === "NETWORK_ERROR";
473
+ }
474
+ function kindFromStatus(status) {
475
+ if (status === 401 || status === 403) return "AUTHENTICATION_ERROR";
476
+ if (status === 429) return "RATE_LIMIT_ERROR";
477
+ if (status === 404) return "MODEL_NOT_FOUND";
478
+ if (status === 408 || status === 504) return "TIMEOUT";
479
+ if (status >= 500) return "SERVER_ERROR";
480
+ if (status >= 400) return "INVALID_REQUEST";
481
+ return "UNKNOWN_ERROR";
482
+ }
483
+ function extractMessage(body) {
484
+ if (body === null || typeof body !== "object") return void 0;
485
+ const root = body;
486
+ const error = root.error ?? root;
487
+ const message = error.message ?? error.detail ?? root.message;
488
+ return typeof message === "string" ? message : void 0;
489
+ }
490
+ function parseRetryAfter(value, now = Date.now()) {
491
+ if (!value) return void 0;
492
+ const seconds = Number(value);
493
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1e3);
494
+ const date = Date.parse(value);
495
+ return Number.isNaN(date) ? void 0 : Math.max(0, date - now);
496
+ }
497
+ function isTimeoutError(error) {
498
+ return error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError") || typeof error?.code === "string" && error.code === "ABORT_ERR";
499
+ }
500
+ function fallbackMessage(kind, context, status) {
501
+ const { provider, operation } = context;
502
+ switch (kind) {
503
+ case "AUTHENTICATION_ERROR":
504
+ return `Authentication with provider "${provider}" failed (HTTP ${status}). Check that the referenced API key is set and valid.`;
505
+ case "RATE_LIMIT_ERROR":
506
+ return `Provider "${provider}" rate-limited ${operation} (HTTP ${status}). Retry later.`;
507
+ case "MODEL_NOT_FOUND":
508
+ return `Provider "${provider}" does not expose the requested model (HTTP ${status}).`;
509
+ case "TIMEOUT":
510
+ return `Provider "${provider}" timed out during ${operation} (HTTP ${status}).`;
511
+ case "SERVER_ERROR":
512
+ return `Provider "${provider}" returned a server error during ${operation} (HTTP ${status}).`;
513
+ case "INVALID_REQUEST":
514
+ return `Provider "${provider}" rejected ${operation} as invalid (HTTP ${status}).`;
515
+ default:
516
+ return `Provider "${provider}" failed during ${operation} (HTTP ${status}).`;
517
+ }
518
+ }
519
+ function providerHttpError(context) {
520
+ const status = context.status ?? 0;
521
+ const kind = kindFromStatus(status);
522
+ const detail = extractMessage(context.body);
523
+ const retryAfterMs = context.retryAfterMs ?? parseRetryAfter(void 0);
524
+ return new ProviderError(
525
+ context.code ?? kindToCode(kind),
526
+ detail ?? fallbackMessage(kind, context, status),
527
+ {
528
+ cause: context.cause,
529
+ details: {
530
+ kind,
531
+ provider: context.provider,
532
+ operation: context.operation,
533
+ status,
534
+ retryable: isRetryable(kind),
535
+ ...retryAfterMs !== void 0 ? { retryAfterMs } : {},
536
+ ...detail ? { providerMessage: detail } : {}
537
+ }
538
+ }
539
+ );
540
+ }
541
+ function providerTransportError(context) {
542
+ const cause = context.cause;
543
+ const kind = isTimeoutError(cause) ? "TIMEOUT" : cause instanceof SyntaxError || cause?.name === "HttpResponseError" ? "UNKNOWN_ERROR" : "NETWORK_ERROR";
544
+ const message = context.cause instanceof Error ? context.cause.message : String(context.cause);
545
+ return new ProviderError("PROVIDER_UNAVAILABLE", `Provider request failed: ${message}`, {
546
+ cause: context.cause,
547
+ details: {
548
+ kind,
549
+ provider: context.provider,
550
+ operation: context.operation,
551
+ retryable: isRetryable(kind)
552
+ }
553
+ });
554
+ }
555
+
450
556
  // src/providers/openai-compatible/index.ts
557
+ var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
451
558
  var OpenAICompatibleProvider = class {
452
559
  config;
560
+ providerId;
561
+ authentication;
453
562
  log;
454
- constructor(config, log = logger) {
563
+ /** Discovered models, so getModel() answers without another round-trip. */
564
+ discovered;
565
+ env;
566
+ constructor(config, log = logger, env = process.env) {
455
567
  this.config = { ...config, baseUrl: normalizeBaseUrl(config.baseUrl) };
568
+ this.env = env;
569
+ this.providerId = config.providerId ?? config.name;
570
+ this.authentication = {
571
+ method: config.apiKeyEnvVar ? "bearer" : "none",
572
+ ...config.apiKeyEnvVar ? { envVar: config.apiKeyEnvVar } : {},
573
+ configured: config.apiKeyEnvVar ? Boolean(env[config.apiKeyEnvVar]) : true
574
+ };
456
575
  this.log = log.child({ provider: config.name });
457
576
  }
458
- async listModels() {
459
- const { status, body } = await requestJson(`${this.config.baseUrl}/models`, {
460
- headers: authHeaders(this.config)
461
- });
462
- if (status === 401 || status === 403) {
463
- throw authFailed(this.config, status);
577
+ /** Provider-level request timeout (roadmap §6.6/§11) with the shared default. */
578
+ get requestTimeoutMs() {
579
+ return this.config.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
580
+ }
581
+ /** Authenticate + discover (roadmap §10, Model Discovery). */
582
+ async connect() {
583
+ authHeaders(this.config, this.env);
584
+ const models = await this.listModels();
585
+ this.discovered = models;
586
+ return models;
587
+ }
588
+ async getModel(modelId) {
589
+ const models = this.discovered ?? await this.connect();
590
+ return models.find((m) => m.id === modelId);
591
+ }
592
+ /** Raw /models body (protected so adapters can capture gateway-level metadata). */
593
+ async rawModelsResponse() {
594
+ const response = await this.modelsRequest();
595
+ return response.body;
596
+ }
597
+ async modelsRequest() {
598
+ let response;
599
+ try {
600
+ response = await requestJson(`${this.config.baseUrl}/models`, {
601
+ headers: authHeaders(this.config, this.env),
602
+ timeoutMs: this.requestTimeoutMs
603
+ });
604
+ } catch (error) {
605
+ if (error instanceof ProviderError) throw error;
606
+ throw providerTransportError({ provider: this.config.name, operation: "listModels", cause: error });
464
607
  }
608
+ const { status, body } = response;
465
609
  if (status !== 200) {
466
- throw unavailable(this.config, `Model discovery returned HTTP ${status}.`, status);
610
+ throw providerHttpError({
611
+ provider: this.config.name,
612
+ operation: "listModels",
613
+ status,
614
+ body
615
+ });
467
616
  }
617
+ return response;
618
+ }
619
+ async listModels() {
620
+ const body = await this.rawModelsResponse();
468
621
  const entries = extractModelEntries(body, this.config);
469
622
  const models = [];
470
623
  let skipped = 0;
@@ -473,22 +626,44 @@ var OpenAICompatibleProvider = class {
473
626
  skipped += 1;
474
627
  continue;
475
628
  }
476
- models.push({
477
- provider: this.config.name,
478
- id: entry2.id,
479
- displayName: entry2.id,
480
- status: "available",
481
- metadata: {
482
- ...typeof entry2.owned_by === "string" ? { ownedBy: entry2.owned_by } : {},
483
- ...typeof entry2.created === "number" ? { createdAt: entry2.created } : {}
484
- }
485
- });
629
+ models.push(this.toModelInfo(entry2));
486
630
  }
487
631
  if (skipped > 0) {
488
632
  this.log.warn(`Skipped ${skipped} model entr(y/ies) without a valid id.`);
489
633
  }
490
634
  return models;
491
635
  }
636
+ /** Maps a raw model entry onto the normalized metadata shape (§10). */
637
+ toModelInfo(entry2) {
638
+ const parameters = Array.isArray(entry2.supported_parameters) ? entry2.supported_parameters.filter((p) => typeof p === "string") : void 0;
639
+ const contextWindow = typeof entry2.context_length === "number" ? entry2.context_length : typeof entry2.context_window === "number" ? entry2.context_window : void 0;
640
+ const inputTypes = normalizeModalities(entry2.input_modalities);
641
+ const outputTypes = normalizeModalities(entry2.output_modalities);
642
+ return {
643
+ provider: this.config.name,
644
+ id: entry2.id,
645
+ displayName: entry2.id,
646
+ ...typeof entry2.name === "string" ? { name: entry2.name } : {},
647
+ status: "available",
648
+ ...contextWindow !== void 0 ? { contextWindow } : {},
649
+ ...inputTypes !== void 0 ? { inputTypes } : {},
650
+ ...outputTypes !== void 0 ? { outputTypes } : {},
651
+ ...parameters !== void 0 ? {
652
+ supportsTools: parameters.includes("tools") || parameters.includes("tool_choice"),
653
+ supportsStructuredOutput: parameters.includes("response_format"),
654
+ supportsStreaming: parameters.includes("stream")
655
+ } : {},
656
+ metadata: {
657
+ ...typeof entry2.owned_by === "string" ? { ownedBy: entry2.owned_by } : {},
658
+ ...typeof entry2.created === "number" ? { createdAt: entry2.created } : {},
659
+ ...parameters !== void 0 ? { supportedParameters: parameters } : {}
660
+ }
661
+ };
662
+ }
663
+ /** Streaming where the gateway supports it (§11). */
664
+ stream(request, onChunk) {
665
+ return streamChatCompletion(this.config, request, onChunk, this.env);
666
+ }
492
667
  async healthCheck() {
493
668
  const lastCheckedAt = (/* @__PURE__ */ new Date()).toISOString();
494
669
  try {
@@ -518,27 +693,35 @@ var OpenAICompatibleProvider = class {
518
693
  }
519
694
  }
520
695
  async chat(request) {
521
- const { status, body } = await requestJson(`${this.config.baseUrl}/chat/completions`, {
522
- method: "POST",
523
- headers: authHeaders(this.config),
524
- body: {
525
- model: request.model,
526
- messages: request.messages,
527
- ...request.temperature !== void 0 ? { temperature: request.temperature } : {},
528
- ...request.maxTokens !== void 0 ? { max_tokens: request.maxTokens } : {},
529
- ...request.stop !== void 0 ? { stop: request.stop } : {}
530
- }
531
- });
532
- if (status === 401 || status === 403) throw authFailed(this.config, status);
533
- if (status === 404) {
534
- throw new ProviderError(
535
- "MODEL_NOT_FOUND",
536
- `Model "${request.model}" or the chat endpoint was not found on provider "${this.config.name}" (HTTP 404).`,
537
- { details: { status } }
538
- );
696
+ let response;
697
+ try {
698
+ response = await requestJson(`${this.config.baseUrl}/chat/completions`, {
699
+ method: "POST",
700
+ headers: authHeaders(this.config, this.env),
701
+ timeoutMs: this.requestTimeoutMs,
702
+ body: {
703
+ model: request.model,
704
+ messages: request.messages,
705
+ ...request.temperature !== void 0 ? { temperature: request.temperature } : {},
706
+ ...request.maxTokens !== void 0 ? { max_tokens: request.maxTokens } : {},
707
+ ...request.stop !== void 0 ? { stop: request.stop } : {}
708
+ }
709
+ });
710
+ } catch (error) {
711
+ if (error instanceof ProviderError) throw error;
712
+ throw providerTransportError({ provider: this.config.name, operation: "chat", cause: error });
539
713
  }
714
+ const { status, body } = response;
540
715
  if (status !== 200) {
541
- throw unavailable(this.config, `Chat completion returned HTTP ${status}.`, status);
716
+ throw providerHttpError({
717
+ provider: this.config.name,
718
+ operation: "chat",
719
+ status,
720
+ body,
721
+ ...status === 404 ? {
722
+ code: "MODEL_NOT_FOUND"
723
+ } : {}
724
+ });
542
725
  }
543
726
  const payload = body;
544
727
  const choices = Array.isArray(payload.choices) ? payload.choices : [];
@@ -560,6 +743,81 @@ var OpenAICompatibleProvider = class {
560
743
  };
561
744
  }
562
745
  };
746
+ async function streamChatCompletion(config, request, onChunk, env = process.env) {
747
+ const baseUrl = normalizeBaseUrl(config.baseUrl);
748
+ const timeoutMs = config.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
749
+ let response;
750
+ try {
751
+ response = await fetch(`${baseUrl}/chat/completions`, {
752
+ method: "POST",
753
+ headers: {
754
+ accept: "text/event-stream",
755
+ "content-type": "application/json",
756
+ ...authHeaders(config, env)
757
+ },
758
+ body: JSON.stringify({
759
+ model: request.model,
760
+ messages: request.messages,
761
+ stream: true,
762
+ ...request.temperature !== void 0 ? { temperature: request.temperature } : {},
763
+ ...request.maxTokens !== void 0 ? { max_tokens: request.maxTokens } : {}
764
+ }),
765
+ signal: AbortSignal.timeout(timeoutMs)
766
+ });
767
+ } catch (error) {
768
+ throw providerTransportError({ provider: config.name, operation: "stream", cause: error });
769
+ }
770
+ if (!response.ok) {
771
+ const body = await response.json().catch(() => void 0);
772
+ throw providerHttpError({ provider: config.name, operation: "stream", status: response.status, body });
773
+ }
774
+ if (!response.body) {
775
+ throw providerHttpError({
776
+ provider: config.name,
777
+ operation: "stream",
778
+ status: 500,
779
+ body: { error: { message: "streaming is not supported by this endpoint" } }
780
+ });
781
+ }
782
+ let content = "";
783
+ let model = request.model;
784
+ let finishReason;
785
+ const decoder = new TextDecoder();
786
+ let buffer = "";
787
+ for await (const piece of response.body) {
788
+ buffer += decoder.decode(piece, { stream: true });
789
+ let newline = buffer.indexOf("\n");
790
+ while (newline >= 0) {
791
+ const line = buffer.slice(0, newline).trim();
792
+ buffer = buffer.slice(newline + 1);
793
+ newline = buffer.indexOf("\n");
794
+ if (line.length === 0 || line.startsWith(":")) continue;
795
+ const data = line.startsWith("data:") ? line.slice(5).trim() : line;
796
+ if (data === "[DONE]") continue;
797
+ let event;
798
+ try {
799
+ event = JSON.parse(data);
800
+ } catch {
801
+ continue;
802
+ }
803
+ if (typeof event.model === "string") model = event.model;
804
+ const choices = Array.isArray(event.choices) ? event.choices : [];
805
+ const first = choices[0];
806
+ const delta = typeof first?.delta?.content === "string" ? first.delta.content : "";
807
+ if (delta.length > 0) content += delta;
808
+ if (typeof first?.finish_reason === "string") finishReason = first.finish_reason;
809
+ onChunk({ delta, ...finishReason !== void 0 ? { finishReason } : {}, raw: event });
810
+ }
811
+ }
812
+ return { model, content, ...finishReason !== void 0 ? { finishReason } : {} };
813
+ }
814
+ function normalizeModalities(value) {
815
+ const list = Array.isArray(value) ? value : typeof value === "string" ? [value] : void 0;
816
+ if (!list) return void 0;
817
+ const allowed = /* @__PURE__ */ new Set(["text", "image", "audio", "video", "embedding"]);
818
+ const modalities = list.filter((m) => typeof m === "string" && allowed.has(m));
819
+ return modalities.length > 0 ? modalities : void 0;
820
+ }
563
821
  function normalizeBaseUrl(baseUrl) {
564
822
  return baseUrl.replace(/\/+$/, "");
565
823
  }
@@ -574,14 +832,6 @@ function extractModelEntries(body, config) {
574
832
  { details: { endpoint: "models" } }
575
833
  );
576
834
  }
577
- function authFailed(config, status) {
578
- const envHint = config.apiKeyEnvVar ? ` Check that $${config.apiKeyEnvVar} is set and valid.` : " The provider requires authentication; configure api_key_env_var for it.";
579
- return new ProviderError(
580
- "PROVIDER_AUTH_FAILED",
581
- `Authentication with provider "${config.name}" failed (HTTP ${status}).${envHint}`,
582
- { details: { status } }
583
- );
584
- }
585
835
  function unavailable(config, message, status) {
586
836
  return new ProviderError("PROVIDER_UNAVAILABLE", `Provider "${config.name}": ${message}`, {
587
837
  details: { status }
@@ -594,11 +844,33 @@ function inferHealth(error) {
594
844
 
595
845
  // src/providers/omnihilbras/index.ts
596
846
  var OmniHilbrasProvider = class extends OpenAICompatibleProvider {
847
+ gateway;
848
+ async listModels() {
849
+ const raw = await this.rawModelsResponse();
850
+ await this.extractGatewayMetadata(raw);
851
+ const models = await super.listModels();
852
+ return this.gateway ? models.map((model) => ({ ...model, metadata: { ...model.metadata, gateway: this.gateway } })) : models;
853
+ }
597
854
  /**
598
855
  * Run the full connect flow. Throws on authentication or transport failure
599
856
  * (use healthCheck() for a non-throwing probe).
600
857
  */
601
- async connect(registry) {
858
+ /**
859
+ * Gateway metadata reported alongside the model list (gateway version, tier,
860
+ * region…), kept separate from per-model metadata (§11 — provider metadata).
861
+ */
862
+ gatewayMetadata() {
863
+ return this.gateway;
864
+ }
865
+ async extractGatewayMetadata(body) {
866
+ if (body !== null && typeof body === "object") {
867
+ const gateway = body.gateway;
868
+ if (gateway !== null && typeof gateway === "object") {
869
+ this.gateway = gateway;
870
+ }
871
+ }
872
+ }
873
+ async connectAndRegister(registry) {
602
874
  const apiKey = resolveApiKey(this.config);
603
875
  const discovered = await this.listModels();
604
876
  const seen = /* @__PURE__ */ new Set();
@@ -822,7 +1094,7 @@ async function testProvider(name, options = {}) {
822
1094
  if (options.connect && provider instanceof OmniHilbrasProvider) {
823
1095
  const registry = new ModelRegistry();
824
1096
  try {
825
- const result = await provider.connect(registry);
1097
+ const result = await provider.connectAndRegister(registry);
826
1098
  printProviderStatus(result.status);
827
1099
  console.log(` registered: ${result.registered}`);
828
1100
  } catch (error) {
@@ -897,19 +1169,416 @@ function registerModelsCommand(program) {
897
1169
 
898
1170
  // src/agents/process/index.ts
899
1171
  import { spawn } from "child_process";
900
- var DEFAULT_TIMEOUT_MS2 = 6e5;
901
- var KILL_GRACE_MS = 5e3;
1172
+
1173
+ // src/agent-protocol/version.ts
1174
+ var PROTOCOL_V2 = "omninode-agent-protocol/2";
1175
+ var SUPPORTED_PROTOCOL_VERSIONS = [
1176
+ "omninode-agent-protocol/1",
1177
+ PROTOCOL_V2
1178
+ ];
1179
+ var MAX_MESSAGE_BYTES = 1048576;
1180
+ function isSupportedProtocol(protocol) {
1181
+ return SUPPORTED_PROTOCOL_VERSIONS.includes(protocol);
1182
+ }
1183
+
1184
+ // src/agent-protocol/types.ts
1185
+ function newMessageId(prefix = "msg") {
1186
+ const random = Math.random().toString(36).slice(2, 10);
1187
+ return `${prefix}-${Date.now().toString(36)}-${random}`;
1188
+ }
1189
+ function envelope(type, payload, ids = {}) {
1190
+ return {
1191
+ protocol: PROTOCOL_V2,
1192
+ messageId: newMessageId(),
1193
+ ...ids.requestId !== void 0 ? { requestId: ids.requestId } : {},
1194
+ ...ids.taskId !== void 0 ? { taskId: ids.taskId } : {},
1195
+ ...ids.agentId !== void 0 ? { agentId: ids.agentId } : {},
1196
+ ...ids.pipelineId !== void 0 ? { pipelineId: ids.pipelineId } : {},
1197
+ ...ids.executionId !== void 0 ? { executionId: ids.executionId } : {},
1198
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1199
+ type,
1200
+ payload,
1201
+ ...ids.metadata !== void 0 ? { metadata: ids.metadata } : {}
1202
+ };
1203
+ }
1204
+
1205
+ // src/agent-protocol/codec.ts
1206
+ var KNOWN_TYPES = /* @__PURE__ */ new Set([
1207
+ // node → agent
1208
+ "TASK",
1209
+ "HELLO_ACK",
1210
+ "CONTEXT",
1211
+ "INSTRUCTION",
1212
+ "RESPONSE",
1213
+ "CANCEL",
1214
+ // agent → node
1215
+ "HELLO",
1216
+ "TASK_REQUEST",
1217
+ "TASK_ACCEPTED",
1218
+ "TASK_STARTED",
1219
+ "STATUS",
1220
+ "QUESTION",
1221
+ "REPORT",
1222
+ "ARTIFACT",
1223
+ "COMPLETION",
1224
+ "ERROR",
1225
+ "CANCELLED"
1226
+ ]);
1227
+ var PAYLOAD_REQUIRED_TYPES = /* @__PURE__ */ new Set(["TASK", "REPORT", "COMPLETION", "ERROR", "QUESTION", "ARTIFACT", "HELLO"]);
1228
+ function validateEnvelope(value, line) {
1229
+ const sample = line?.slice(0, 200);
1230
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
1231
+ return { ok: false, error: { code: "NOT_AN_OBJECT", message: "message must be a JSON object", ...sample ? { sample } : {} } };
1232
+ }
1233
+ const raw = value;
1234
+ const protocol = raw.protocol;
1235
+ if (protocol === void 0) {
1236
+ const type = raw.type;
1237
+ if (typeof type !== "string" || !KNOWN_TYPES.has(type)) {
1238
+ return { ok: false, error: { code: "UNKNOWN_TYPE", message: `unknown message type ${String(type)}`, ...sample ? { sample } : {} } };
1239
+ }
1240
+ return {
1241
+ ok: true,
1242
+ legacy: true,
1243
+ envelope: {
1244
+ protocol: "omninode-agent-protocol/1",
1245
+ messageId: typeof raw.messageId === "string" ? raw.messageId : "legacy",
1246
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1247
+ type,
1248
+ payload: raw.payload
1249
+ }
1250
+ };
1251
+ }
1252
+ if (typeof protocol !== "string" || !isSupportedProtocol(protocol)) {
1253
+ return {
1254
+ ok: false,
1255
+ error: { code: "UNSUPPORTED_VERSION", message: `unsupported protocol "${String(protocol)}"`, ...sample ? { sample } : {} }
1256
+ };
1257
+ }
1258
+ if (raw.type === void 0) {
1259
+ return { ok: false, error: { code: "MISSING_FIELD", message: "message is missing 'type'", ...sample ? { sample } : {} } };
1260
+ }
1261
+ if (typeof raw.type !== "string" || !KNOWN_TYPES.has(raw.type)) {
1262
+ return { ok: false, error: { code: "UNKNOWN_TYPE", message: `unknown message type ${String(raw.type)}`, ...sample ? { sample } : {} } };
1263
+ }
1264
+ if (raw.timestamp !== void 0 && typeof raw.timestamp !== "string") {
1265
+ return { ok: false, error: { code: "MISSING_FIELD", message: "'timestamp' must be a string", ...sample ? { sample } : {} } };
1266
+ }
1267
+ if (PAYLOAD_REQUIRED_TYPES.has(raw.type) && (raw.payload === void 0 || raw.payload === null)) {
1268
+ return { ok: false, error: { code: "INVALID_PAYLOAD", message: `${raw.type} requires a payload`, ...sample ? { sample } : {} } };
1269
+ }
1270
+ return {
1271
+ ok: true,
1272
+ legacy: protocol !== PROTOCOL_V2,
1273
+ envelope: {
1274
+ protocol,
1275
+ messageId: typeof raw.messageId === "string" ? raw.messageId : "unknown",
1276
+ ...typeof raw.requestId === "string" ? { requestId: raw.requestId } : {},
1277
+ ...typeof raw.taskId === "string" ? { taskId: raw.taskId } : {},
1278
+ ...typeof raw.agentId === "string" ? { agentId: raw.agentId } : {},
1279
+ ...typeof raw.pipelineId === "string" ? { pipelineId: raw.pipelineId } : {},
1280
+ ...typeof raw.executionId === "string" ? { executionId: raw.executionId } : {},
1281
+ timestamp: typeof raw.timestamp === "string" ? raw.timestamp : (/* @__PURE__ */ new Date()).toISOString(),
1282
+ type: raw.type,
1283
+ payload: raw.payload,
1284
+ ...raw.metadata !== null && typeof raw.metadata === "object" ? { metadata: raw.metadata } : {}
1285
+ }
1286
+ };
1287
+ }
1288
+ function decodeMessage(line, maxBytes = MAX_MESSAGE_BYTES) {
1289
+ const trimmed = line.trim();
1290
+ if (trimmed.length === 0) {
1291
+ return { ok: false, error: { code: "INVALID_JSON", message: "empty line" } };
1292
+ }
1293
+ if (trimmed.length > maxBytes) {
1294
+ return {
1295
+ ok: false,
1296
+ error: { code: "OVERSIZED", message: `message of ${trimmed.length} bytes exceeds the ${maxBytes} byte limit`, sample: trimmed.slice(0, 200) }
1297
+ };
1298
+ }
1299
+ let value;
1300
+ try {
1301
+ value = JSON.parse(trimmed);
1302
+ } catch (error) {
1303
+ return {
1304
+ ok: false,
1305
+ error: { code: "INVALID_JSON", message: error instanceof Error ? error.message : "invalid JSON", sample: trimmed.slice(0, 200) }
1306
+ };
1307
+ }
1308
+ return validateEnvelope(value, trimmed);
1309
+ }
1310
+ function encodeMessage(envelope2, maxBytes = MAX_MESSAGE_BYTES) {
1311
+ const line = JSON.stringify(envelope2);
1312
+ if (line.length > maxBytes) {
1313
+ throw new Error(`outbound protocol message of ${line.length} bytes exceeds the ${maxBytes} byte limit`);
1314
+ }
1315
+ return line;
1316
+ }
1317
+ var ProtocolDecoder = class {
1318
+ buffer = "";
1319
+ maxBytes;
1320
+ constructor(maxBytes = MAX_MESSAGE_BYTES) {
1321
+ this.maxBytes = maxBytes;
1322
+ }
1323
+ push(chunk) {
1324
+ this.buffer += chunk;
1325
+ const results = [];
1326
+ let index = this.buffer.indexOf("\n");
1327
+ while (index >= 0) {
1328
+ const line = this.buffer.slice(0, index);
1329
+ this.buffer = this.buffer.slice(index + 1);
1330
+ if (line.trim().length > 0) results.push(decodeMessage(line, this.maxBytes));
1331
+ index = this.buffer.indexOf("\n");
1332
+ }
1333
+ if (this.buffer.length > this.maxBytes) {
1334
+ results.push({
1335
+ ok: false,
1336
+ error: { code: "OVERSIZED", message: `line exceeded ${this.maxBytes} bytes without a newline`, sample: this.buffer.slice(0, 200) }
1337
+ });
1338
+ this.buffer = "";
1339
+ }
1340
+ return results;
1341
+ }
1342
+ /** Flush any trailing line when the process exits. */
1343
+ flush() {
1344
+ if (this.buffer.trim().length === 0) {
1345
+ this.buffer = "";
1346
+ return [];
1347
+ }
1348
+ const result = decodeMessage(this.buffer, this.maxBytes);
1349
+ this.buffer = "";
1350
+ return [result];
1351
+ }
1352
+ };
1353
+
1354
+ // src/agent-protocol/handshake.ts
1355
+ function parseHello(envelopeValue) {
1356
+ if (envelopeValue.type !== "HELLO") {
1357
+ return { ok: false, reason: `expected HELLO, received ${envelopeValue.type}` };
1358
+ }
1359
+ const payload = envelopeValue.payload;
1360
+ if (payload === null || typeof payload !== "object") {
1361
+ return { ok: false, reason: "HELLO payload must be an object" };
1362
+ }
1363
+ const p = payload;
1364
+ if (typeof p.name !== "string" || p.name.length === 0) {
1365
+ return { ok: false, reason: "HELLO payload is missing 'name'" };
1366
+ }
1367
+ return {
1368
+ ok: true,
1369
+ descriptor: {
1370
+ protocol: envelopeValue.protocol,
1371
+ name: p.name,
1372
+ ...typeof p.version === "string" ? { version: p.version } : {},
1373
+ capabilities: Array.isArray(p.capabilities) ? p.capabilities.filter((c) => typeof c === "string") : [],
1374
+ supports: Array.isArray(p.supports) ? p.supports.filter((s) => typeof s === "string") : []
1375
+ }
1376
+ };
1377
+ }
1378
+ function buildHelloAck(descriptor) {
1379
+ return envelope("HELLO_ACK", {
1380
+ ok: true,
1381
+ protocol: PROTOCOL_V2,
1382
+ agent: descriptor.name,
1383
+ ...descriptor.version !== void 0 ? { agentVersion: descriptor.version } : {},
1384
+ supportedProtocols: [PROTOCOL_V2]
1385
+ }, { agentId: descriptor.name });
1386
+ }
1387
+
1388
+ // src/agent-protocol/session.ts
1389
+ var ProtocolSession = class {
1390
+ decoder;
1391
+ ids;
1392
+ responder;
1393
+ violations = [];
1394
+ questions = [];
1395
+ artifacts = [];
1396
+ reports = [];
1397
+ descriptor;
1398
+ completionSummary;
1399
+ errorMessage;
1400
+ constructor(ids, options = {}) {
1401
+ this.ids = ids;
1402
+ this.responder = options.responder;
1403
+ this.decoder = new ProtocolDecoder(options.maxBytes);
1404
+ }
1405
+ /** The v2 messages OmniNode sends to open a task (written to the agent's stdin). */
1406
+ taskMessages(objective, role, context, instruction) {
1407
+ const ids = this.ids;
1408
+ const messages = [envelope("TASK", { taskId: ids.taskId, objective }, ids)];
1409
+ if (role !== void 0) messages.push(envelope("CONTEXT", { role }, ids));
1410
+ if (context !== void 0 && context.length > 0) messages.push(envelope("CONTEXT", { body: context }, ids));
1411
+ if (instruction !== void 0 && instruction.length > 0) messages.push(envelope("INSTRUCTION", { text: instruction }, ids));
1412
+ return messages.map((m) => encodeMessage(m));
1413
+ }
1414
+ helloAck(descriptor) {
1415
+ return encodeMessage(buildHelloAck(descriptor));
1416
+ }
1417
+ /** Consumes decoded stdout, answering questions as they arrive. */
1418
+ async handle(decoded) {
1419
+ if (!decoded.ok) {
1420
+ this.violations.push(decoded.error);
1421
+ return;
1422
+ }
1423
+ const message = decoded.envelope;
1424
+ if (message.protocol === PROTOCOL_V2) this.sawV2 = true;
1425
+ if (message.type === "HELLO") {
1426
+ const hello = parseHello(message);
1427
+ if (hello.ok) this.descriptor = hello.descriptor;
1428
+ else this.violations.push({ code: "INVALID_PAYLOAD", message: `bad HELLO: ${hello.reason}` });
1429
+ return;
1430
+ }
1431
+ if (message.type === "QUESTION") {
1432
+ await this.answerQuestion(message);
1433
+ return;
1434
+ }
1435
+ if (message.type === "REPORT") {
1436
+ this.reports.push(message.payload);
1437
+ return;
1438
+ }
1439
+ if (message.type === "ARTIFACT") {
1440
+ this.artifacts.push(message.payload);
1441
+ return;
1442
+ }
1443
+ if (message.type === "COMPLETION") {
1444
+ const payload = message.payload;
1445
+ this.completionSummary = typeof payload?.summary === "string" ? payload.summary : "";
1446
+ return;
1447
+ }
1448
+ if (message.type === "ERROR") {
1449
+ const payload = message.payload;
1450
+ const detail = typeof payload?.message === "string" ? payload.message : "agent reported an error";
1451
+ const code = typeof payload?.code === "string" ? ` [${payload.code}]` : "";
1452
+ this.errorMessage = `Agent error${code}: ${detail}`;
1453
+ return;
1454
+ }
1455
+ if (message.type === "TASK_ACCEPTED" || message.type === "TASK_STARTED" || message.type === "STATUS") {
1456
+ return;
1457
+ }
1458
+ this.violations.push({
1459
+ code: "UNKNOWN_TYPE",
1460
+ message: `unexpected message type ${message.type} from agent`
1461
+ });
1462
+ }
1463
+ async answerQuestion(message) {
1464
+ const payload = message.payload ?? {};
1465
+ const questionId = typeof payload.questionId === "string" ? payload.questionId : message.messageId;
1466
+ const question = typeof payload.question === "string" ? payload.question : "";
1467
+ const expected = typeof payload.expected === "string" ? payload.expected : "text";
1468
+ let answer;
1469
+ if (this.responder) {
1470
+ try {
1471
+ answer = await this.responder({ questionId, question, expected });
1472
+ } catch (error) {
1473
+ this.violations.push({
1474
+ code: "INVALID_PAYLOAD",
1475
+ message: `question responder failed: ${error instanceof Error ? error.message : String(error)}`
1476
+ });
1477
+ }
1478
+ }
1479
+ this.questions.push({ questionId, question, ...answer !== void 0 ? { answer } : {} });
1480
+ this.pendingAnswers.push(
1481
+ encodeMessage(
1482
+ envelope("RESPONSE", { questionId, ...answer !== void 0 ? { body: answer } : {} }, {
1483
+ ...this.ids,
1484
+ requestId: message.requestId ?? this.ids.requestId
1485
+ })
1486
+ )
1487
+ );
1488
+ }
1489
+ pendingAnswers = [];
1490
+ /** True once any inbound message carried the v2 protocol field. */
1491
+ sawV2 = false;
1492
+ };
1493
+
1494
+ // src/agents/cwd.ts
1495
+ import { statSync } from "fs";
1496
+ import { isAbsolute, relative, resolve } from "path";
1497
+ function validateWorkingDirectory(cwd, options = {}) {
1498
+ if (!cwd) return void 0;
1499
+ const normalized = isAbsolute(cwd) ? resolve(cwd) : resolve(process.cwd(), cwd);
1500
+ let stats;
1501
+ try {
1502
+ stats = statSync(normalized);
1503
+ } catch (error) {
1504
+ throw new AgentError(
1505
+ "AGENT_NOT_FOUND",
1506
+ `Working directory "${normalized}" does not exist or is not accessible.`,
1507
+ { cause: error, details: { cwd: normalized } }
1508
+ );
1509
+ }
1510
+ if (!stats.isDirectory()) {
1511
+ throw new AgentError("AGENT_NOT_FOUND", `Working directory "${normalized}" is not a directory.`, {
1512
+ details: { cwd: normalized }
1513
+ });
1514
+ }
1515
+ try {
1516
+ statSync(resolve(normalized, "."));
1517
+ } catch (error) {
1518
+ throw new AgentError(
1519
+ "AGENT_NOT_FOUND",
1520
+ `Working directory "${normalized}" is not accessible.`,
1521
+ { cause: error }
1522
+ );
1523
+ }
1524
+ const projectRoot = resolve(options.projectRoot ?? process.cwd());
1525
+ const rel = relative(projectRoot, normalized);
1526
+ const outsideProject = rel.startsWith("..") || isAbsolute(rel);
1527
+ if (outsideProject && options.allowExternal !== true) {
1528
+ throw new AgentError(
1529
+ "AGENT_NOT_FOUND",
1530
+ `Working directory "${normalized}" is outside the project root "${projectRoot}". Set allow_external_cwd: true on the agent to permit this.`,
1531
+ { details: { cwd: normalized, projectRoot } }
1532
+ );
1533
+ }
1534
+ return { cwd: normalized, outsideProject };
1535
+ }
1536
+
1537
+ // src/agents/env.ts
1538
+ var BASELINE_KEYS = ["PATH", "HOME"];
1539
+ function resolveEnvPolicy(config) {
1540
+ if (config.envPolicy !== void 0) return config.envPolicy;
1541
+ if (config.inheritEnv === false) return "explicit";
1542
+ return "inherit";
1543
+ }
902
1544
  function buildChildEnv(config) {
903
1545
  const configured = config.env ?? {};
904
- if (config.inheritEnv !== false) {
1546
+ const policy = resolveEnvPolicy(config);
1547
+ if (policy === "inherit") {
905
1548
  return { ...process.env, ...configured };
906
1549
  }
907
- return {
908
- PATH: process.env.PATH ?? "",
909
- HOME: process.env.HOME ?? "",
910
- ...configured
911
- };
1550
+ const base = {};
1551
+ for (const key of BASELINE_KEYS) {
1552
+ const value = process.env[key];
1553
+ if (value !== void 0) base[key] = value;
1554
+ }
1555
+ switch (policy) {
1556
+ case "explicit":
1557
+ return { ...base, ...configured };
1558
+ case "allowlist": {
1559
+ const allowed = { ...base };
1560
+ for (const key of config.envAllowlist ?? []) {
1561
+ const value = process.env[key];
1562
+ if (value !== void 0) allowed[key] = value;
1563
+ }
1564
+ return { ...allowed, ...configured };
1565
+ }
1566
+ case "denylist": {
1567
+ const denied = new Set((config.envDenylist ?? []).map((k) => k.toUpperCase()));
1568
+ const filtered = {};
1569
+ for (const [key, value] of Object.entries(process.env)) {
1570
+ if (denied.has(key.toUpperCase())) continue;
1571
+ filtered[key] = value;
1572
+ }
1573
+ return { ...filtered, ...configured };
1574
+ }
1575
+ }
912
1576
  }
1577
+
1578
+ // src/agents/process/index.ts
1579
+ var DEFAULT_TIMEOUT_MS2 = 6e5;
1580
+ var KILL_GRACE_MS = 5e3;
1581
+ var DEFAULT_MAX_OUTPUT_BYTES = 5 * 1024 * 1024;
913
1582
  function composeTaskText(input) {
914
1583
  const sections = [`# Objective
915
1584
  ${input.objective}`];
@@ -930,18 +1599,19 @@ ${input.instruction}`);
930
1599
  return sections.join("\n\n");
931
1600
  }
932
1601
  function buildProtocolMessages(input, agentName) {
933
- const meta = {
1602
+ const session = new ProtocolSession({
1603
+ requestId: `req-${input.taskId}`,
934
1604
  taskId: input.taskId,
935
- agent: agentName,
936
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
937
- };
938
- const messages = [
939
- { type: "TASK", payload: { taskId: input.taskId, objective: input.objective } }
940
- ];
941
- if (input.role) messages.push({ type: "ROLE", payload: input.role });
942
- if (input.context) messages.push({ type: "CONTEXT", payload: { body: input.context } });
943
- if (input.instruction) messages.push({ type: "INSTRUCTION", payload: { text: input.instruction } });
944
- return messages.map((message) => JSON.stringify({ meta, message }));
1605
+ agentId: agentName,
1606
+ ...input.pipelineId !== void 0 ? { pipelineId: input.pipelineId } : {},
1607
+ ...input.executionId !== void 0 ? { executionId: input.executionId } : {}
1608
+ });
1609
+ return session.taskMessages(
1610
+ input.objective,
1611
+ input.role,
1612
+ input.context,
1613
+ input.instruction
1614
+ );
945
1615
  }
946
1616
  var ProcessAgent = class {
947
1617
  config;
@@ -970,40 +1640,73 @@ var ProcessAgent = class {
970
1640
  }
971
1641
  const mode = this.config.inputMode ?? "stdin";
972
1642
  const timeoutMs = this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
973
- return new Promise((resolve, reject) => {
1643
+ return new Promise((resolve2, reject) => {
1644
+ let cwd;
1645
+ try {
1646
+ cwd = validateWorkingDirectory(this.config.cwd, {
1647
+ allowExternal: this.config.allowExternalCwd
1648
+ })?.cwd;
1649
+ } catch (error) {
1650
+ reject(error);
1651
+ return;
1652
+ }
974
1653
  const child = spawn(
975
1654
  command,
976
1655
  [...this.config.args ?? [], ...mode === "arg" ? [composeTaskText(input)] : []],
977
1656
  {
978
- cwd: this.config.cwd,
1657
+ ...cwd !== void 0 ? { cwd } : {},
979
1658
  env: buildChildEnv(this.config),
980
- stdio: ["pipe", "pipe", "pipe"]
1659
+ stdio: ["pipe", "pipe", "pipe"],
1660
+ // Own process group so the whole tree can be terminated together
1661
+ // (roadmap §8: no orphaned children).
1662
+ ...process.platform === "win32" ? {} : { detached: true }
981
1663
  }
982
1664
  );
983
1665
  this.running.set(input.taskId, child);
1666
+ GLOBAL_RUNNING.add(child);
1667
+ installSignalForwarding();
984
1668
  let stdout = "";
985
1669
  let stderr = "";
986
1670
  let timedOut = false;
1671
+ let outputLimitExceeded = false;
987
1672
  let settled = false;
1673
+ const maxOutputBytes = this.config.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
988
1674
  const timer = setTimeout(() => {
989
1675
  timedOut = true;
990
- this.log.warn(`Task ${input.taskId} exceeded ${timeoutMs}ms \u2014 killing process.`);
991
- child.kill("SIGTERM");
992
- setTimeout(() => child.kill("SIGKILL"), KILL_GRACE_MS).unref();
1676
+ this.log.warn(`Task ${input.taskId} exceeded ${timeoutMs}ms \u2014 killing process tree.`);
1677
+ killTree(child, "SIGTERM");
1678
+ setTimeout(() => killTree(child, "SIGKILL"), KILL_GRACE_MS).unref();
993
1679
  }, timeoutMs);
994
1680
  const settle = (finish) => {
995
1681
  if (settled) return;
996
1682
  settled = true;
997
1683
  clearTimeout(timer);
998
1684
  this.running.delete(input.taskId);
1685
+ GLOBAL_RUNNING.delete(child);
999
1686
  finish();
1000
1687
  };
1001
- child.stdout?.on("data", (chunk) => {
1002
- stdout += chunk.toString();
1003
- });
1004
- child.stderr?.on("data", (chunk) => {
1005
- stderr += chunk.toString();
1006
- });
1688
+ const collect = (into, chunk) => {
1689
+ if (outputLimitExceeded) return;
1690
+ const text = sanitizeChunk(chunk);
1691
+ if (into === "stdout") {
1692
+ if (stdout.length + text.length > maxOutputBytes) {
1693
+ stdout = (stdout + text).slice(0, maxOutputBytes);
1694
+ outputLimitExceeded = true;
1695
+ this.log.warn(`Agent "${this.config.name}" exceeded the ${maxOutputBytes} byte output limit \u2014 killing it.`);
1696
+ killTree(child, "SIGKILL");
1697
+ return;
1698
+ }
1699
+ stdout += text;
1700
+ } else {
1701
+ if (stderr.length + text.length > maxOutputBytes) {
1702
+ stderr = (stderr + text).slice(0, maxOutputBytes);
1703
+ return;
1704
+ }
1705
+ stderr += text;
1706
+ }
1707
+ };
1708
+ child.stdout?.on("data", (chunk) => collect("stdout", chunk));
1709
+ child.stderr?.on("data", (chunk) => collect("stderr", chunk));
1007
1710
  child.stdin?.on("error", () => {
1008
1711
  });
1009
1712
  if (mode !== "arg") {
@@ -1037,7 +1740,19 @@ var ProcessAgent = class {
1037
1740
  new AgentError(
1038
1741
  "AGENT_TIMEOUT",
1039
1742
  `Agent "${this.config.name}" timed out after ${timeoutMs}ms and was killed.`,
1040
- { details: { taskId: input.taskId, stdout: stdout.slice(0, 4e3), stderr: stderr.slice(0, 2e3) } }
1743
+ {
1744
+ details: {
1745
+ operation: "agent.run",
1746
+ agent: this.config.name,
1747
+ durationMs: timeoutMs,
1748
+ // The process was started and may have performed side
1749
+ // effects before being killed — the outcome is unknown,
1750
+ // not failed (roadmap §6.3).
1751
+ dispatched: true,
1752
+ stdout: stdout.slice(0, 4e3),
1753
+ stderr: stderr.slice(0, 2e3)
1754
+ }
1755
+ }
1041
1756
  )
1042
1757
  );
1043
1758
  return;
@@ -1045,7 +1760,7 @@ var ProcessAgent = class {
1045
1760
  const logs = stderr.trim().length > 0 ? stderr.split("\n") : void 0;
1046
1761
  if (mode === "protocol") {
1047
1762
  try {
1048
- resolve(
1763
+ resolve2(
1049
1764
  interpretProtocolRun({
1050
1765
  input,
1051
1766
  agentName: this.config.name,
@@ -1060,14 +1775,14 @@ var ProcessAgent = class {
1060
1775
  return;
1061
1776
  }
1062
1777
  const trimmed = stdout.trim();
1063
- resolve({
1778
+ resolve2({
1064
1779
  taskId: input.taskId,
1065
- status: code === 0 ? "completed" : "failed",
1780
+ status: code === 0 && !outputLimitExceeded ? "completed" : "failed",
1066
1781
  summary: trimmed.length > 0 ? firstLine(trimmed) : void 0,
1067
1782
  rawOutput: trimmed.length > 0 ? trimmed : void 0,
1068
1783
  exitCode: code ?? void 0,
1069
1784
  logs,
1070
- error: code === 0 ? void 0 : `Process exited with code ${code ?? "unknown"}${signal ? ` (signal: ${signal})` : ""}`
1785
+ ...outputLimitExceeded ? { error: `Agent exceeded the ${maxOutputBytes} byte output limit and was terminated.` } : code === 0 ? {} : { error: describeExit(code, signal) }
1071
1786
  });
1072
1787
  });
1073
1788
  });
@@ -1076,67 +1791,117 @@ var ProcessAgent = class {
1076
1791
  /** Ask the agent to stop current work (SIGTERM). */
1077
1792
  async cancel(taskId) {
1078
1793
  const child = this.running.get(taskId);
1079
- if (child) child.kill("SIGTERM");
1794
+ if (child) killTree(child, "SIGTERM");
1080
1795
  }
1081
1796
  runningTaskIds() {
1082
1797
  return [...this.running.keys()];
1083
1798
  }
1084
1799
  };
1085
- function interpretProtocolRun({ input, agentName, stdout, exitCode, logs }) {
1086
- const reports = [];
1087
- let summary;
1088
- let error;
1089
- let sawProtocolMessage = false;
1090
- for (const line of stdout.split("\n")) {
1091
- const trimmed2 = line.trim();
1092
- if (trimmed2.length === 0) continue;
1093
- const message = parseAgentMessage(trimmed2);
1094
- if (!message) continue;
1095
- sawProtocolMessage = true;
1096
- if (message.type === "REPORT") {
1097
- const report = normalizeReport(message.payload, input.taskId, agentName, reports.length);
1098
- if (report) reports.push(report);
1099
- } else if (message.type === "COMPLETION") {
1100
- const payload = message.payload;
1101
- if (typeof payload?.summary === "string") summary = payload.summary;
1102
- } else if (message.type === "ERROR") {
1103
- const payload = message.payload;
1104
- const detail = typeof payload?.message === "string" ? payload.message : "unknown error";
1105
- const code = typeof payload?.code === "string" ? ` [${payload.code}]` : "";
1106
- error = `Agent error${code}: ${detail}`;
1107
- }
1800
+ function interpretProtocolRun({
1801
+ input,
1802
+ agentName,
1803
+ stdout,
1804
+ exitCode,
1805
+ logs,
1806
+ responder
1807
+ }) {
1808
+ const session = new ProtocolSession(
1809
+ {
1810
+ requestId: `req-${input.taskId}`,
1811
+ taskId: input.taskId,
1812
+ agentId: agentName,
1813
+ ...input.pipelineId !== void 0 ? { pipelineId: input.pipelineId } : {},
1814
+ ...input.executionId !== void 0 ? { executionId: input.executionId } : {}
1815
+ },
1816
+ { ...responder !== void 0 ? { responder } : {} }
1817
+ );
1818
+ for (const result of session.decoder.push(stdout)) {
1819
+ session.handle(result);
1108
1820
  }
1109
- if (!sawProtocolMessage && exitCode === 0) {
1110
- throw new ProtocolError(
1111
- `Agent "${agentName}" produced no protocol messages on stdout (input mode "protocol").`,
1112
- { details: { taskId: input.taskId, stdout: stdout.slice(0, 2e3) } }
1113
- );
1821
+ for (const result of session.decoder.flush()) {
1822
+ session.handle(result);
1114
1823
  }
1824
+ const reports = session.reports.flatMap((payload) => Array.isArray(payload) ? payload : [payload]).map((payload, index) => normalizeReport(payload, input.taskId, agentName, index)).filter((report) => report !== void 0);
1115
1825
  const trimmed = stdout.trim();
1826
+ const protocolBlock = {
1827
+ protocol: session.descriptor?.protocol ?? PROTOCOL_V2,
1828
+ legacy: !session.sawV2 && session.descriptor === void 0,
1829
+ ...session.descriptor !== void 0 ? { descriptor: session.descriptor } : {},
1830
+ questions: session.questions,
1831
+ violations: session.violations.map((v) => `${v.code}: ${v.message}`)
1832
+ };
1833
+ if (session.errorMessage !== void 0) {
1834
+ return {
1835
+ taskId: input.taskId,
1836
+ status: "failed",
1837
+ error: session.errorMessage,
1838
+ rawOutput: trimmed.length > 0 ? trimmed : void 0,
1839
+ exitCode: exitCode ?? void 0,
1840
+ logs,
1841
+ protocol: protocolBlock,
1842
+ ...reports.length > 0 ? { reports } : {}
1843
+ };
1844
+ }
1845
+ if (session.descriptor === void 0 && session.violations.length > 0 && session.reports.length === 0 && session.completionSummary === void 0) {
1846
+ return {
1847
+ taskId: input.taskId,
1848
+ status: "failed",
1849
+ error: `agent produced no valid protocol messages: ${protocolBlock.violations.join("; ")}`,
1850
+ rawOutput: trimmed.length > 0 ? trimmed : void 0,
1851
+ exitCode: exitCode ?? void 0,
1852
+ logs,
1853
+ protocol: protocolBlock
1854
+ };
1855
+ }
1856
+ const completed = exitCode === 0 && session.completionSummary !== void 0;
1116
1857
  return {
1117
1858
  taskId: input.taskId,
1118
- status: error !== void 0 || exitCode !== 0 ? "failed" : "completed",
1119
- summary: summary ?? reports[0]?.summary,
1859
+ status: completed || exitCode === 0 ? completed ? "completed" : "completed" : "failed",
1860
+ summary: session.completionSummary ?? reports[0]?.summary,
1120
1861
  rawOutput: trimmed.length > 0 ? trimmed : void 0,
1121
1862
  exitCode: exitCode ?? void 0,
1122
1863
  logs,
1123
- reports: reports.length > 0 ? reports : void 0,
1124
- error: error ?? (exitCode !== 0 ? `Process exited with code ${exitCode ?? "unknown"}` : void 0)
1864
+ protocol: protocolBlock,
1865
+ ...reports.length > 0 ? { reports } : {},
1866
+ ...exitCode !== 0 ? { error: `Process exited with code ${exitCode ?? "unknown"}` } : {}
1125
1867
  };
1126
1868
  }
1127
- function parseAgentMessage(line) {
1128
- try {
1129
- const parsed = JSON.parse(line);
1130
- if (typeof parsed.type === "string") {
1131
- return { type: parsed.type, payload: parsed.payload };
1132
- }
1133
- const inner = parsed.message;
1134
- if (inner && typeof inner.type === "string") {
1135
- return { type: inner.type, payload: inner.payload };
1869
+ var GLOBAL_RUNNING = /* @__PURE__ */ new Set();
1870
+ var signalForwardingInstalled = false;
1871
+ function installSignalForwarding() {
1872
+ if (signalForwardingInstalled) return;
1873
+ signalForwardingInstalled = true;
1874
+ for (const signal of ["SIGINT", "SIGTERM"]) {
1875
+ process.on(signal, () => {
1876
+ for (const child of GLOBAL_RUNNING) {
1877
+ killTree(child, "SIGTERM");
1878
+ }
1879
+ });
1880
+ }
1881
+ }
1882
+ function killTree(child, signal) {
1883
+ if (child.pid === void 0) return;
1884
+ if (process.platform !== "win32") {
1885
+ try {
1886
+ process.kill(-child.pid, signal);
1887
+ return;
1888
+ } catch {
1136
1889
  }
1890
+ }
1891
+ try {
1892
+ child.kill(signal);
1137
1893
  } catch {
1138
1894
  }
1139
- return void 0;
1895
+ }
1896
+ function describeExit(code, signal) {
1897
+ if (signal !== null) {
1898
+ return signal === "SIGKILL" ? "Process was killed (SIGKILL) \u2014 usually an external kill or OOM." : `Process terminated by signal ${signal}.`;
1899
+ }
1900
+ if (code === null) return "Process terminated without an exit code.";
1901
+ return `Process exited with code ${code}.`;
1902
+ }
1903
+ function sanitizeChunk(chunk) {
1904
+ return [...chunk.toString("utf8")].filter((char) => char.charCodeAt(0) !== 0).join("");
1140
1905
  }
1141
1906
  function normalizeReport(payload, taskId, agentName, index) {
1142
1907
  if (payload === null || typeof payload !== "object") return void 0;
@@ -1157,6 +1922,40 @@ function firstLine(text, maxLength = 200) {
1157
1922
  return line.length > maxLength ? `${line.slice(0, maxLength)}\u2026` : line;
1158
1923
  }
1159
1924
 
1925
+ // src/agents/adapters.ts
1926
+ var AgentAdapterRegistry = class {
1927
+ adapters = /* @__PURE__ */ new Map();
1928
+ register(adapter) {
1929
+ this.adapters.set(adapter.integration, adapter);
1930
+ }
1931
+ get(integration) {
1932
+ return this.adapters.get(integration);
1933
+ }
1934
+ list() {
1935
+ return [...this.adapters.values()];
1936
+ }
1937
+ create(config) {
1938
+ const adapter = this.get(config.integration);
1939
+ if (!adapter) {
1940
+ throw new AgentError(
1941
+ "AGENT_NOT_FOUND",
1942
+ `No agent adapter registered for integration "${config.integration}". Registered: ${[...this.adapters.keys()].join(", ") || "(none)"}.`
1943
+ );
1944
+ }
1945
+ return adapter.create(config);
1946
+ }
1947
+ };
1948
+ var ProcessAgentAdapter = class {
1949
+ constructor(factory) {
1950
+ this.factory = factory;
1951
+ }
1952
+ factory;
1953
+ integration = "process";
1954
+ create(config) {
1955
+ return this.factory(config);
1956
+ }
1957
+ };
1958
+
1160
1959
  // src/agents/registry.ts
1161
1960
  var AgentRegistry = class {
1162
1961
  agents = /* @__PURE__ */ new Map();
@@ -1172,14 +1971,15 @@ var AgentRegistry = class {
1172
1971
  };
1173
1972
 
1174
1973
  // src/agents/factory.ts
1175
- function createAgent(config) {
1176
- if (config.integration === "process") {
1177
- return new ProcessAgent(config);
1178
- }
1974
+ var defaultAgentAdapters = new AgentAdapterRegistry();
1975
+ defaultAgentAdapters.register(new ProcessAgentAdapter((config) => new ProcessAgent(config)));
1976
+ function createAgent(config, adapters = defaultAgentAdapters) {
1977
+ const registered = adapters.get(config.integration);
1978
+ if (registered) return registered.create(config);
1179
1979
  if (config.integration === "native") {
1180
1980
  throw new OmniNodeError(
1181
1981
  "NOT_IMPLEMENTED",
1182
- `Native agent adapters are planned for a later phase. Use input over stdin/stdout (type "cli") for "${config.name}" for now.`
1982
+ `No native adapter is registered for "${config.name}". Register an IAgentAdapter (createAgent(config, adapters)) or use input over stdin/stdout (type "cli").`
1183
1983
  );
1184
1984
  }
1185
1985
  throw new OmniNodeError("NOT_IMPLEMENTED", `Agent integration "${config.integration}" has no adapter yet.`);
@@ -1222,7 +2022,7 @@ function registerAgentCommands(program) {
1222
2022
  "--input-mode <mode>",
1223
2023
  "How the task is delivered: stdin (prompt on stdin), arg (prompt as last argument) or protocol (\xA721 JSON-lines task protocol).",
1224
2024
  "stdin"
1225
- ).option(
2025
+ ).option("--env-policy <policy>", "Environment policy: inherit, allowlist, denylist or explicit.").option("--allow-env <name>", "Variable to forward under the allowlist policy (repeatable).", collectArgs).option("--deny-env <name>", "Variable to remove under the denylist policy (repeatable).", collectArgs).option("--max-output-bytes <n>", "Per-stream output budget in bytes (default 5 MiB).", (value) => Number(value)).option("--allow-external-cwd", "Allow a working directory outside the project root.").option(
1226
2026
  "--timeout-ms <ms>",
1227
2027
  "Per-task timeout in milliseconds.",
1228
2028
  (value) => Number(value)
@@ -1237,7 +2037,12 @@ async function addAgent(options) {
1237
2037
  ...options.arg !== void 0 && options.arg.length > 0 ? { args: options.arg } : {},
1238
2038
  ...options.cwd !== void 0 ? { cwd: options.cwd } : {},
1239
2039
  input_mode: options.inputMode,
1240
- ...options.timeoutMs !== void 0 ? { timeout_ms: options.timeoutMs } : {}
2040
+ ...options.timeoutMs !== void 0 ? { timeout_ms: options.timeoutMs } : {},
2041
+ ...options.envPolicy !== void 0 ? { env_policy: options.envPolicy } : {},
2042
+ ...options.allowEnv !== void 0 && options.allowEnv.length > 0 ? { env_allowlist: options.allowEnv } : {},
2043
+ ...options.denyEnv !== void 0 && options.denyEnv.length > 0 ? { env_denylist: options.denyEnv } : {},
2044
+ ...options.maxOutputBytes !== void 0 ? { max_output_bytes: options.maxOutputBytes } : {},
2045
+ ...options.allowExternalCwd !== void 0 ? { allow_external_cwd: options.allowExternalCwd } : {}
1241
2046
  });
1242
2047
  if (!candidate.success) {
1243
2048
  const issues = candidate.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
@@ -1397,12 +2202,18 @@ import { randomBytes } from "crypto";
1397
2202
  var ALLOWED_TRANSITIONS = {
1398
2203
  created: ["queued", "running", "cancelled"],
1399
2204
  queued: ["running", "cancelled"],
1400
- running: ["completed", "failed", "cancelled"],
2205
+ running: ["completed", "failed", "timed_out", "unknown", "cancelled", "partially_completed"],
1401
2206
  waiting: ["running", "cancelled"],
1402
2207
  completed: [],
1403
- failed: [],
1404
- cancelled: []
2208
+ // failed → running is the only re-entry: bounded automatic retry (§6.4).
2209
+ failed: ["running"],
2210
+ timed_out: [],
2211
+ cancelled: [],
2212
+ unknown: [],
2213
+ partially_completed: []
1405
2214
  };
2215
+ var RETRYABLE_ERROR_CODES = ["AGENT_FAILED"];
2216
+ var EXECUTION_HISTORY_LIMIT = 10;
1406
2217
  var TaskEngine = class {
1407
2218
  constructor(options) {
1408
2219
  this.options = options;
@@ -1434,6 +2245,7 @@ var TaskEngine = class {
1434
2245
  id: generateTaskId(),
1435
2246
  objective: input.objective.trim(),
1436
2247
  status: "created",
2248
+ attempt: 0,
1437
2249
  createdAt: now,
1438
2250
  updatedAt: now,
1439
2251
  ...input.project !== void 0 ? { project: input.project } : {},
@@ -1450,8 +2262,10 @@ var TaskEngine = class {
1450
2262
  const task = await this.mustGet(id);
1451
2263
  return this.transition(task, "queued");
1452
2264
  }
1453
- /** Runs the task through its assigned agent and records the result. */
1454
- async run(id) {
2265
+ /** Runs the task through its assigned agent and records the result.
2266
+ * `maxAttempts` enables bounded automatic retries of *retryable* errors
2267
+ * with linear backoff; unknown/timed_out outcomes are never auto-retried. */
2268
+ async run(id, options = {}) {
1455
2269
  const task = await this.mustGet(id);
1456
2270
  if (task.status !== "created" && task.status !== "queued") {
1457
2271
  throw new TaskError("TASK_INVALID", `Task ${id} is "${task.status}" and cannot be run.`);
@@ -1464,49 +2278,89 @@ var TaskEngine = class {
1464
2278
  throw new TaskError("TASK_INVALID", `Agent "${task.agent}" is not registered anymore.`);
1465
2279
  }
1466
2280
  const role = task.role ? this.options.roles.get(task.role) : void 0;
1467
- const running = await this.transition(task, "running");
1468
2281
  this.log.info(`Running task ${task.id} on agent "${task.agent}".`);
1469
- let background = running.context?.background;
2282
+ let background = task.context?.background;
1470
2283
  if (this.options.memory) {
1471
2284
  const memoryContext = await this.options.memory.gatherContext({
1472
- objective: running.objective,
1473
- ...running.role ? { role: running.role } : {},
1474
- ...running.project ? { project: running.project } : {}
2285
+ objective: task.objective,
2286
+ ...task.role ? { role: task.role } : {},
2287
+ ...task.project ? { project: task.project } : {}
1475
2288
  });
1476
2289
  if (memoryContext.length > 0) {
1477
2290
  background = [memoryContext, background].filter((part) => part && part.length > 0).join("\n\n");
1478
2291
  }
1479
2292
  }
1480
- let output;
1481
- try {
1482
- output = await agent.run({
1483
- taskId: running.id,
1484
- objective: running.objective,
1485
- ...role ? { role } : {},
1486
- ...background !== void 0 ? { context: background } : {}
1487
- });
1488
- } catch (error) {
1489
- const message = error instanceof Error ? error.message : String(error);
1490
- this.log.error(`Task ${task.id} failed: ${message}`);
1491
- return this.transition(running, "failed", {
1492
- result: { error: message, finishedAt: (/* @__PURE__ */ new Date()).toISOString() }
2293
+ const maxAttempts = Math.max(1, options.maxAttempts ?? 1);
2294
+ const backoffMs = options.retryBackoffMs ?? 0;
2295
+ let current = task;
2296
+ for (let i = 0; i < maxAttempts; i += 1) {
2297
+ const attempt = current.attempt + 1;
2298
+ const executionId = `exec-${Date.now().toString(36)}-${randomBytes(3).toString("hex")}`;
2299
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
2300
+ current = await this.transition(current, "running", { attempt });
2301
+ let output;
2302
+ let thrown;
2303
+ try {
2304
+ output = await agent.run({
2305
+ taskId: current.id,
2306
+ objective: current.objective,
2307
+ ...role ? { role } : {},
2308
+ ...background !== void 0 ? { context: background } : {}
2309
+ });
2310
+ } catch (error) {
2311
+ thrown = error;
2312
+ }
2313
+ const finishedAt = (/* @__PURE__ */ new Date()).toISOString();
2314
+ if (thrown !== void 0 || output === void 0) {
2315
+ const outcome = classifyThrown(thrown);
2316
+ const message = thrown instanceof Error ? thrown.message : String(thrown);
2317
+ const retryable = outcome === "failed" && RETRYABLE_ERROR_CODES.includes(errorCode(thrown)) && i < maxAttempts - 1;
2318
+ current = await this.transition(current, outcome, {
2319
+ attempt,
2320
+ lastError: message,
2321
+ execution: {
2322
+ executionId,
2323
+ attempt,
2324
+ startedAt,
2325
+ finishedAt,
2326
+ outcome,
2327
+ error: message
2328
+ }
2329
+ });
2330
+ this.log.warn(`Task ${current.id} attempt ${attempt} \u2192 ${outcome}: ${message}`);
2331
+ if (retryable) {
2332
+ if (backoffMs > 0) await sleep(backoffMs * attempt);
2333
+ continue;
2334
+ }
2335
+ if (this.options.memory) await this.options.memory.recordTaskOutcome(current);
2336
+ return current;
2337
+ }
2338
+ const currentState = await this.options.store.get(current.id);
2339
+ if (currentState?.status === "cancelled") {
2340
+ current = currentState;
2341
+ break;
2342
+ }
2343
+ const result = {
2344
+ ...output.summary !== void 0 ? { summary: output.summary } : {},
2345
+ ...output.reports !== void 0 && output.reports.length > 0 ? { reports: output.reports } : {},
2346
+ // Keep the verbatim output (bounded) for report extraction and audit.
2347
+ ...output.rawOutput !== void 0 && output.rawOutput.length > 0 ? { rawOutput: output.rawOutput.slice(0, 1e4) } : {},
2348
+ ...output.error !== void 0 ? { error: output.error } : {},
2349
+ finishedAt
2350
+ };
2351
+ const finalStatus = output.status === "completed" ? "completed" : "failed";
2352
+ current = await this.transition(current, finalStatus, {
2353
+ attempt,
2354
+ result,
2355
+ execution: { executionId, attempt, startedAt, finishedAt, outcome: finalStatus }
1493
2356
  });
2357
+ this.log.info(`Task ${current.id} finished: ${finalStatus}.`);
2358
+ break;
1494
2359
  }
1495
- const result = {
1496
- ...output.summary !== void 0 ? { summary: output.summary } : {},
1497
- ...output.reports !== void 0 && output.reports.length > 0 ? { reports: output.reports } : {},
1498
- // Keep the verbatim output (bounded) for report extraction and audit.
1499
- ...output.rawOutput !== void 0 && output.rawOutput.length > 0 ? { rawOutput: output.rawOutput.slice(0, 1e4) } : {},
1500
- ...output.error !== void 0 ? { error: output.error } : {},
1501
- finishedAt: (/* @__PURE__ */ new Date()).toISOString()
1502
- };
1503
- const finalStatus = output.status === "completed" ? "completed" : "failed";
1504
- const finished = await this.transition(running, finalStatus, { result });
1505
- this.log.info(`Task ${task.id} finished: ${finalStatus}.`);
1506
2360
  if (this.options.memory) {
1507
- await this.options.memory.recordTaskOutcome(finished);
2361
+ await this.options.memory.recordTaskOutcome(current);
1508
2362
  }
1509
- return finished;
2363
+ return current;
1510
2364
  }
1511
2365
  async cancel(id) {
1512
2366
  const task = await this.mustGet(id);
@@ -1519,13 +2373,14 @@ var TaskEngine = class {
1519
2373
  }
1520
2374
  return this.transition(task, "cancelled");
1521
2375
  }
1522
- /** Error recovery (§27 Phase 9): reset a failed/cancelled task so it can run again. */
2376
+ /** Error recovery: reset a failed/cancelled/unknown/timed_out task so it can run again. */
1523
2377
  async retry(id) {
1524
2378
  const task = await this.mustGet(id);
1525
- if (task.status !== "failed" && task.status !== "cancelled") {
2379
+ const retryableStates = ["failed", "cancelled", "unknown", "timed_out"];
2380
+ if (!retryableStates.includes(task.status)) {
1526
2381
  throw new TaskError(
1527
2382
  "TASK_INVALID",
1528
- `Only failed or cancelled tasks can be retried \u2014 task ${id} is "${task.status}".`
2383
+ `Only failed, timed out, unknown or cancelled tasks can be retried \u2014 task ${id} is "${task.status}".`
1529
2384
  );
1530
2385
  }
1531
2386
  const { result: _discarded, ...rest } = task;
@@ -1557,10 +2412,14 @@ var TaskEngine = class {
1557
2412
  if (!allowed.includes(to)) {
1558
2413
  throw new TaskError("TASK_INVALID", `Task ${task.id} cannot move from "${task.status}" to "${to}".`);
1559
2414
  }
2415
+ const executions = extra.execution ? [...task.executions ?? [], extra.execution].slice(-EXECUTION_HISTORY_LIMIT) : task.executions;
1560
2416
  const updated = {
1561
2417
  ...task,
1562
2418
  status: to,
1563
2419
  updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2420
+ ...extra.attempt !== void 0 ? { attempt: extra.attempt } : {},
2421
+ ...extra.lastError !== void 0 ? { lastError: extra.lastError } : {},
2422
+ ...executions !== void 0 ? { executions } : {},
1564
2423
  ...extra.result !== void 0 ? { result: { ...task.result, ...extra.result } } : {}
1565
2424
  };
1566
2425
  await this.options.store.save(updated);
@@ -1581,6 +2440,19 @@ var TaskEngine = class {
1581
2440
  function generateTaskId() {
1582
2441
  return `task-${Date.now().toString(36)}-${randomBytes(3).toString("hex")}`;
1583
2442
  }
2443
+ function errorCode(error) {
2444
+ return error?.code ?? "";
2445
+ }
2446
+ function classifyThrown(error) {
2447
+ if (error instanceof AgentError && error.code === "AGENT_TIMEOUT") {
2448
+ const dispatched = error.details?.dispatched;
2449
+ return dispatched === false ? "timed_out" : "unknown";
2450
+ }
2451
+ return "failed";
2452
+ }
2453
+ function sleep(ms) {
2454
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
2455
+ }
1584
2456
 
1585
2457
  // src/audit/index.ts
1586
2458
  import { appendFile, mkdir as mkdir2 } from "fs/promises";
@@ -1623,6 +2495,23 @@ var FileAuditLog = class {
1623
2495
  }
1624
2496
  };
1625
2497
 
2498
+ // src/memory/base.ts
2499
+ function defaultSearch(provider, text, options = {}) {
2500
+ return provider.retrieve({
2501
+ text,
2502
+ ...options.scope !== void 0 ? { scope: options.scope } : {},
2503
+ ...options.limit !== void 0 ? { limit: options.limit } : {}
2504
+ });
2505
+ }
2506
+ function defaultMetadata(name, options = {}) {
2507
+ return {
2508
+ name,
2509
+ capabilities: options.capabilities ?? ["retrieve", "store", "search"],
2510
+ relevanceRanking: options.relevanceRanking ?? false,
2511
+ ...options.backend !== void 0 ? { backend: options.backend } : {}
2512
+ };
2513
+ }
2514
+
1626
2515
  // src/memory/local.ts
1627
2516
  var STOPWORDS = /* @__PURE__ */ new Set([
1628
2517
  "the",
@@ -1685,9 +2574,27 @@ var LocalMemoryProvider = class extends JsonFileStore {
1685
2574
  constructor(directory) {
1686
2575
  super({ ...directory !== void 0 ? { directory } : {}, fileName: "memory.json" });
1687
2576
  }
1688
- async query(query) {
2577
+ metadata() {
2578
+ return defaultMetadata(this.name, {
2579
+ capabilities: ["retrieve", "store", "search", "categories", "tags"],
2580
+ relevanceRanking: true,
2581
+ backend: { kind: "json-file", deterministicScoring: true }
2582
+ });
2583
+ }
2584
+ search(text, options = {}) {
2585
+ return defaultSearch(this, text, options);
2586
+ }
2587
+ /** @deprecated v1 alias of {@link retrieve}. */
2588
+ query(query) {
2589
+ return this.retrieve(query);
2590
+ }
2591
+ /** @deprecated v1 alias of {@link store}. */
2592
+ write(entry2) {
2593
+ return this.store(entry2);
2594
+ }
2595
+ async retrieve(query) {
1689
2596
  const entries = await this.readAll();
1690
- const filtered = entries.filter((entry2) => query.scope ? entry2.scope === query.scope : true).filter(
2597
+ const filtered = entries.filter((entry2) => query.scope ? entry2.scope === query.scope : true).filter((entry2) => query.category ? entry2.category === query.category : true).filter(
1691
2598
  (entry2) => query.tags && query.tags.length > 0 ? query.tags.every((tag) => (entry2.tags ?? []).includes(tag)) : true
1692
2599
  );
1693
2600
  if (!query.text || query.text.trim().length === 0) {
@@ -1696,7 +2603,7 @@ var LocalMemoryProvider = class extends JsonFileStore {
1696
2603
  const queryKeywords = keywords(query.text);
1697
2604
  return filtered.map((entry2) => ({ entry: entry2, score: relevanceScore(queryKeywords, entry2) })).filter(({ score }) => score > 0).sort((a, b) => b.score - a.score).slice(0, query.limit ?? 10).map(({ entry: entry2 }) => entry2);
1698
2605
  }
1699
- write(entry2) {
2606
+ store(entry2) {
1700
2607
  return this.mutate((entries) => {
1701
2608
  const index = entries.findIndex((e) => e.key === entry2.key);
1702
2609
  const stamped = {
@@ -1727,11 +2634,40 @@ var RememberaMemoryProvider = class {
1727
2634
  this.env = config.env ?? process.env;
1728
2635
  this.log = log.child({ component: "remembera" });
1729
2636
  }
2637
+ get authenticationConfigured() {
2638
+ return this.apiKeyEnvVar === void 0 || Boolean(this.env[this.apiKeyEnvVar]);
2639
+ }
1730
2640
  headers() {
1731
2641
  const key = this.apiKeyEnvVar ? resolveApiKey({ name: "remembera", apiKeyEnvVar: this.apiKeyEnvVar }, this.env) : void 0;
1732
2642
  return key ? { authorization: `Bearer ${key}` } : {};
1733
2643
  }
1734
- async query(query) {
2644
+ /** Search delegates to the gateway's relevance query. */
2645
+ search(text, options = {}) {
2646
+ return defaultSearch(
2647
+ this,
2648
+ text,
2649
+ {
2650
+ ...options.scope !== void 0 ? { scope: options.scope } : {},
2651
+ ...options.limit !== void 0 ? { limit: options.limit } : {}
2652
+ }
2653
+ );
2654
+ }
2655
+ metadata() {
2656
+ return defaultMetadata(this.name, {
2657
+ capabilities: ["retrieve", "store", "search", "categories", "tags"],
2658
+ relevanceRanking: true,
2659
+ backend: { kind: "http", baseUrl: this.baseUrl, authenticated: this.authenticationConfigured }
2660
+ });
2661
+ }
2662
+ /** @deprecated v1 alias of {@link retrieve}. */
2663
+ query(query) {
2664
+ return this.retrieve(query);
2665
+ }
2666
+ /** @deprecated v1 alias of {@link store}. */
2667
+ write(entry2) {
2668
+ return this.store(entry2);
2669
+ }
2670
+ async retrieve(query) {
1735
2671
  const { status, body } = await this.request(`${this.baseUrl}/api/memory/query`, query);
1736
2672
  if (status !== 200) {
1737
2673
  throw new MemoryError(`Remembera query returned HTTP ${status}.`);
@@ -1742,7 +2678,7 @@ var RememberaMemoryProvider = class {
1742
2678
  }
1743
2679
  return entries.map((entry2) => this.toEntry(entry2)).filter((entry2) => entry2 !== void 0);
1744
2680
  }
1745
- async write(entry2) {
2681
+ async store(entry2) {
1746
2682
  const { status } = await this.request(`${this.baseUrl}/api/memory`, entry2);
1747
2683
  if (status !== 200 && status !== 201) {
1748
2684
  throw new MemoryError(`Remembera write returned HTTP ${status}.`);
@@ -1777,6 +2713,7 @@ var RememberaMemoryProvider = class {
1777
2713
  content: entry2.content,
1778
2714
  scope: scope === "project" || scope === "role" || scope === "task" || scope === "knowledge" ? scope : "knowledge",
1779
2715
  ...Array.isArray(entry2.tags) ? { tags: entry2.tags.filter((t) => typeof t === "string") } : {},
2716
+ ...typeof entry2.category === "string" ? { category: entry2.category } : {},
1780
2717
  ...typeof entry2.createdAt === "string" ? { createdAt: entry2.createdAt } : {},
1781
2718
  ...typeof entry2.updatedAt === "string" ? { updatedAt: entry2.updatedAt } : {}
1782
2719
  };
@@ -1807,57 +2744,54 @@ function createMemoryProvider(config) {
1807
2744
  }
1808
2745
 
1809
2746
  // src/memory/service.ts
2747
+ var DEFAULT_ENTRY_LIMIT = 8;
1810
2748
  var MAX_CONTEXT_CHARS = 4e3;
1811
2749
  var MemoryService = class {
1812
- constructor(provider, log = logger) {
2750
+ constructor(provider, log = logger, options = {}) {
1813
2751
  this.provider = provider;
1814
2752
  this.log = log.child({ component: "memory", provider: provider.name });
2753
+ this.required = options.required ?? false;
2754
+ this.maxContextChars = options.maxContextChars ?? MAX_CONTEXT_CHARS;
1815
2755
  }
1816
2756
  provider;
1817
2757
  log;
2758
+ required;
2759
+ maxContextChars;
1818
2760
  get providerRef() {
1819
2761
  return this.provider;
1820
2762
  }
1821
2763
  get providerName() {
1822
2764
  return this.provider.name;
1823
2765
  }
1824
- /** Relevant context for a task objective; empty string when nothing matches. */
1825
- async gatherContext(query) {
2766
+ get isRequired() {
2767
+ return this.required;
2768
+ }
2769
+ /** Structured, budgeted context for a task (§12 — avoid loading all memory). */
2770
+ async contextFor(query) {
2771
+ let entries;
1826
2772
  try {
1827
- const entries = await this.provider.query({
2773
+ entries = await this.provider.retrieve({
1828
2774
  text: query.objective,
1829
2775
  ...query.role ? { tags: [query.role] } : {},
1830
- limit: 8
2776
+ limit: query.limit ?? DEFAULT_ENTRY_LIMIT
1831
2777
  });
1832
- if (entries.length === 0) return "";
1833
- const byScope = /* @__PURE__ */ new Map();
1834
- for (const entry2 of entries) {
1835
- byScope.set(entry2.scope, [...byScope.get(entry2.scope) ?? [], entry2]);
1836
- }
1837
- const parts = [];
1838
- for (const scope of ["project", "task", "role", "knowledge"]) {
1839
- const scopeEntries = byScope.get(scope) ?? [];
1840
- if (scopeEntries.length === 0) continue;
1841
- const lines = scopeEntries.map((entry2) => `- ${entry2.content}`).join("\n");
1842
- parts.push(`### Relevant memory (${scope})
1843
- ${lines}`);
1844
- }
1845
- const context = parts.join("\n\n");
1846
- return context.length > MAX_CONTEXT_CHARS ? `${context.slice(0, MAX_CONTEXT_CHARS)}\u2026` : context;
1847
2778
  } catch (error) {
1848
- this.log.warn(
1849
- `Memory query failed (continuing without memory): ${error instanceof Error ? error.message : String(error)}`
1850
- );
1851
- return "";
2779
+ return this.handleFailure("retrieve", error, { text: "", entries: [], truncated: false });
1852
2780
  }
2781
+ return this.format(entries);
2782
+ }
2783
+ /** Prompt-ready context text (v1 convenience wrapper). */
2784
+ async gatherContext(query) {
2785
+ return (await this.contextFor(query)).text;
1853
2786
  }
1854
- /** Records a finished task's outcome — best-effort. */
2787
+ /** Records a finished task's outcome — task history plus "known problems" (§12). */
1855
2788
  async recordTaskOutcome(task) {
1856
2789
  const outcome = task.result?.summary ?? task.result?.error ?? "(no output)";
1857
2790
  try {
1858
- await this.provider.write({
2791
+ await this.provider.store({
1859
2792
  key: `task:${task.id}`,
1860
2793
  scope: "task",
2794
+ category: "tasks",
1861
2795
  content: `Objective: ${task.objective}
1862
2796
  Status: ${task.status}
1863
2797
  Outcome: ${outcome}`,
@@ -1868,18 +2802,53 @@ Outcome: ${outcome}`,
1868
2802
  (report) => report.findings.filter((finding) => finding.severity === "high" || finding.severity === "critical").map((finding) => ({ report, finding }))
1869
2803
  );
1870
2804
  for (const { report, finding } of significant) {
1871
- await this.provider.write({
2805
+ await this.provider.store({
1872
2806
  key: `finding:${report.id}:${finding.id}`,
1873
2807
  scope: "project",
2808
+ category: "problems",
1874
2809
  content: `[${finding.severity}] ${finding.title} \u2014 ${finding.detail}`,
1875
2810
  tags: ["known-problem", report.agent]
1876
2811
  });
1877
2812
  }
1878
2813
  } catch (error) {
1879
- this.log.warn(
1880
- `Memory write failed (continuing): ${error instanceof Error ? error.message : String(error)}`
2814
+ this.handleFailure("store", error, void 0);
2815
+ }
2816
+ }
2817
+ format(entries) {
2818
+ if (entries.length === 0) return { text: "", entries, truncated: false };
2819
+ const byScope = /* @__PURE__ */ new Map();
2820
+ for (const entry2 of entries) {
2821
+ byScope.set(entry2.scope, [...byScope.get(entry2.scope) ?? [], entry2]);
2822
+ }
2823
+ const parts = [];
2824
+ for (const scope of ["project", "task", "role", "knowledge"]) {
2825
+ const scopeEntries = byScope.get(scope) ?? [];
2826
+ if (scopeEntries.length === 0) continue;
2827
+ const lines = scopeEntries.map((entry2) => {
2828
+ const category = entry2.category ? `[${entry2.category}] ` : "";
2829
+ return `- ${category}${entry2.content}`;
2830
+ }).join("\n");
2831
+ parts.push(`### Relevant memory (${scope})
2832
+ ${lines}`);
2833
+ }
2834
+ let text = parts.join("\n\n");
2835
+ const truncated = text.length > this.maxContextChars;
2836
+ if (truncated) text = `${text.slice(0, this.maxContextChars - 1)}\u2026`;
2837
+ return { text, entries, truncated };
2838
+ }
2839
+ /**
2840
+ * Failure policy (§12): continue unless memory is explicitly required.
2841
+ */
2842
+ handleFailure(operation, error, fallback) {
2843
+ const message = error instanceof Error ? error.message : String(error);
2844
+ if (this.required) {
2845
+ throw new MemoryError(
2846
+ `Memory is marked required, but ${operation} failed: ${message}`,
2847
+ { cause: error }
1881
2848
  );
1882
2849
  }
2850
+ this.log.warn(`Memory ${operation} failed (continuing without memory): ${message}`);
2851
+ return fallback ?? { text: "", entries: [], truncated: false };
1883
2852
  }
1884
2853
  };
1885
2854
 
@@ -1913,7 +2882,11 @@ function createTaskEngine(config, log = logger, directory) {
1913
2882
  agents,
1914
2883
  roles,
1915
2884
  store: new FileTaskStore(directory),
1916
- ...config.project.memory !== void 0 ? { memory: new MemoryService(createMemoryProvider(config.project.memory), log) } : {},
2885
+ ...config.project.memory !== void 0 ? {
2886
+ memory: new MemoryService(createMemoryProvider(config.project.memory), log, {
2887
+ required: config.project.memory.required
2888
+ })
2889
+ } : {},
1917
2890
  audit: new FileAuditLog(),
1918
2891
  log
1919
2892
  });
@@ -1980,7 +2953,7 @@ function registerTaskCommands(program) {
1980
2953
  const cancelled = await engine.cancel(taskId);
1981
2954
  console.log(`Task ${cancelled.id} is now "${cancelled.status}".`);
1982
2955
  });
1983
- task.command("retry <task-id>").description("Reset a failed/cancelled task to created; combine with --run to execute it again.").option("--run", "Run the task again immediately after resetting it.").action(async (taskId, options) => {
2956
+ task.command("retry <task-id>").description("Reset a failed, timed out, unknown or cancelled task to created; combine with --run to execute it again.").option("--run", "Run the task again immediately after resetting it.").action(async (taskId, options) => {
1984
2957
  const config = loadConfig();
1985
2958
  const engine = createTaskEngine(config);
1986
2959
  const reset = await engine.retry(taskId);
@@ -2003,6 +2976,15 @@ function printOutcome(task, options = {}) {
2003
2976
  if (task.context?.files) console.log(` files: ${task.context.files.join(", ")}`);
2004
2977
  console.log(` objective: ${task.objective}`);
2005
2978
  }
2979
+ if (options.verbose && task.attempt > 0) {
2980
+ console.log(` attempt: ${task.attempt}`);
2981
+ }
2982
+ if (task.lastError) console.log(` error: ${task.lastError}`);
2983
+ if (options.verbose && task.executions && task.executions.length > 0) {
2984
+ console.log(
2985
+ ` executions: ${task.executions.map((e) => `#${e.attempt}:${e.outcome}`).join(", ")}`
2986
+ );
2987
+ }
2006
2988
  const result = task.result;
2007
2989
  if (!result) return;
2008
2990
  if (result.summary) console.log(` summary: ${result.summary}`);
@@ -2018,6 +3000,87 @@ function truncate2(text, max) {
2018
3000
 
2019
3001
  // src/reports/aggregate.ts
2020
3002
  import { randomBytes as randomBytes2 } from "crypto";
3003
+
3004
+ // src/reports/schema.ts
3005
+ import { z as z2 } from "zod";
3006
+ var evidenceSchema = z2.union([
3007
+ z2.string().transform((excerpt) => ({ kind: "observation", excerpt })),
3008
+ z2.object({
3009
+ kind: z2.enum(["file", "line", "url", "command", "observation", "artifact"]),
3010
+ ref: z2.string().optional(),
3011
+ line: z2.number().int().optional(),
3012
+ excerpt: z2.string().optional(),
3013
+ agent: z2.string().optional()
3014
+ })
3015
+ ]);
3016
+ var findingSchema = z2.object({
3017
+ id: z2.string().min(1),
3018
+ title: z2.string().min(1),
3019
+ detail: z2.string().default(""),
3020
+ description: z2.string().optional(),
3021
+ severity: z2.enum(["info", "low", "medium", "high", "critical"]).optional(),
3022
+ confidence: z2.enum(["low", "medium", "high"]).optional(),
3023
+ severityBySource: z2.record(z2.enum(["info", "low", "medium", "high", "critical"])).optional(),
3024
+ confidenceBySource: z2.record(z2.enum(["low", "medium", "high"])).optional(),
3025
+ recommendations: z2.array(z2.string()).optional(),
3026
+ evidence: z2.array(evidenceSchema).optional(),
3027
+ location: z2.string().optional(),
3028
+ tags: z2.array(z2.string()).optional(),
3029
+ source: z2.object({ agent: z2.string().optional(), reportId: z2.string().optional(), pipelineRunId: z2.string().optional() }).optional(),
3030
+ recommendation: z2.string().optional()
3031
+ });
3032
+ var artifactSchema = z2.object({
3033
+ kind: z2.enum(["file", "patch", "log", "screenshot", "document", "data"]),
3034
+ ref: z2.string().min(1),
3035
+ description: z2.string().optional(),
3036
+ mediaType: z2.string().optional(),
3037
+ bytes: z2.number().int().optional()
3038
+ });
3039
+ var reportSchema = z2.object({
3040
+ id: z2.string().min(1),
3041
+ taskId: z2.string().min(1),
3042
+ agent: z2.string().min(1),
3043
+ agentId: z2.string().optional(),
3044
+ pipelineRunId: z2.string().optional(),
3045
+ model: z2.string().optional(),
3046
+ summary: z2.string(),
3047
+ findings: z2.array(findingSchema),
3048
+ recommendations: z2.array(z2.string()),
3049
+ confidence: z2.enum(["low", "medium", "high"]).optional(),
3050
+ evidence: z2.array(evidenceSchema).optional(),
3051
+ artifacts: z2.array(artifactSchema).optional(),
3052
+ createdAt: z2.string().min(1),
3053
+ metadata: z2.record(z2.unknown()).optional()
3054
+ });
3055
+ function validateReport(value) {
3056
+ const parsed = reportSchema.safeParse(value);
3057
+ if (!parsed.success) {
3058
+ const issues = parsed.error.issues.map((issue) => ({
3059
+ path: issue.path.join(".") || "(root)",
3060
+ message: issue.message
3061
+ }));
3062
+ return {
3063
+ valid: false,
3064
+ issues,
3065
+ reason: `report failed schema validation (${issues.length} issue(s))`
3066
+ };
3067
+ }
3068
+ const report = parsed.data;
3069
+ report.findings = report.findings.map((finding) => ({
3070
+ ...finding,
3071
+ description: finding.description ?? finding.detail,
3072
+ ...finding.source === void 0 ? { source: { agent: report.agent, reportId: report.id, ...report.pipelineRunId !== void 0 ? { pipelineRunId: report.pipelineRunId } : {} } } : {}
3073
+ }));
3074
+ return { valid: true, report };
3075
+ }
3076
+ function normalizeEvidence(input) {
3077
+ if (input === void 0) return void 0;
3078
+ return input.map(
3079
+ (item) => typeof item === "string" ? { kind: "observation", excerpt: item } : item
3080
+ );
3081
+ }
3082
+
3083
+ // src/reports/aggregate.ts
2021
3084
  var SEVERITY_RANK = {
2022
3085
  info: 0,
2023
3086
  low: 1,
@@ -2039,7 +3102,12 @@ function aggregateReports(reports, options = {}) {
2039
3102
  }
2040
3103
  const sources = [...new Set(reports.map((r) => r.agent))].sort();
2041
3104
  const totalRawFindings = reports.reduce((sum, r) => sum + r.findings.length, 0);
2042
- const summary = `${reports.length} report(s) from ${sources.length > 0 ? sources.join(", ") : "no agents"}: ${findingGroups.length} unique finding group(s) (${totalRawFindings} raw), ${recommendationGroups.length} recommendation group(s).`;
3105
+ const conflicts = detectConflicts(findingGroups);
3106
+ const agreements = findingGroups.filter(
3107
+ (group) => group.sources.length > 1 && group.severity !== void 0 && new Set(Object.values(group.severityBySource ?? {})).size === 1
3108
+ );
3109
+ const disputed = conflicts.length;
3110
+ const summary = `${reports.length} report(s) from ${sources.length > 0 ? sources.join(", ") : "no agents"}: ${findingGroups.length} unique finding group(s) (${totalRawFindings} raw), ${agreements.length} agreement(s), ${disputed} conflict(s), ${recommendationGroups.length} recommendation group(s).`;
2043
3111
  return {
2044
3112
  id: `combined-${Date.now().toString(36)}-${randomBytes2(3).toString("hex")}`,
2045
3113
  title: options.objective !== void 0 ? truncate3(options.objective, 120) : "Combined report",
@@ -2048,10 +3116,57 @@ function aggregateReports(reports, options = {}) {
2048
3116
  taskIds: options.taskIds ?? [],
2049
3117
  summary,
2050
3118
  findings: findingGroups.sort((a, b) => rank(b) - rank(a)),
3119
+ agreements,
3120
+ conflicts,
2051
3121
  recommendations: recommendationGroups,
2052
3122
  sources,
2053
3123
  reportIds: reports.map((r) => r.id),
2054
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
3124
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
3125
+ metadata: {
3126
+ agents: sources.length,
3127
+ reports: reports.length,
3128
+ rawFindings: totalRawFindings,
3129
+ findingGroups: findingGroups.length,
3130
+ agreements: agreements.length,
3131
+ conflicts: conflicts.length
3132
+ }
3133
+ };
3134
+ }
3135
+ function rankConfidence(confidence) {
3136
+ return confidence ? { low: 1, medium: 2, high: 3 }[confidence] : 0;
3137
+ }
3138
+ function detectConflicts(groups) {
3139
+ const conflicts = [];
3140
+ for (const group of groups) {
3141
+ if (group.sources.length < 2) continue;
3142
+ const severities = new Set(Object.values(group.severityBySource ?? {}));
3143
+ if (severities.size > 1) {
3144
+ conflicts.push({
3145
+ type: "severity",
3146
+ findingTitle: group.title,
3147
+ positions: group.sources.map((source) => position(source, group)),
3148
+ note: `Agents disagree on severity (${[...severities].sort((a, b) => SEVERITY_RANK[a] - SEVERITY_RANK[b]).join(", ")}); the highest severity is shown, every position is preserved.`
3149
+ });
3150
+ }
3151
+ const recommendations = (group.recommendations ?? []).filter((r) => r.length > 0);
3152
+ if (recommendations.length > 1) {
3153
+ conflicts.push({
3154
+ type: "recommendation",
3155
+ findingTitle: group.title,
3156
+ positions: group.sources.map((source) => position(source, group)),
3157
+ note: `Agents proposed ${recommendations.length} different fixes; none was discarded.`
3158
+ });
3159
+ }
3160
+ }
3161
+ return conflicts;
3162
+ }
3163
+ function position(source, group) {
3164
+ return {
3165
+ agent: source.agent,
3166
+ reportId: source.reportId,
3167
+ ...group.severityBySource?.[source.agent] !== void 0 ? { severity: group.severityBySource[source.agent] } : {},
3168
+ ...group.confidenceBySource?.[source.agent] !== void 0 ? { confidence: group.confidenceBySource[source.agent] } : {},
3169
+ ...group.recommendations?.[0] !== void 0 ? { recommendation: group.recommendations[0] } : {}
2055
3170
  };
2056
3171
  }
2057
3172
  function rank(finding) {
@@ -2064,10 +3179,22 @@ function mergeFinding(groups, finding, report, threshold) {
2064
3179
  group.occurrences += 1;
2065
3180
  group.details.push(finding.detail);
2066
3181
  group.sources.push(source);
2067
- group.evidence.push(...finding.evidence ?? []);
3182
+ group.evidence.push(...normalizeEvidence(finding.evidence) ?? []);
2068
3183
  if (finding.severity && (!group.severity || SEVERITY_RANK[finding.severity] > SEVERITY_RANK[group.severity])) {
2069
3184
  group.severity = finding.severity;
2070
3185
  }
3186
+ if (finding.confidence && rankConfidence(finding.confidence) > rankConfidence(group.confidence)) {
3187
+ group.confidence = finding.confidence;
3188
+ }
3189
+ if (finding.severity) {
3190
+ group.severityBySource = { ...group.severityBySource, [report.agent]: finding.severity };
3191
+ }
3192
+ if (finding.confidence) {
3193
+ group.confidenceBySource = { ...group.confidenceBySource, [report.agent]: finding.confidence };
3194
+ }
3195
+ if (finding.recommendation && !group.recommendations?.includes(finding.recommendation)) {
3196
+ group.recommendations = [...group.recommendations ?? [], finding.recommendation];
3197
+ }
2071
3198
  return;
2072
3199
  }
2073
3200
  groups.push({
@@ -2076,7 +3203,11 @@ function mergeFinding(groups, finding, report, threshold) {
2076
3203
  sources: [source],
2077
3204
  occurrences: 1,
2078
3205
  ...finding.severity ? { severity: finding.severity } : {},
2079
- evidence: [...finding.evidence ?? []]
3206
+ ...finding.confidence ? { confidence: finding.confidence } : {},
3207
+ ...finding.severity ? { severityBySource: { [report.agent]: finding.severity } } : {},
3208
+ ...finding.confidence ? { confidenceBySource: { [report.agent]: finding.confidence } } : {},
3209
+ ...finding.recommendation ? { recommendations: [finding.recommendation] } : {},
3210
+ evidence: [...normalizeEvidence(finding.evidence) ?? []]
2080
3211
  });
2081
3212
  }
2082
3213
  function mergeRecommendation(groups, text, report, threshold) {
@@ -2158,6 +3289,7 @@ function extractReportFromText(raw, meta) {
2158
3289
  id: `${meta.taskId}-f-${index + 1}`,
2159
3290
  title: truncate4(text, 120),
2160
3291
  detail: text,
3292
+ description: text,
2161
3293
  severity: guessSeverity(text)
2162
3294
  }));
2163
3295
  return {
@@ -2168,7 +3300,8 @@ function extractReportFromText(raw, meta) {
2168
3300
  summary,
2169
3301
  findings,
2170
3302
  recommendations: recommendationTexts.map((t) => truncate4(t, 200)),
2171
- metadata: evidenceTexts.length > 0 ? { evidence: evidenceTexts } : void 0,
3303
+ ...evidenceTexts.length > 0 ? { evidence: evidenceTexts.map((excerpt) => ({ kind: "observation", excerpt })) } : {},
3304
+ metadata: evidenceTexts.length > 0 ? { evidenceCount: evidenceTexts.length } : void 0,
2172
3305
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
2173
3306
  };
2174
3307
  }
@@ -2184,27 +3317,46 @@ var ReportService = class {
2184
3317
  }
2185
3318
  store;
2186
3319
  log;
3320
+ /** Rejected reports from the last collection (§13 — malformed never enters aggregation). */
3321
+ lastDiagnostics = { collected: 0, accepted: 0, rejected: [] };
2187
3322
  get storeRef() {
2188
3323
  return this.store;
2189
3324
  }
2190
- /** Normalizes finished tasks into reports (§17 — normalizer). */
3325
+ /** Normalizes finished tasks into reports (§17 — normalizer), validating each
3326
+ * one so malformed reports never reach aggregation (§13). */
2191
3327
  async collectFromTasks(tasks) {
2192
- const reports = [];
3328
+ const candidates = [];
2193
3329
  for (const task of tasks) {
2194
3330
  const structured = task.result?.reports ?? [];
2195
3331
  if (structured.length > 0) {
2196
- reports.push(...structured);
3332
+ candidates.push(...structured);
2197
3333
  continue;
2198
3334
  }
2199
3335
  const text = task.result?.rawOutput ?? task.result?.summary ?? "";
2200
3336
  if (text.trim().length === 0) continue;
2201
- reports.push(
3337
+ candidates.push(
2202
3338
  extractReportFromText(text, {
2203
3339
  taskId: task.id,
2204
3340
  agent: task.agent ?? "unknown"
2205
3341
  })
2206
3342
  );
2207
3343
  }
3344
+ const reports = [];
3345
+ const rejected = [];
3346
+ for (const candidate of candidates) {
3347
+ const result = validateReport(candidate);
3348
+ if (result.valid) {
3349
+ reports.push(result.report);
3350
+ } else {
3351
+ rejected.push({
3352
+ key: candidate?.id ?? "(unknown)",
3353
+ reason: result.reason,
3354
+ issues: result.issues.map((issue) => `${issue.path}: ${issue.message}`)
3355
+ });
3356
+ this.log.warn(`Rejected malformed report: ${result.reason}`);
3357
+ }
3358
+ }
3359
+ this.lastDiagnostics = { collected: candidates.length, accepted: reports.length, rejected };
2208
3360
  return reports;
2209
3361
  }
2210
3362
  async saveReports(reports) {
@@ -2293,8 +3445,9 @@ function registerReportCommands(program) {
2293
3445
  return;
2294
3446
  }
2295
3447
  for (const c of combined) {
3448
+ const conflicts = c.conflicts.length > 0 ? ` ${c.conflicts.length} CONFLICT(S)` : "";
2296
3449
  console.log(
2297
- `${c.id} ${c.findings.length} finding(s) from ${c.sources.length} source(s)${c.pipelineRunId ? ` run=${c.pipelineRunId}` : ""}`
3450
+ `${c.id} ${c.findings.length} finding(s) from ${c.sources.length} source(s)${conflicts}${c.pipelineRunId ? ` run=${c.pipelineRunId}` : ""}`
2298
3451
  );
2299
3452
  }
2300
3453
  });
@@ -2331,6 +3484,21 @@ function registerReportCommands(program) {
2331
3484
  console.log(` reports: ${combined.reportIds.length}`);
2332
3485
  if (combined.pipelineRunId) console.log(` run: ${combined.pipelineRunId}`);
2333
3486
  console.log(` summary: ${combined.summary}`);
3487
+ if (combined.conflicts.length > 0) {
3488
+ console.log(` CONFLICTS (${combined.conflicts.length}) \u2014 both positions preserved:`);
3489
+ for (const conflict of combined.conflicts) {
3490
+ console.log(` ! ${conflict.findingTitle} [${conflict.type}]`);
3491
+ for (const position2 of conflict.positions) {
3492
+ console.log(
3493
+ ` ${position2.agent}: severity=${position2.severity ?? "unstated"}${position2.recommendation ? ` fix="${position2.recommendation}"` : ""}`
3494
+ );
3495
+ }
3496
+ console.log(` ${conflict.note}`);
3497
+ }
3498
+ }
3499
+ if (combined.agreements.length > 0) {
3500
+ console.log(` agreements: ${combined.agreements.length} (multi-agent consensus)`);
3501
+ }
2334
3502
  if (combined.findings.length > 0) {
2335
3503
  console.log(" findings (grouped, duplicates merged):");
2336
3504
  for (const finding of combined.findings) {
@@ -2367,19 +3535,32 @@ var HeuristicPlanner = class {
2367
3535
  name = "heuristic";
2368
3536
  async plan(request) {
2369
3537
  const steps = [];
2370
- const findings = request.reports.flatMap(
2371
- (report) => report.findings.map((finding) => ({ finding, agent: report.agent }))
2372
- );
2373
3538
  const recommendations = dedupe(request.reports.flatMap((r) => r.recommendations));
2374
- for (const [index, text] of recommendations.entries()) {
3539
+ for (const text of recommendations) {
2375
3540
  steps.push({
2376
3541
  id: `step-${steps.length + 1}`,
2377
3542
  title: truncate5(text, 120),
2378
3543
  order: steps.length + 1,
2379
- ...index > 0 ? { dependsOn: [`step-${steps.length}`] } : {}
3544
+ ...steps.length > 0 ? { dependsOn: [`step-${steps.length}`] } : {}
2380
3545
  });
2381
3546
  }
2382
- const significant = findings.filter(({ finding }) => finding.severity ? SEVERITY_RANK2[finding.severity] >= SEVERITY_RANK2.high : false).sort((a, b) => SEVERITY_RANK2[b.finding.severity ?? "info"] - SEVERITY_RANK2[a.finding.severity ?? "info"]);
3547
+ const contributed = request.aggregated ? request.aggregated.findings.map((finding) => ({
3548
+ finding: {
3549
+ id: `agg-${slug(finding.title)}`,
3550
+ title: finding.title,
3551
+ detail: finding.details[0] ?? finding.title,
3552
+ ...finding.severity ? { severity: finding.severity } : {},
3553
+ ...finding.recommendations && finding.recommendations.length > 0 ? { recommendation: finding.recommendations[0] } : {}
3554
+ },
3555
+ agent: finding.sources.map((s) => s.agent).join("+")
3556
+ })) : request.reports.flatMap(
3557
+ (report) => report.findings.map((finding) => ({ finding, agent: report.agent }))
3558
+ );
3559
+ const significant = contributed.filter(
3560
+ ({ finding }) => finding.severity ? SEVERITY_RANK2[finding.severity] >= SEVERITY_RANK2.high : false
3561
+ ).sort(
3562
+ (a, b) => SEVERITY_RANK2[b.finding.severity ?? "info"] - SEVERITY_RANK2[a.finding.severity ?? "info"]
3563
+ );
2383
3564
  for (const { finding } of significant) {
2384
3565
  steps.push(remediationStep(finding, steps.length + 1));
2385
3566
  }
@@ -2392,12 +3573,20 @@ var HeuristicPlanner = class {
2392
3573
  });
2393
3574
  }
2394
3575
  const risks = significant.filter(({ finding }) => finding.severity === "critical").map(({ finding }) => `Critical finding unaddressed until its step completes: ${finding.title}`);
3576
+ const conflicts = request.aggregated?.conflicts ?? [];
3577
+ if (conflicts.length > 0) {
3578
+ risks.push(
3579
+ `${conflicts.length} unresolved agent disagreement(s): ${conflicts.map((c) => c.findingTitle).join("; ")}`
3580
+ );
3581
+ }
2395
3582
  return {
2396
3583
  id: `plan-${Date.now().toString(36)}-${randomBytes3(3).toString("hex")}`,
2397
3584
  objective: request.objective,
3585
+ goal: request.aggregated?.title ?? truncate5(request.objective, 120),
2398
3586
  summary: `Derived from ${request.reports.length} report(s): ${recommendations.length} recommendation step(s), ${significant.length} remediation step(s).`,
2399
3587
  steps,
2400
3588
  ...risks.length > 0 ? { risks } : {},
3589
+ ...request.constraints !== void 0 && request.constraints.length > 0 ? { constraints: request.constraints } : {},
2401
3590
  generatedBy: "heuristic",
2402
3591
  ...request.taskId !== void 0 ? { taskId: request.taskId } : {},
2403
3592
  ...request.pipelineRunId !== void 0 ? { pipelineRunId: request.pipelineRunId } : {},
@@ -2412,24 +3601,76 @@ function remediationStep(finding, order) {
2412
3601
  description: finding.detail,
2413
3602
  order,
2414
3603
  dependsOn: order > 1 ? [`step-${order - 1}`] : [],
2415
- sourceFindings: [finding.id]
3604
+ sourceFindings: [finding.id],
3605
+ ...finding.recommendation ? { recommendation: finding.recommendation } : {}
2416
3606
  };
2417
3607
  }
2418
3608
  function dedupe(texts) {
2419
3609
  return [...new Set(texts.map((t) => t.trim()).filter((t) => t.length > 0))];
2420
3610
  }
3611
+ function slug(text) {
3612
+ return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
3613
+ }
2421
3614
  function truncate5(text, max) {
2422
3615
  return text.length > max ? `${text.slice(0, max - 1)}\u2026` : text;
2423
3616
  }
2424
3617
 
2425
3618
  // src/planner/model.ts
2426
3619
  import { randomBytes as randomBytes4 } from "crypto";
2427
- import { z as z2 } from "zod";
3620
+
3621
+ // src/planner/schema.ts
3622
+ import { z as z3 } from "zod";
3623
+ var planStepSchema = z3.object({
3624
+ id: z3.string().min(1).optional(),
3625
+ title: z3.string().min(1),
3626
+ description: z3.string().optional(),
3627
+ order: z3.number().int().optional(),
3628
+ targets: z3.array(z3.string()).optional(),
3629
+ acceptance_criteria: z3.array(z3.string()).optional(),
3630
+ depends_on: z3.array(z3.string()).optional(),
3631
+ source_findings: z3.array(z3.string()).optional()
3632
+ });
3633
+ var planJsonSchema = z3.object({
3634
+ goal: z3.string().min(1),
3635
+ summary: z3.string().min(1),
3636
+ steps: z3.array(planStepSchema).min(1),
3637
+ risks: z3.array(z3.string()).optional(),
3638
+ notes: z3.array(z3.string()).optional()
3639
+ });
3640
+ function validatePlan(plan) {
3641
+ const issues = [];
3642
+ const stepIds = /* @__PURE__ */ new Set();
3643
+ for (const [index, step] of plan.steps.entries()) {
3644
+ if (stepIds.has(step.id)) {
3645
+ issues.push({ path: `steps.${index}.id`, message: `duplicate step id "${step.id}"` });
3646
+ }
3647
+ stepIds.add(step.id);
3648
+ if (!step.title || step.title.trim().length === 0) {
3649
+ issues.push({ path: `steps.${index}.title`, message: "title must not be empty" });
3650
+ }
3651
+ for (const dep of step.dependsOn ?? []) {
3652
+ if (!stepIds.has(dep) && !plan.steps.some((s) => s.id === dep)) {
3653
+ issues.push({ path: `steps.${index}.dependsOn`, message: `unknown step dependency "${dep}"` });
3654
+ }
3655
+ }
3656
+ }
3657
+ if (plan.steps.length === 0) {
3658
+ issues.push({ path: "steps", message: "a plan needs at least one step" });
3659
+ }
3660
+ if (!plan.summary || plan.summary.trim().length === 0) {
3661
+ issues.push({ path: "summary", message: "summary must not be empty" });
3662
+ }
3663
+ return issues.length > 0 ? { valid: false, issues, reason: `plan failed validation (${issues.length} issue(s))` } : { valid: true, plan };
3664
+ }
2428
3665
 
2429
3666
  // src/planner/context.ts
2430
3667
  function buildPlannerContext(request) {
2431
- const parts = [`# Objective
3668
+ const parts = [`# Task
2432
3669
  ${request.objective}`];
3670
+ if (request.projectContext) {
3671
+ parts.push(`# Project context
3672
+ ${request.projectContext}`);
3673
+ }
2433
3674
  if (request.role) {
2434
3675
  const roleLines = [`# Role
2435
3676
  ${request.role.name} (${request.role.id})`];
@@ -2449,7 +3690,7 @@ ${request.role.name} (${request.role.id})`];
2449
3690
  )
2450
3691
  );
2451
3692
  parts.push(
2452
- `# Findings (${request.reports.length} report(s) by ${agents})
3693
+ `# Agent reports (${request.reports.length} by ${agents})
2453
3694
  ` + (findingLines.length > 0 ? findingLines.join("\n") : "(no findings reported)")
2454
3695
  );
2455
3696
  const recommendationLines = request.reports.flatMap(
@@ -2459,9 +3700,20 @@ ${request.role.name} (${request.role.id})`];
2459
3700
  parts.push(`# Recommendations
2460
3701
  ${recommendationLines.join("\n")}`);
2461
3702
  }
2462
- const summaryLines = request.reports.map((report) => `- [${report.agent}] ${report.summary}`);
2463
- parts.push(`# Report summaries
2464
- ${summaryLines.join("\n")}`);
3703
+ }
3704
+ if (request.aggregated) {
3705
+ const aggregatedLines = [
3706
+ `- findings: ${request.aggregated.findings.length} (agreements: ${request.aggregated.agreements.length}, conflicts: ${request.aggregated.conflicts.length})`,
3707
+ ...request.aggregated.findings.map(
3708
+ (finding) => `- [${finding.severity ?? "info"}] ${finding.title} (x${finding.occurrences})`
3709
+ ),
3710
+ // Conflicts stay visible to the planner — it must plan around disagreement.
3711
+ ...request.aggregated.conflicts.map(
3712
+ (conflict) => `- CONFLICT (${conflict.type}) on "${conflict.findingTitle}": ${conflict.note}`
3713
+ )
3714
+ ];
3715
+ parts.push(`# Aggregated findings
3716
+ ${aggregatedLines.join("\n")}`);
2465
3717
  }
2466
3718
  if (request.memory && request.memory.length > 0) {
2467
3719
  parts.push(
@@ -2469,7 +3721,11 @@ ${summaryLines.join("\n")}`);
2469
3721
  ` + request.memory.map((entry2) => `- [${entry2.scope}] ${entry2.content}`).join("\n")
2470
3722
  );
2471
3723
  }
2472
- if (request.context && request.context.length > 0) {
3724
+ if (request.constraints && request.constraints.length > 0) {
3725
+ parts.push(`# Constraints
3726
+ ${request.constraints.map((c) => `- ${c}`).join("\n")}`);
3727
+ }
3728
+ if (request.context) {
2473
3729
  parts.push(`# Research context
2474
3730
  ${request.context}`);
2475
3731
  }
@@ -2477,22 +3733,6 @@ ${request.context}`);
2477
3733
  }
2478
3734
 
2479
3735
  // src/planner/model.ts
2480
- var planStepJsonSchema = z2.object({
2481
- id: z2.string().optional(),
2482
- title: z2.string().min(1),
2483
- description: z2.string().optional(),
2484
- order: z2.number().int().optional(),
2485
- targets: z2.array(z2.string()).optional(),
2486
- acceptance_criteria: z2.array(z2.string()).optional(),
2487
- depends_on: z2.array(z2.string()).optional(),
2488
- source_findings: z2.array(z2.string()).optional()
2489
- }).strict();
2490
- var planJsonSchema = z2.object({
2491
- summary: z2.string().min(1),
2492
- steps: z2.array(planStepJsonSchema).min(1),
2493
- risks: z2.array(z2.string()).optional(),
2494
- notes: z2.array(z2.string()).optional()
2495
- }).strict();
2496
3736
  var ModelPlanner = class {
2497
3737
  constructor(options) {
2498
3738
  this.options = options;
@@ -2506,7 +3746,7 @@ var ModelPlanner = class {
2506
3746
  const messages = [
2507
3747
  {
2508
3748
  role: "system",
2509
- content: 'You are the OmniNode planner. Given the objective and the combined research context, produce a final analysis and a prioritized, actionable implementation plan. Respond with JSON ONLY (no prose, no code fences) matching exactly this shape: {"summary": string, "steps": [{"id": string, "title": string, "description": string, "order": number, "targets": string[], "acceptance_criteria": string[], "source_findings": string[]}], "risks": string[], "notes": string[]}. ' + (this.options.instruction ?? "")
3749
+ content: 'You are the OmniNode planner. Given the objective and the combined research context, produce a final analysis and a prioritized, actionable implementation plan. Respond with JSON ONLY (no prose, no code fences) matching exactly this shape: {"goal": string, "summary": string, "steps": [{"id": string, "title": string, "description": string, "order": number, "targets": string[], "acceptance_criteria": string[], "source_findings": string[]}], "risks": string[], "notes": string[]}. ' + (this.options.instruction ?? "")
2510
3750
  },
2511
3751
  { role: "user", content: context }
2512
3752
  ];
@@ -2517,60 +3757,86 @@ var ModelPlanner = class {
2517
3757
  return this.degrade(request, `planner model call failed: ${describe(error)}`);
2518
3758
  }
2519
3759
  const parsed = parsePlanJson(responseText);
2520
- if (!parsed) {
2521
- return this.degrade(request, "planner model output was not valid plan JSON");
3760
+ if (!parsed.ok) {
3761
+ return this.degrade(request, `planner model output rejected: ${parsed.reason}`, parsed.issues);
2522
3762
  }
2523
3763
  const plan = {
2524
3764
  id: `plan-${Date.now().toString(36)}-${randomBytes4(3).toString("hex")}`,
2525
3765
  objective: request.objective,
3766
+ goal: parsed.goal,
2526
3767
  summary: parsed.summary,
2527
3768
  steps: parsed.steps,
2528
3769
  ...parsed.risks !== void 0 ? { risks: parsed.risks } : {},
2529
3770
  ...parsed.notes !== void 0 ? { notes: parsed.notes } : {},
3771
+ ...request.constraints !== void 0 && request.constraints.length > 0 ? { constraints: request.constraints } : {},
2530
3772
  generatedBy: `model:${this.options.model}`,
2531
3773
  model: this.options.model,
2532
3774
  ...request.taskId !== void 0 ? { taskId: request.taskId } : {},
2533
3775
  ...request.pipelineRunId !== void 0 ? { pipelineRunId: request.pipelineRunId } : {},
2534
3776
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
2535
3777
  };
2536
- return plan;
3778
+ const validation = validatePlan(plan);
3779
+ if (!validation.valid) {
3780
+ return this.degrade(request, `generated plan rejected: ${validation.reason}`, validation.issues);
3781
+ }
3782
+ return validation.plan;
2537
3783
  }
2538
- async degrade(request, reason) {
3784
+ async degrade(request, reason, issues = []) {
2539
3785
  if (this.options.fallback) {
2540
3786
  this.log.warn(`${reason} \u2014 using the heuristic fallback planner.`);
2541
3787
  return this.options.fallback.plan(request);
2542
3788
  }
2543
- throw new PlannerError("PLANNER_FAILED", `Planner failed: ${reason}.`);
3789
+ throw new PlannerError("PLAN_INVALID", `Planner failed: ${reason}.`, {
3790
+ details: { reason, issues }
3791
+ });
2544
3792
  }
2545
3793
  };
2546
3794
  function parsePlanJson(text) {
2547
3795
  const unfenced = text.replace(/```(?:json)?/gi, "");
2548
3796
  const start = unfenced.indexOf("{");
2549
3797
  const end = unfenced.lastIndexOf("}");
2550
- if (start < 0 || end <= start) return void 0;
3798
+ if (start < 0 || end <= start) {
3799
+ return { ok: false, reason: "planner output contained no JSON object", issues: [] };
3800
+ }
3801
+ let json;
2551
3802
  try {
2552
- const json = JSON.parse(unfenced.slice(start, end + 1));
2553
- const parsed = planJsonSchema.safeParse(json);
2554
- if (!parsed.success) return void 0;
2555
- const steps = parsed.data.steps.map((step, index) => ({
2556
- id: step.id ?? `step-${index + 1}`,
2557
- title: step.title,
2558
- description: step.description,
2559
- order: step.order ?? index + 1,
2560
- targets: step.targets,
2561
- acceptanceCriteria: step.acceptance_criteria,
2562
- dependsOn: step.depends_on,
2563
- sourceFindings: step.source_findings
2564
- }));
3803
+ json = JSON.parse(unfenced.slice(start, end + 1));
3804
+ } catch (error) {
2565
3805
  return {
2566
- summary: parsed.data.summary,
2567
- steps: steps.sort((a, b) => a.order - b.order),
2568
- risks: parsed.data.risks,
2569
- notes: parsed.data.notes
3806
+ ok: false,
3807
+ reason: `planner output was not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
3808
+ issues: []
3809
+ };
3810
+ }
3811
+ const parsed = planJsonSchema.safeParse(json);
3812
+ if (!parsed.success) {
3813
+ return {
3814
+ ok: false,
3815
+ reason: "planner output did not match the plan schema",
3816
+ issues: parsed.error.issues.map((issue) => ({
3817
+ path: issue.path.join(".") || "(root)",
3818
+ message: issue.message
3819
+ }))
2570
3820
  };
2571
- } catch {
2572
- return void 0;
2573
3821
  }
3822
+ const steps = parsed.data.steps.map((step, index) => ({
3823
+ id: step.id ?? `step-${index + 1}`,
3824
+ title: step.title,
3825
+ description: step.description,
3826
+ order: step.order ?? index + 1,
3827
+ targets: step.targets,
3828
+ acceptanceCriteria: step.acceptance_criteria,
3829
+ dependsOn: step.depends_on,
3830
+ sourceFindings: step.source_findings
3831
+ })).sort((a, b) => a.order - b.order);
3832
+ return {
3833
+ ok: true,
3834
+ goal: parsed.data.goal,
3835
+ summary: parsed.data.summary,
3836
+ steps,
3837
+ risks: parsed.data.risks,
3838
+ notes: parsed.data.notes
3839
+ };
2574
3840
  }
2575
3841
  function describe(error) {
2576
3842
  return error instanceof Error ? error.message : String(error);
@@ -2584,9 +3850,23 @@ var PipelineEngine = class {
2584
3850
  }
2585
3851
  options;
2586
3852
  log;
3853
+ /** Runs executing in THIS process — cancellation can terminate them directly. */
3854
+ activeRuns = /* @__PURE__ */ new Map();
2587
3855
  get store() {
2588
3856
  return this.options.store;
2589
3857
  }
3858
+ /**
3859
+ * Saves the run while preserving fields that may be set by another process
3860
+ * (e.g. a cancellation request) — an in-memory snapshot must never clobber
3861
+ * externally requested cancellation (§9.2).
3862
+ */
3863
+ async persist(run2) {
3864
+ const current = await this.options.store.get(run2.id);
3865
+ await this.options.store.save({
3866
+ ...run2,
3867
+ ...current?.cancellationRequested === true ? { cancellationRequested: true, cancellationRequestedAt: current.cancellationRequestedAt } : {}
3868
+ });
3869
+ }
2590
3870
  /** Structural + semantic validation (§27 Phase 5 — pipeline parser). */
2591
3871
  validate(def) {
2592
3872
  const ids = /* @__PURE__ */ new Set();
@@ -2686,18 +3966,44 @@ var PipelineEngine = class {
2686
3966
  );
2687
3967
  }
2688
3968
  }
3969
+ if (step.model) {
3970
+ const separator = step.model.indexOf(":");
3971
+ if (separator <= 0 || separator === step.model.length - 1) {
3972
+ throw new PipelineError(
3973
+ "PIPELINE_INVALID",
3974
+ `Step "${step.id}" has an invalid model reference "${step.model}" \u2014 use "provider:model-id".`
3975
+ );
3976
+ }
3977
+ const providerName = step.model.slice(0, separator);
3978
+ if (!this.options.providers.some((p) => p.name === providerName)) {
3979
+ throw new PipelineError(
3980
+ "PIPELINE_INVALID",
3981
+ `Step "${step.id}" uses model "${step.model}" but provider "${providerName}" is not configured.`
3982
+ );
3983
+ }
3984
+ }
2689
3985
  }
2690
3986
  const now = (/* @__PURE__ */ new Date()).toISOString();
2691
3987
  const run2 = {
2692
3988
  id: `run-${Date.now().toString(36)}-${randomBytes5(3).toString("hex")}`,
2693
3989
  pipelineId: def.id,
3990
+ attempt: 1,
2694
3991
  objective,
2695
3992
  status: "running",
2696
3993
  startedAt: now,
2697
3994
  stepRuns: []
2698
3995
  };
2699
3996
  await this.options.store.save(run2);
2700
- this.log.info(`Running pipeline ${def.id} as ${run2.id}.`);
3997
+ const liveTaskIds = /* @__PURE__ */ new Set();
3998
+ this.activeRuns.set(run2.id, {
3999
+ taskIds: liveTaskIds,
4000
+ cancelTasks: async () => {
4001
+ for (const taskId of liveTaskIds) {
4002
+ await this.options.tasks.cancel(taskId).catch(() => void 0);
4003
+ }
4004
+ }
4005
+ });
4006
+ this.log.info(`Running pipeline ${def.id} as ${run2.id} (attempt ${run2.attempt}).`);
2701
4007
  await this.options.audit?.record({
2702
4008
  at: (/* @__PURE__ */ new Date()).toISOString(),
2703
4009
  action: "pipeline.run.started",
@@ -2709,7 +4015,22 @@ var PipelineEngine = class {
2709
4015
  const pending = new Set(def.steps.map((s) => s.id));
2710
4016
  const contextParts = [];
2711
4017
  const allReports = [];
4018
+ let cancelled = false;
2712
4019
  while (pending.size > 0) {
4020
+ const persisted = await this.options.store.get(run2.id);
4021
+ if (persisted?.cancellationRequested === true) {
4022
+ cancelled = true;
4023
+ run2.cancellationRequested = true;
4024
+ run2.cancellationRequestedAt = persisted.cancellationRequestedAt;
4025
+ await this.activeRuns.get(run2.id)?.cancelTasks();
4026
+ for (const step of def.steps.filter((s) => pending.has(s.id))) {
4027
+ const stepRun = { stepId: step.id, status: "cancelled" };
4028
+ run2.stepRuns.push(stepRun);
4029
+ options.onStep?.(stepRun);
4030
+ }
4031
+ pending.clear();
4032
+ break;
4033
+ }
2713
4034
  const ready = def.steps.filter(
2714
4035
  (step) => pending.has(step.id) && (deps.get(step.id) ?? []).every((d) => outcomes.has(d))
2715
4036
  );
@@ -2726,7 +4047,8 @@ var PipelineEngine = class {
2726
4047
  ...role ? { role } : {},
2727
4048
  combinedContext: contextParts.join("\n\n"),
2728
4049
  reports: [...allReports],
2729
- pipelineRunId: run2.id
4050
+ pipelineRunId: run2.id,
4051
+ runId: run2.id
2730
4052
  };
2731
4053
  return this.executeStep(step, ctx);
2732
4054
  })
@@ -2743,7 +4065,9 @@ ${contribution}`);
2743
4065
  }
2744
4066
  const stepRun = {
2745
4067
  stepId: step.id,
2746
- status: outcome.status === "skipped" ? "cancelled" : outcome.status === "failed" ? "failed" : "completed",
4068
+ attempt: outcome.attempt ?? 1,
4069
+ ...outcome.executionIds && outcome.executionIds.length > 0 ? { executionIds: outcome.executionIds } : {},
4070
+ status: outcome.status === "skipped" ? "cancelled" : outcome.status === "failed" ? "failed" : outcome.status === "partial" ? "partially_completed" : "completed",
2747
4071
  ...outcome.taskIds.length > 0 ? { taskIds: outcome.taskIds } : {},
2748
4072
  ...outcome.planId !== void 0 ? { planId: outcome.planId } : {},
2749
4073
  ...outcome.error !== void 0 ? { error: outcome.error } : {}
@@ -2752,10 +4076,12 @@ ${contribution}`);
2752
4076
  options.onStep?.(stepRun);
2753
4077
  });
2754
4078
  run2.reports = [...allReports];
2755
- await this.options.store.save(run2);
4079
+ await this.persist(run2);
2756
4080
  }
2757
- const failed = [...outcomes.values()].some((o) => o.status === "failed");
2758
- const finalStatus = failed ? "failed" : "completed";
4081
+ const allOutcomes = [...outcomes.values()];
4082
+ const failed = !cancelled && allOutcomes.some((o) => o.status === "failed");
4083
+ const partial = !cancelled && !failed && allOutcomes.some((o) => o.status === "partial");
4084
+ const finalStatus = cancelled ? "cancelled" : failed ? "failed" : partial ? "partial" : "completed";
2759
4085
  const planId = [...outcomes.values()].find((o) => o.planId !== void 0)?.planId;
2760
4086
  let resultSummary;
2761
4087
  for (let i = def.steps.length - 1; i >= 0; i -= 1) {
@@ -2783,16 +4109,19 @@ ${contribution}`);
2783
4109
  run2.reports = collected;
2784
4110
  }
2785
4111
  }
4112
+ const finishedAt = (/* @__PURE__ */ new Date()).toISOString();
4113
+ this.activeRuns.delete(run2.id);
2786
4114
  const finished = {
2787
4115
  ...run2,
2788
4116
  status: finalStatus,
2789
- finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
4117
+ finishedAt,
4118
+ attempts: [...run2.attempts ?? [], { attempt: run2.attempt, startedAt: run2.startedAt ?? finishedAt, finishedAt, status: finalStatus }],
2790
4119
  ...run2.reports !== void 0 && run2.reports.length > 0 ? { reports: run2.reports } : {},
2791
4120
  ...combinedReportId !== void 0 ? { combinedReportId } : {},
2792
4121
  ...planId !== void 0 ? { planId } : {},
2793
4122
  ...resultSummary !== void 0 ? { resultSummary } : {}
2794
4123
  };
2795
- await this.options.store.save(finished);
4124
+ await this.persist(finished);
2796
4125
  this.log.info(`Pipeline ${def.id} finished: ${finalStatus}.`);
2797
4126
  await this.options.audit?.record({
2798
4127
  at: (/* @__PURE__ */ new Date()).toISOString(),
@@ -2802,6 +4131,23 @@ ${contribution}`);
2802
4131
  });
2803
4132
  return finished;
2804
4133
  }
4134
+ /** First-class cancellation (§9.2): requests cancellation and terminates
4135
+ * tasks that are running in this process immediately. */
4136
+ async cancel(runId) {
4137
+ const run2 = await this.options.store.get(runId);
4138
+ if (!run2) {
4139
+ throw new PipelineError("PIPELINE_NOT_FOUND", `Pipeline run "${runId}" does not exist.`);
4140
+ }
4141
+ await this.options.store.save({
4142
+ ...run2,
4143
+ cancellationRequested: true,
4144
+ cancellationRequestedAt: (/* @__PURE__ */ new Date()).toISOString()
4145
+ });
4146
+ const active = this.activeRuns.get(runId);
4147
+ if (active) {
4148
+ await active.cancelTasks();
4149
+ }
4150
+ }
2805
4151
  async get(runId) {
2806
4152
  return this.options.store.get(runId);
2807
4153
  }
@@ -2825,9 +4171,16 @@ ${contribution}`);
2825
4171
  this.log.warn(`Step "${step.id}" failed \u2014 retry ${attempt}/${retries}.`);
2826
4172
  }
2827
4173
  last = await this.executeStepOnce(step, ctx);
2828
- if (last.status !== "failed") return last;
4174
+ if (last.status !== "failed") return { ...last, attempt: attempt + 1 };
2829
4175
  }
2830
- return last;
4176
+ return { ...last, attempt: retries + 1 };
4177
+ }
4178
+ /** Registers tasks created for a step as live, so cancellation can terminate them (§9.2). */
4179
+ trackLiveTasks(runId, taskIds) {
4180
+ if (!runId) return;
4181
+ const active = this.activeRuns.get(runId);
4182
+ if (!active) return;
4183
+ for (const id of taskIds) active.taskIds.add(id);
2831
4184
  }
2832
4185
  async executeStepOnce(step, ctx) {
2833
4186
  switch (step.kind) {
@@ -2860,7 +4213,10 @@ ${contribution}`);
2860
4213
  })
2861
4214
  )
2862
4215
  );
4216
+ this.trackLiveTasks(ctx.runId, created.map((t) => t.id));
2863
4217
  const results = await Promise.all(created.map((task) => this.options.tasks.run(task.id)));
4218
+ const executionIds = results.map((r) => r.executions?.at(-1)?.executionId).filter((id) => id !== void 0);
4219
+ const completedTasks = results.filter((r) => r.status === "completed");
2864
4220
  const failedTasks = results.filter((r) => r.status !== "completed");
2865
4221
  const lines = results.map((result, index) => {
2866
4222
  const agentName = agentNames[index] ?? "unknown";
@@ -2870,16 +4226,25 @@ ${contribution}`);
2870
4226
  const reports = results.flatMap((r) => r.result?.reports ?? []);
2871
4227
  const summary = lines.join("\n");
2872
4228
  if (failedTasks.length > 0) {
2873
- const firstError = failedTasks[0]?.result?.error ?? "unknown error";
4229
+ const firstError = failedTasks[0]?.result?.error ?? failedTasks[0]?.lastError ?? "unknown outcome";
4230
+ const states = failedTasks.map((r) => r.status).join(", ");
2874
4231
  return {
2875
- status: "failed",
4232
+ // Some agents succeeded → the step produced usable but incomplete
4233
+ // intelligence: partial, not failed (roadmap §9.3).
4234
+ status: completedTasks.length > 0 ? "partial" : "failed",
2876
4235
  summary,
2877
4236
  taskIds: created.map((t) => t.id),
2878
- error: `${failedTasks.length}/${results.length} task(s) failed \u2014 ${firstError}`,
4237
+ error: `${failedTasks.length}/${results.length} task(s) incomplete (${states}) \u2014 ${firstError}`,
2879
4238
  reports
2880
4239
  };
2881
4240
  }
2882
- return { status: "completed", summary, taskIds: created.map((t) => t.id), reports };
4241
+ return {
4242
+ status: "completed",
4243
+ summary,
4244
+ taskIds: created.map((t) => t.id),
4245
+ ...executionIds.length > 0 ? { executionIds } : {},
4246
+ reports
4247
+ };
2883
4248
  }
2884
4249
  async runSingle(step, ctx, agentName) {
2885
4250
  const task = await this.options.tasks.create({
@@ -2888,10 +4253,13 @@ ${contribution}`);
2888
4253
  ...ctx.role ? { role: ctx.role.id } : {},
2889
4254
  ...ctx.combinedContext.length > 0 ? { context: { background: ctx.combinedContext } } : {}
2890
4255
  });
4256
+ this.trackLiveTasks(ctx.runId, [task.id]);
2891
4257
  const result = await this.options.tasks.run(task.id);
2892
4258
  if (result.status !== "completed") {
2893
4259
  return {
2894
- status: "failed",
4260
+ // Unknown/timed_out outcomes are not failures — the work may have
4261
+ // happened remotely; the step is incomplete, not broken.
4262
+ status: result.status === "unknown" || result.status === "timed_out" ? "partial" : "failed",
2895
4263
  summary: result.result?.summary ?? "",
2896
4264
  taskIds: [task.id],
2897
4265
  error: result.result?.error ?? `task ${task.id} did not complete`,
@@ -2906,11 +4274,14 @@ ${contribution}`);
2906
4274
  };
2907
4275
  }
2908
4276
  async runPlan(step, ctx) {
4277
+ const aggregated = ctx.reports.length > 0 ? aggregateReports(ctx.reports) : void 0;
2909
4278
  const request = {
2910
4279
  objective: ctx.objective,
2911
4280
  reports: ctx.reports,
4281
+ ...aggregated !== void 0 ? { aggregated } : {},
2912
4282
  ...ctx.combinedContext.length > 0 ? { context: ctx.combinedContext } : {},
2913
4283
  ...ctx.role ? { role: ctx.role } : {},
4284
+ ...step.constraints !== void 0 && step.constraints.length > 0 ? { constraints: step.constraints } : {},
2914
4285
  ...ctx.pipelineRunId !== void 0 ? { pipelineRunId: ctx.pipelineRunId } : {}
2915
4286
  };
2916
4287
  let planner;
@@ -3078,7 +4449,11 @@ function buildPipelineEngine(config, log = logger) {
3078
4449
  agents,
3079
4450
  roles,
3080
4451
  store: new FileTaskStore(),
3081
- ...config.project.memory !== void 0 ? { memory: new MemoryService(createMemoryProvider(config.project.memory), log) } : {},
4452
+ ...config.project.memory !== void 0 ? {
4453
+ memory: new MemoryService(createMemoryProvider(config.project.memory), log, {
4454
+ required: config.project.memory.required
4455
+ })
4456
+ } : {},
3082
4457
  audit: new FileAuditLog(),
3083
4458
  log
3084
4459
  });
@@ -3136,6 +4511,24 @@ function registerPipelineCommands(program) {
3136
4511
  }
3137
4512
  await runPipelineAction(def.id, objective ?? previous.objective);
3138
4513
  });
4514
+ pipeline.command("cancel <run-id>").description("Request cancellation of a pipeline run (stops scheduling and cancels running tasks).").action(async (runId) => {
4515
+ const config = loadConfig();
4516
+ const engine = buildPipelineEngine(config);
4517
+ await engine.cancel(runId);
4518
+ console.log(`Cancellation requested for run ${runId}.`);
4519
+ console.log("A run executing in another process stops at its next check point.");
4520
+ });
4521
+ pipeline.command("validate <id>").description("Validate a pipeline definition before running it (\xA79.5).").action(async (id) => {
4522
+ const config = loadConfig();
4523
+ const def = findPipeline(config, id);
4524
+ const engine = buildPipelineEngine(config);
4525
+ engine.validate(def);
4526
+ console.log(`Pipeline "${def.id}" is valid (${def.steps.length} step(s)).`);
4527
+ for (const step of def.steps) {
4528
+ const deps = step.dependsOn?.join(", ") ?? "(previous step)";
4529
+ console.log(` ${step.id} kind=${step.kind} depends_on=${deps}`);
4530
+ }
4531
+ });
3139
4532
  pipeline.command("runs <id>").description("List recorded runs of a pipeline.").action(async (id) => {
3140
4533
  const config = loadConfig();
3141
4534
  const engine = buildPipelineEngine(config);
@@ -3145,7 +4538,8 @@ function registerPipelineCommands(program) {
3145
4538
  return;
3146
4539
  }
3147
4540
  for (const run2 of runs) {
3148
- console.log(`${run2.id} ${run2.status.padEnd(10)} ${run2.startedAt ?? "?"}`);
4541
+ const note = isInterrupted(run2) ? " (interrupted \u2014 process gone before finish)" : "";
4542
+ console.log(`${run2.id} ${run2.status.padEnd(10)} ${run2.startedAt ?? "?"}${note}`);
3149
4543
  }
3150
4544
  });
3151
4545
  pipeline.command("status <run-id>").description("Show the recorded state of a pipeline run.").action(async (runId) => {
@@ -3163,6 +4557,13 @@ function registerPipelineCommands(program) {
3163
4557
  console.log(` status: ${run2.status}`);
3164
4558
  console.log(` started: ${run2.startedAt ?? "?"}`);
3165
4559
  if (run2.finishedAt) console.log(` finished: ${run2.finishedAt}`);
4560
+ console.log(` attempt: ${run2.attempt}`);
4561
+ if (run2.cancellationRequested) {
4562
+ console.log(` cancellation requested at ${run2.cancellationRequestedAt ?? "?"}`);
4563
+ }
4564
+ if (isInterrupted(run2)) {
4565
+ console.log(" note: this run looks interrupted (no finishedAt) \u2014 retry with `pipeline retry`");
4566
+ }
3166
4567
  if (run2.resultSummary) console.log(` result: ${run2.resultSummary}`);
3167
4568
  for (const stepRun of run2.stepRuns) {
3168
4569
  console.log(` [${stepRun.stepId}] ${stepRun.status}`);
@@ -3204,6 +4605,9 @@ async function runPipelineAction(id, objective) {
3204
4605
  function registerRunAlias(program) {
3205
4606
  program.command("run <id> [objective]").description("Alias of `omninode pipeline run` \u2014 execute a configured pipeline end to end.").action(async (id, objective) => runPipelineAction(id, objective));
3206
4607
  }
4608
+ function isInterrupted(run2) {
4609
+ return run2.status === "running" && run2.finishedAt === void 0;
4610
+ }
3207
4611
  function findPipeline(config, id) {
3208
4612
  const def = config.project.pipelines.find((p) => p.id === id);
3209
4613
  if (!def) {