@oxygen-agent/cli 1.627.4 → 1.632.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,4 +34,4 @@ oxygen update
34
34
 
35
35
  For product documentation, visit https://oxygen-agent.com/docs. For support, visit https://oxygen-agent.com.
36
36
 
37
- Version: 1.627.4
37
+ Version: 1.632.1
package/dist/index.js CHANGED
@@ -667,6 +667,10 @@ function writeMaxCreditsHint(error) {
667
667
  return;
668
668
  }
669
669
  case "approval_required": {
670
+ if (error.message.startsWith("Public replies require --approved")) {
671
+ process.stderr.write("hint: inspect the exact preview, then re-run with its --content-hash <sha256> and --approved\n");
672
+ return;
673
+ }
670
674
  // Free-but-irreversible gates (tables/columns delete --now) approve with
671
675
  // --yes and spend nothing — the spend-flag hint would name flags the
672
676
  // command does not take. The server message is the signal: it spells out
@@ -2035,6 +2039,30 @@ function buildPublishingAnalyticsPath(base, params) {
2035
2039
  const suffix = query.toString();
2036
2040
  return suffix ? `${base}?${suffix}` : base;
2037
2041
  }
2042
+ function buildPublishingCommentsListPath(options) {
2043
+ return buildPublishingAnalyticsPath("/api/cli/publishing/comments", {
2044
+ status: readOption(options.status),
2045
+ post_id: readOption(options.post),
2046
+ assignee: readOption(options.assignee),
2047
+ channel: readOption(options.channel),
2048
+ cursor: readOption(options.cursor),
2049
+ limit: readOption(options.limit),
2050
+ });
2051
+ }
2052
+ function readPublishingCommentText(text, file, label) {
2053
+ if (readOption(text) && readOption(file)) {
2054
+ throw new OxygenError("conflicting_flags", `Pass either --${label} or --${label}-file, not both.`, {
2055
+ exitCode: 1,
2056
+ });
2057
+ }
2058
+ const value = readOption(file)
2059
+ ? readPublishingTextFile(readOption(file) ?? "", `--${label}-file`)
2060
+ : readOption(text);
2061
+ if (!value?.trim()) {
2062
+ throw new OxygenError("invalid_request", `Pass --${label} or --${label}-file.`, { exitCode: 1 });
2063
+ }
2064
+ return value.trim();
2065
+ }
2038
2066
  // `--file` is the whole import: a .csv is posted verbatim as `csv` (the API owns
2039
2067
  // the parse, so the CLI and the web importer can never disagree about a quoted
2040
2068
  // comma); a .json is posted as `rows`. Dry-run is the DEFAULT — a bare
@@ -3288,7 +3316,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
3288
3316
  }));
3289
3317
  program
3290
3318
  .command("publishing")
3291
- .description("Deterministic social post scheduling commands.")
3319
+ .description("Social publishing, performance, and public-comment operations.")
3292
3320
  .addCommand(new Command("mentions")
3293
3321
  .description("Resolve LinkedIn identities for a publish-faithful post preview.")
3294
3322
  .addCommand(new Command("resolve")
@@ -3614,11 +3642,123 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
3614
3642
  method: "POST",
3615
3643
  body: buildPublishingTagsBody(options),
3616
3644
  }));
3645
+ })))
3646
+ .addCommand(new Command("comments")
3647
+ .description("Public comments on posts published through OXYGEN. These are network conversations, not workspace review notes. The local queue targets a six-hour background poll; provider quota/backoff can delay it, and list reports the exact freshness plus empty_queue_is_current. If that field is false, do not conclude there are no comments: wait for next_sync_at. The worker retries automatically; there is no manual retry by design because it could loop against LinkedIn quota. Queue reads use 0 credits and make no provider call; only explicit approval can post a public reply.")
3648
+ .addCommand(new Command("list")
3649
+ .description("List the local public-comment work queue, oldest first, with polling freshness/backoff even when empty. Defaults to LinkedIn comments needing a reply. Read-only: no provider call, 0 credits.")
3650
+ .option("--status <status>", "Filter by needs_reply, draft, awaiting_approval, replied, resolved, ignored, spam, or action_unavailable.")
3651
+ .option("--post <post_id>", "Only comments on one scheduled Publishing post.")
3652
+ .option("--assignee <actor>", "Only one actor id; pass me for your own assignments.")
3653
+ .option("--channel <channel>", "Social channel. Defaults to linkedin.")
3654
+ .option("--cursor <cursor>", "Opaque next_cursor from the previous page.")
3655
+ .option("--limit <n>", "Maximum comments to return. Defaults to 50; maximum 100.")
3656
+ .option("--json", "Print a JSON envelope.")
3657
+ .action(async (options) => {
3658
+ await handleAsyncAction("publishing comments list", options, () => requestOxygen(buildPublishingCommentsListPath(options)));
3659
+ }))
3660
+ .addCommand(new Command("get")
3661
+ .description("Get one stored public comment with its provider thread and immutable reply-action history. Read-only: no provider call, 0 credits.")
3662
+ .argument("<comment_id>", "OXYGEN public comment id.")
3663
+ .option("--json", "Print a JSON envelope.")
3664
+ .action(async (commentId, options) => {
3665
+ await handleAsyncAction("publishing comments get", options, () => requestOxygen(`/api/cli/publishing/comments/${encodeURIComponent(commentId)}`));
3666
+ }))
3667
+ .addCommand(new Command("update")
3668
+ .description("Save local draft, notes, assignment, or triage state. Nothing is posted publicly and no credits are used.")
3669
+ .argument("<comment_id>", "OXYGEN public comment id.")
3670
+ .option("--draft <text>", "Save a reply draft locally.")
3671
+ .option("--draft-file <path>", "Read the local reply draft from a file.")
3672
+ .option("--notes <text>", "Workspace-only operator notes.")
3673
+ .option("--assignee <actor_id>", "Assign to a workspace actor id; pass an empty string to clear.")
3674
+ .option("--status <status>", "Set needs_reply, draft, resolved, ignored, or spam.")
3675
+ .option("--json", "Print a JSON envelope.")
3676
+ .action(async (commentId, options) => {
3677
+ await handleAsyncAction("publishing comments update", options, () => {
3678
+ const hasDraft = Boolean(readOption(options.draft) || readOption(options.draftFile));
3679
+ if (!hasDraft && options.notes === undefined && options.assignee === undefined && options.status === undefined) {
3680
+ throw new OxygenError("invalid_request", "Pass --draft/--draft-file, --notes, --assignee, or --status.", { exitCode: 1 });
3681
+ }
3682
+ return requestOxygen(`/api/cli/publishing/comments/${encodeURIComponent(commentId)}`, {
3683
+ method: "PATCH",
3684
+ body: {
3685
+ ...(hasDraft
3686
+ ? { draft_body: readPublishingCommentText(options.draft, options.draftFile, "draft") }
3687
+ : {}),
3688
+ ...(options.notes !== undefined ? { notes: options.notes } : {}),
3689
+ ...(options.assignee !== undefined
3690
+ ? { assignee_actor_id: readOption(options.assignee) ?? null }
3691
+ : {}),
3692
+ ...(readOption(options.status) ? { status: readOption(options.status) } : {}),
3693
+ },
3694
+ });
3695
+ });
3696
+ }))
3697
+ .addCommand(new Command("reply")
3698
+ .description("Preview an exact public reply and return its action_id + content_hash. Makes no provider call, posts nothing, and uses 0 credits.")
3699
+ .argument("<comment_id>", "OXYGEN public comment id.")
3700
+ .option("--text <text>", "Reply text to preview.")
3701
+ .option("--text-file <path>", "Read reply text from a file.")
3702
+ .option("--idempotency-key <key>", "Stable key for safely recreating the same preview.")
3703
+ .option("--json", "Print a JSON envelope.")
3704
+ .action(async (commentId, options) => {
3705
+ await handleAsyncAction("publishing comments reply preview", options, () => requestOxygen(`/api/cli/publishing/comments/${encodeURIComponent(commentId)}/reply-actions`, {
3706
+ method: "POST",
3707
+ body: {
3708
+ body: readPublishingCommentText(options.text, options.textFile, "text"),
3709
+ ...(readOption(options.idempotencyKey)
3710
+ ? { idempotency_key: readOption(options.idempotencyKey) }
3711
+ : {}),
3712
+ },
3713
+ }));
3714
+ }))
3715
+ .addCommand(new Command("approve")
3716
+ .description("Post one exact previewed reply publicly. A REAL LinkedIn write; refused without --approved and the preview's content hash.")
3717
+ .argument("<action_id>", "Reply action id returned by `publishing comments reply`.")
3718
+ .requiredOption("--content-hash <sha256>", "Exact content_hash returned by the preview.")
3719
+ .option("--approved", "Approve this exact public reply.")
3720
+ .option("--json", "Print a JSON envelope.")
3721
+ .action(async (actionId, options) => {
3722
+ await handleAsyncAction("publishing comments reply approve", options, () => {
3723
+ if (options.approved !== true) {
3724
+ throw new OxygenError("approval_required", "Public replies require --approved after inspecting the exact preview.", { exitCode: 7 });
3725
+ }
3726
+ return requestOxygen(`/api/cli/publishing/comment-replies/${encodeURIComponent(actionId)}/approve`, {
3727
+ method: "POST",
3728
+ body: { content_hash: options.contentHash, approved: true },
3729
+ });
3730
+ });
3731
+ }))
3732
+ .addCommand(new Command("resolve")
3733
+ .description("Resolve, ignore, mark spam, or reopen one local public-comment work item. No provider write, 0 credits.")
3734
+ .argument("<comment_id>", "OXYGEN public comment id.")
3735
+ .option("--resolution <status>", "resolved (default), ignored, spam, or needs_reply.")
3736
+ .option("--json", "Print a JSON envelope.")
3737
+ .action(async (commentId, options) => {
3738
+ await handleAsyncAction("publishing comments resolve", options, () => requestOxygen(`/api/cli/publishing/comments/${encodeURIComponent(commentId)}/resolve`, {
3739
+ method: "POST",
3740
+ body: { resolution: readOption(options.resolution) ?? "resolved" },
3741
+ }));
3617
3742
  })))
3618
3743
  .addCommand(new Command("analytics")
3619
3744
  .description("Engagement metrics the worker syncs back from the provider. Free reads. LinkedIn exposes reactions, comments, and reshares only — impressions, saves, and sends come back null, never zero.")
3745
+ .addCommand(new Command("summary")
3746
+ .description("Workspace LinkedIn performance for posts published through OXYGEN. Uses each post's latest stored snapshot; unsupported metrics stay null. Read-only: no provider call, 0 credits.")
3747
+ .option("--range <range>", "Publication window: 7d, 30d (default), 90d, or 365d.")
3748
+ .option("--from <date>", "Custom inclusive UTC start date.")
3749
+ .option("--to <date>", "Custom inclusive UTC end date.")
3750
+ .option("--limit <n>", "Top posts to return. Defaults to 10, maximum 50.")
3751
+ .option("--json", "Print a JSON envelope.")
3752
+ .action(async (options) => {
3753
+ await handleAsyncAction("publishing analytics summary", options, () => requestOxygen(buildPublishingAnalyticsPath("/api/cli/publishing/analytics/summary", {
3754
+ range: readOption(options.range),
3755
+ from: readOption(options.from),
3756
+ to: readOption(options.to),
3757
+ limit: readOption(options.limit),
3758
+ })));
3759
+ }))
3620
3760
  .addCommand(new Command("post")
3621
- .description("One post's daily metric series, totals, and earned-media value. EMV is impressions-derived, so on LinkedIn it returns null with an unavailable_reason instead of a made-up number.")
3761
+ .description("One post's stored daily metric series, totals, and earned-media value. EMV is impressions-derived, so on LinkedIn it returns null with an unavailable_reason instead of a made-up number. Read-only: no provider call, 0 credits.")
3622
3762
  .argument("<post_id>", "Scheduled post id.")
3623
3763
  .option("--since <date>", "ISO date to start the series from.")
3624
3764
  .option("--json", "Print a JSON envelope.")
@@ -3626,7 +3766,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
3626
3766
  await handleAsyncAction("publishing analytics post", options, () => requestOxygen(buildPublishingAnalyticsPath(`/api/cli/publishing/analytics/posts/${encodeURIComponent(postId)}`, { since: readOption(options.since) })));
3627
3767
  }))
3628
3768
  .addCommand(new Command("account")
3629
- .description("Per-account metric series for the connected publishing accounts.")
3769
+ .description("Stored per-account metric series for connected publishing accounts. Read-only: no provider call, 0 credits.")
3630
3770
  .option("--provider <provider>", "Provider to read. Defaults to linkedin.")
3631
3771
  .option("--since <date>", "ISO date to start the series from.")
3632
3772
  .option("--json", "Print a JSON envelope.")
@@ -4845,6 +4985,82 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4845
4985
  mode: options.live ? "live" : "dry_run",
4846
4986
  },
4847
4987
  }));
4988
+ }))
4989
+ .addCommand(new Command("relations")
4990
+ .description("Inspect and manage native Tables-owned relation definitions.")
4991
+ .addCommand(new Command("list")
4992
+ .description("List the native relations visible from one table, including cardinality, target, and active link count.")
4993
+ .argument("<table>", "Table id or slug.")
4994
+ .option("--json", "Print a JSON envelope.")
4995
+ .action(async (table, options) => {
4996
+ await handleAsyncAction("tables relations list", options, () => {
4997
+ const params = new URLSearchParams({ table });
4998
+ return requestOxygen(`/api/cli/tables/relations?${params.toString()}`);
4999
+ });
5000
+ }))
5001
+ .addCommand(new Command("update")
5002
+ .description("Rename either side of a relation or change its cardinality. Endpoints and slugs stay immutable. Previews by default.")
5003
+ .argument("<table>", "Table id or slug.")
5004
+ .argument("<relation>", "Relation slug as seen from this table.")
5005
+ .option("--display-name <name>", "New display name on this table.")
5006
+ .option("--inverse-display-name <name>", "New display name on the other table.")
5007
+ .option("--cardinality <cardinality>", "one_to_one, one_to_many, many_to_one, or many_to_many from this table's perspective.")
5008
+ .option("--approved", "Apply after inspecting the preview.")
5009
+ .option("--dry-run", "Preview without writing. This is the default.")
5010
+ .option("--json", "Print a JSON envelope.")
5011
+ .action(async (table, relation, options) => {
5012
+ await handleAsyncAction("tables relations update", options, () => requestOxygen("/api/cli/tables/relations", {
5013
+ method: "PATCH",
5014
+ body: {
5015
+ table,
5016
+ relation,
5017
+ ...(readOption(options.displayName) ? { display_name: readOption(options.displayName) } : {}),
5018
+ ...(readOption(options.inverseDisplayName)
5019
+ ? { inverse_display_name: readOption(options.inverseDisplayName) }
5020
+ : {}),
5021
+ ...(readOption(options.cardinality) ? { cardinality: readOption(options.cardinality) } : {}),
5022
+ mode: options.approved && !options.dryRun ? "live" : "dry_run",
5023
+ },
5024
+ }));
5025
+ }))
5026
+ .addCommand(new Command("archive")
5027
+ .description("Archive a native relation, its active links, and both relation columns while preserving history. Previews by default.")
5028
+ .argument("<table>", "Table id or slug.")
5029
+ .argument("<relation>", "Relation slug as seen from this table.")
5030
+ .option("--approved", "Apply after inspecting the preview.")
5031
+ .option("--dry-run", "Preview without writing. This is the default.")
5032
+ .option("--json", "Print a JSON envelope.")
5033
+ .action(async (table, relation, options) => {
5034
+ await handleAsyncAction("tables relations archive", options, () => requestOxygen("/api/cli/tables/relations", {
5035
+ method: "DELETE",
5036
+ body: {
5037
+ table,
5038
+ relation,
5039
+ mode: options.approved && !options.dryRun ? "live" : "dry_run",
5040
+ },
5041
+ }));
5042
+ })))
5043
+ .addCommand(new Command("unlink")
5044
+ .description("Unlink one exact pair of rows by archiving their relation edge. The relation definition and both rows remain. Previews by default.")
5045
+ .argument("<table>", "Source table id or slug.")
5046
+ .argument("<row_id>", "Source row id.")
5047
+ .requiredOption("--relation <slug>", "Relation slug as seen from the source table.")
5048
+ .requiredOption("--target-row-id <row_id>", "Linked row id to remove.")
5049
+ .option("--approved", "Apply after inspecting the preview.")
5050
+ .option("--live", "Alias for --approved.")
5051
+ .option("--dry-run", "Preview without writing. This is the default.")
5052
+ .option("--json", "Print a JSON envelope.")
5053
+ .action(async (table, rowId, options) => {
5054
+ await handleAsyncAction("tables unlink", options, () => requestOxygen("/api/cli/tables/relations/unlink", {
5055
+ method: "POST",
5056
+ body: {
5057
+ table,
5058
+ row_id: rowId,
5059
+ relation: options.relation,
5060
+ target_row_id: options.targetRowId,
5061
+ mode: (options.approved || options.live) && !options.dryRun ? "live" : "dry_run",
5062
+ },
5063
+ }));
4848
5064
  }))
4849
5065
  .addCommand(new Command("link")
4850
5066
  .description("Link two tables: Oxygen finds the column that joins them, works out the direction, shows you how many rows will match, and on approval links every row. Free. Previews by default; --approved queues a background run and records each row's outcome in a system link_status_<relation> column. Wait for that run with `oxygen table-runs wait <run_id>`. (Also links one row to one row — see --relation.)")
@@ -9837,9 +10053,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9837
10053
  });
9838
10054
  })));
9839
10055
  program.addCommand(new Command("posts")
9840
- .description("Read and publish LinkedIn posts through a connected account. `get` returns a post and the composite social_id that `comments`/`reactions` and `oxygen engagement harvest` need; `create` publishes a real post (approval-gated). Every command here addresses ONE post you already have an id for — Oxygen cannot enumerate an account's own LinkedIn history, so analytics only cover posts Oxygen itself published (`oxygen publishing ...`). All calls are metered against the sender account's daily quota.")
10056
+ .description("Read and publish LinkedIn posts through a connected account. `get`, `comments`, and `reactions` are LIVE provider reads: they use 0 Oxygen credits but consume the sender's daily account-read allowance. Do not use them when a task forbids provider calls; use `publishing comments list` for the local cross-post queue. `create` publishes a real post (approval-gated). Every command here addresses ONE post you already have an id for — Oxygen cannot enumerate an account's own LinkedIn history, so analytics only cover posts Oxygen itself published (`oxygen publishing ...`).")
9841
10057
  .addCommand(new Command("get")
9842
- .description("Read a LinkedIn post. Returns the post and its composite social_id (reuse that social_id for `posts comments`, `posts reactions`, and `engagement harvest --source unipile` — NOT the raw activity URN). Metered as an account read.")
10058
+ .description("LIVE provider read of one LinkedIn post. Uses 0 Oxygen credits but consumes the sender's metered account-read allowance; do not run it when the task forbids provider calls. Returns the post and its composite social_id (reuse that social_id for `posts comments`, `posts reactions`, and `engagement harvest --source unipile` — NOT the raw activity URN). This does not populate or refresh the durable `publishing comments` queue.")
9843
10059
  .requiredOption("--post <id>", "Numeric activity id, activity URL, or composite social_id. There is no way to LIST your own posts: take the id from the post's LinkedIn URL, or from a post you scheduled (`oxygen publishing posts get <post_id>` → provider_post_id).")
9844
10060
  .option("--account <ref>", "Sender account to read through (sender id, connection id, or Unipile account id). Omit for the org default.")
9845
10061
  .option("--json", "Print a JSON envelope.")
@@ -9854,7 +10070,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9854
10070
  });
9855
10071
  }))
9856
10072
  .addCommand(new Command("comments")
9857
- .description("List a post's comments (or the replies to a comment with --comment-id). --post MUST be the composite social_id from `posts get`, not the activity URN. Metered as an account read.")
10073
+ .description("Read one post's comments live from LinkedIn (or one comment's replies with --comment-id). This consumes the sender's metered account-read allowance, uses 0 Oxygen credits, and does not populate the durable queue. For the zero-provider-call cross-post work queue, use `publishing comments list`. --post MUST be the composite social_id from `posts get`, not the activity URN.")
9858
10074
  .requiredOption("--post <social_id>", "Composite post social_id from `posts get`.")
9859
10075
  .option("--comment-id <id>", "List replies to this comment instead of top-level comments.")
9860
10076
  .option("--sort-by <sort>", "Comment sort order (provider-defined, e.g. recent|relevant).")
@@ -10495,7 +10711,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10495
10711
  });
10496
10712
  })));
10497
10713
  program.addCommand(new Command("inbox")
10498
- .description("Unified inbox (unibox): email + LinkedIn + WhatsApp conversations in one stream (--channel all), or a single channel. Scan, read threads, and reply.")
10714
+ .description("Unified inbox (unibox): private email + LinkedIn DM + WhatsApp conversations in one stream (--channel all), or a single channel. Public comments on OXYGEN-published posts are not inbox messages; use `publishing comments`. Scan, read threads, and reply.")
10499
10715
  .addCommand(new Command("list")
10500
10716
  .description("List conversations newest first. --channel all merges email + LinkedIn + WhatsApp into one stream (narrow it with --channels); --channel email/linkedin/whatsapp lists a single channel. Primary excludes the negative tier (not_now, not_interested, lost, bounced); All includes every ordinary conversation.")
10501
10717
  .option("--channel <channel>", "Inbox channel: all (merged), linkedin (default), whatsapp, or email.")
@@ -12778,7 +12994,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12778
12994
  .addCommand(new Command("warmup")
12779
12995
  .description("Mailbox warmup for the sending pool. Oxygen's native warmup runs on ONE rail: TrulyInbox, managed and credit-billed per warming-inbox-month. Oxygen enrolls the mailbox for you — preview first, then re-run with --approved to execute and bill. (Instantly and Warmforge remain BYOK integrations elsewhere in Oxygen; they are no longer warmup rails.)")
12780
12996
  .addCommand(new Command("enable")
12781
- .description("Enable warmup for sending mailboxes and stamp each mailbox's warmup state. Targets the whole pool unless --mailboxes is given. Without --approved this is a 0-credit, no-provider-write priced preview: nothing is enrolled or billed. Execute by echoing its --plan and --max-credits with --approved.")
12997
+ .description("Enable warmup for sending mailboxes and stamp each mailbox's warmup state. Targets the whole pool unless --mailboxes is given. Without --approved this is a 0-credit, no-provider-write priced preview: nothing is enrolled or billed. Preview one inbox with `oxygen mailboxes warmup enable --mailboxes sender@example.com --json`. Execute by echoing its --plan and --max-credits with --approved.")
12782
12998
  .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses to warm. Omit to warm the whole pool.")
12783
12999
  .option("--approved", "Execute the PRICED enrollment. Without it the response is a priced preview and nothing is enrolled or billed.")
12784
13000
  .option("--plan <hash>", "Hash from the fresh preview (required with --approved).")
@@ -184,11 +184,11 @@ export const OXYGEN_CAPABILITY_ROUTES = [
184
184
  notFor: "One-to-one conversations, delivery scheduling, or generic public LinkedIn provider search.",
185
185
  execution: "Read or create the hosted Post artifact; Publishing owns scheduled delivery.",
186
186
  posture: "workspace_write",
187
- gatewayTools: ["oxygen_posts_get", "oxygen_posts_comments_list", "oxygen_posts_reactions_list", "oxygen_posts_create"],
188
- gatewayCommands: ["posts get", "posts comments", "posts reactions", "posts create"],
187
+ gatewayTools: ["oxygen_publishing_comments", "oxygen_publishing_analytics", "oxygen_posts_get", "oxygen_posts_create"],
188
+ gatewayCommands: ["publishing comments list", "publishing analytics summary", "posts get", "posts create"],
189
189
  skills: ["oxygen-linkedin-marketing"],
190
190
  endpointSections: [],
191
- intentTerms: ["post artifact", "social post", "post comments", "post reactions", "post performance", "broadcast content"],
191
+ intentTerms: ["post artifact", "social post", "post comments", "public comments", "comments needing reply", "post reactions", "post performance", "posting analytics", "broadcast content"],
192
192
  },
193
193
  {
194
194
  id: "signals",
@@ -473,6 +473,8 @@ function normalizeIntent(query) {
473
473
  function explicitCapabilityIntent(query) {
474
474
  if (isHostedWorkflowIntent(query))
475
475
  return ROUTE_BY_PRIMITIVE.get("workflows") ?? null;
476
+ if (isOwnedPostCommentIntent(query))
477
+ return ROUTE_BY_PRIMITIVE.get("posts") ?? null;
476
478
  // A deferred acquisition motion starts with the public-data owner. This keeps
477
479
  // "find/qualify now, contact later" from skipping straight to the final write
478
480
  // step; the recommendation still hands the eventual initiation to Sequences.
@@ -550,6 +552,17 @@ function highestScoringRoute(query) {
550
552
  return best?.card ?? null;
551
553
  }
552
554
  function recommendationsFor(card, query) {
555
+ if (card.primitive === "posts" && isOwnedPostCommentIntent(query)) {
556
+ return {
557
+ tools: ["oxygen_publishing_comments", "oxygen_publishing_analytics"],
558
+ commands: [
559
+ "publishing comments list",
560
+ "publishing comments get",
561
+ "publishing comments reply",
562
+ "publishing analytics summary",
563
+ ],
564
+ };
565
+ }
553
566
  if (card.id === "linkedin-public-research" && isPublicLinkedInEngagerHarvest(query)) {
554
567
  const motion = isLinkedInEngagerMotion(query);
555
568
  return {
@@ -730,6 +743,14 @@ function isNetNewLinkedInInitiation(query) {
730
743
  return false;
731
744
  return !/\b(sequence|campaign|cadence|enroll|nurture|multi[ -]step|multi[ -]recipient|leads|contacts|recipients|audience|rotate senders?|stop on reply|day\s*\d+)\b/.test(query);
732
745
  }
746
+ function isOwnedPostCommentIntent(query) {
747
+ if (!/\bcomments?\b/.test(query) || !/\b(linkedin|posts?|publishing)\b/.test(query))
748
+ return false;
749
+ const owned = /\b(my|our|own)\b|\boxygen[ -]published\b|\bpublished through oxygen\b/.test(query);
750
+ const operatorWork = /\b(needs?|needing|awaiting)\b.{0,20}\b(response|reply|answer)\b/.test(query)
751
+ || /\b(reply|respond|answer|triage|queue)\b/.test(query);
752
+ return owned || operatorWork;
753
+ }
733
754
  function isHostedWorkflowIntent(query) {
734
755
  const strongWorkflowLanguage = /\b(workflow|automation|automate|automatically|webhook|cron|event trigger|branching?|retr(?:y|ies)|orchestrat(?:e|ion)|scheduled job|deterministic graph)\b/.test(query);
735
756
  const cadenceLanguage = /\b(sequence|campaign|cadence|day\s*\d+|multi[ -]step|rotate senders?|sender rotation|stop on reply|until (?:they )?reply)\b/.test(query);
@@ -0,0 +1,15 @@
1
+ export declare const COPILOT_TURN_TIMEOUT_CODE = "copilot_turn_timeout";
2
+ export declare const COPILOT_TURN_TIMEOUT_MESSAGE = "This Copilot request timed out and stopped. Any completed actions are still saved\u2014review this session before trying again.";
3
+ export type CustomerFacingCopilotError = {
4
+ code: string | null;
5
+ message: string | null;
6
+ };
7
+ /**
8
+ * Replace an internal worker deadline with the stable Copilot timeout contract.
9
+ * The message-pattern fallback heals rows written before the worker error code
10
+ * was persisted consistently. Other errors pass through byte-for-byte.
11
+ */
12
+ export declare function customerFacingCopilotError(input: {
13
+ code?: string | null;
14
+ message?: string | null;
15
+ }): CustomerFacingCopilotError;
@@ -0,0 +1,22 @@
1
+ // Customer-facing Workspace Copilot error policy. Worker supervision details
2
+ // belong in operational telemetry, never in the conversation timeline or turn
3
+ // row every Control surface renders. Keep this mapping shared so new failures
4
+ // and already-persisted failures serialize identically across web, CLI, and MCP.
5
+ export const COPILOT_TURN_TIMEOUT_CODE = "copilot_turn_timeout";
6
+ export const COPILOT_TURN_TIMEOUT_MESSAGE = "This Copilot request timed out and stopped. Any completed actions are still saved—review this session before trying again.";
7
+ const WORKER_STEP_TIMEOUT_MESSAGE = /\bWorker step '[^']+' exceeded \d+ms deadline\.?/i;
8
+ /**
9
+ * Replace an internal worker deadline with the stable Copilot timeout contract.
10
+ * The message-pattern fallback heals rows written before the worker error code
11
+ * was persisted consistently. Other errors pass through byte-for-byte.
12
+ */
13
+ export function customerFacingCopilotError(input) {
14
+ const code = input.code ?? null;
15
+ const message = input.message ?? null;
16
+ const isTimeout = code === "worker_step_timeout" ||
17
+ code === COPILOT_TURN_TIMEOUT_CODE ||
18
+ (message !== null && WORKER_STEP_TIMEOUT_MESSAGE.test(message));
19
+ return isTimeout
20
+ ? { code: COPILOT_TURN_TIMEOUT_CODE, message: COPILOT_TURN_TIMEOUT_MESSAGE }
21
+ : { code, message };
22
+ }
@@ -14,6 +14,7 @@ export * from "./cli-result.js";
14
14
  export * from "./crm-reply-events.js";
15
15
  export * from "./crm-activity-events.js";
16
16
  export * from "./column-types.js";
17
+ export * from "./copilot-errors.js";
17
18
  export * from "./copilot-journeys.js";
18
19
  export * from "./credit-guidance.js";
19
20
  export * from "./directory.js";
@@ -47,6 +48,7 @@ export * from "./log.js";
47
48
  export { sanitizeLogFields } from "./redaction.js";
48
49
  export * from "./provider-request-outcomes.js";
49
50
  export * from "./schedule-label.js";
51
+ export * from "./social-capabilities.js";
50
52
  export * from "./signup-lead-deliveries.js";
51
53
  export * from "./sql-error.js";
52
54
  export * from "./tags.js";
@@ -56,6 +58,7 @@ export * from "./timing.js";
56
58
  export * from "./type-guards.js";
57
59
  export * from "./worker-failures-queue.js";
58
60
  export * from "./workflow-mcp-tools.js";
61
+ export * from "./workflow-trigger-label.js";
59
62
  export * from "./workspace-event-catalog.js";
60
63
  export * from "./workspace-agents.js";
61
64
  export declare const MAX_ROW_LOOP_WRITE_ROWS = 500;
@@ -14,6 +14,7 @@ export * from "./cli-result.js";
14
14
  export * from "./crm-reply-events.js";
15
15
  export * from "./crm-activity-events.js";
16
16
  export * from "./column-types.js";
17
+ export * from "./copilot-errors.js";
17
18
  export * from "./copilot-journeys.js";
18
19
  export * from "./credit-guidance.js";
19
20
  export * from "./directory.js";
@@ -51,6 +52,7 @@ export * from "./log.js";
51
52
  export { sanitizeLogFields } from "./redaction.js";
52
53
  export * from "./provider-request-outcomes.js";
53
54
  export * from "./schedule-label.js";
55
+ export * from "./social-capabilities.js";
54
56
  export * from "./signup-lead-deliveries.js";
55
57
  export * from "./sql-error.js";
56
58
  export * from "./tags.js";
@@ -60,6 +62,7 @@ export * from "./timing.js";
60
62
  export * from "./type-guards.js";
61
63
  export * from "./worker-failures-queue.js";
62
64
  export * from "./workflow-mcp-tools.js";
65
+ export * from "./workflow-trigger-label.js";
63
66
  export * from "./workspace-event-catalog.js";
64
67
  export * from "./workspace-agents.js";
65
68
  // Maximum rows a single row-loop write (insert/upsert/preview) may process. The
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Social channel capability registry.
3
+ *
4
+ * Channel, provider rail, and operation support are deliberately independent.
5
+ * LinkedIn is the only registered channel today, but callers resolve capabilities
6
+ * through this contract instead of branching on `channel === "linkedin"`. Adding a
7
+ * channel later means registering a real adapter profile, not adding placeholder UI.
8
+ */
9
+ export declare const SOCIAL_CHANNELS: readonly ["linkedin"];
10
+ export type SocialChannel = (typeof SOCIAL_CHANNELS)[number];
11
+ export declare const SOCIAL_PROVIDER_RAILS: readonly ["unipile"];
12
+ export type SocialProviderRail = (typeof SOCIAL_PROVIDER_RAILS)[number];
13
+ export declare const SOCIAL_OPERATIONS: readonly ["list_owned_posts", "read_post_metrics", "list_comments", "reply_to_comment"];
14
+ export type SocialOperation = (typeof SOCIAL_OPERATIONS)[number];
15
+ export declare const SOCIAL_POST_METRICS: readonly ["impressions", "reactions", "comments", "reposts", "saves", "sends", "clicks", "video_views"];
16
+ export type SocialPostMetric = (typeof SOCIAL_POST_METRICS)[number];
17
+ export type SocialCapabilityStatus = "available" | "limited" | "unavailable";
18
+ export type SocialOperationCapability = {
19
+ status: SocialCapabilityStatus;
20
+ /** Local durable state or a provider operation. */
21
+ source: "oxygen_store" | "provider";
22
+ provider_operation: string | null;
23
+ external_write: boolean;
24
+ limitation: string | null;
25
+ };
26
+ export type SocialMetricCapability = {
27
+ status: "available" | "unavailable";
28
+ provider_field: string | null;
29
+ reason: string | null;
30
+ };
31
+ export type SocialCapabilityProfile = {
32
+ version: 1;
33
+ channel: SocialChannel;
34
+ provider_rail: SocialProviderRail;
35
+ operations: Record<SocialOperation, SocialOperationCapability>;
36
+ metrics: Record<SocialPostMetric, SocialMetricCapability>;
37
+ ingestion: {
38
+ polling: boolean;
39
+ webhooks: boolean;
40
+ };
41
+ };
42
+ export declare function listSocialCapabilityProfiles(): readonly SocialCapabilityProfile[];
43
+ export declare function getSocialCapabilityProfile(channel: string, providerRail: string): SocialCapabilityProfile | null;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Social channel capability registry.
3
+ *
4
+ * Channel, provider rail, and operation support are deliberately independent.
5
+ * LinkedIn is the only registered channel today, but callers resolve capabilities
6
+ * through this contract instead of branching on `channel === "linkedin"`. Adding a
7
+ * channel later means registering a real adapter profile, not adding placeholder UI.
8
+ */
9
+ export const SOCIAL_CHANNELS = ["linkedin"];
10
+ export const SOCIAL_PROVIDER_RAILS = ["unipile"];
11
+ export const SOCIAL_OPERATIONS = [
12
+ "list_owned_posts",
13
+ "read_post_metrics",
14
+ "list_comments",
15
+ "reply_to_comment",
16
+ ];
17
+ export const SOCIAL_POST_METRICS = [
18
+ "impressions",
19
+ "reactions",
20
+ "comments",
21
+ "reposts",
22
+ "saves",
23
+ "sends",
24
+ "clicks",
25
+ "video_views",
26
+ ];
27
+ const NOT_EXPOSED_BY_UNIPILE = "not_exposed_by_current_provider_rail";
28
+ const LINKEDIN_UNIPILE_CAPABILITIES = {
29
+ version: 1,
30
+ channel: "linkedin",
31
+ provider_rail: "unipile",
32
+ operations: {
33
+ list_owned_posts: {
34
+ status: "limited",
35
+ source: "oxygen_store",
36
+ provider_operation: null,
37
+ external_write: false,
38
+ limitation: "oxygen_published_posts_only",
39
+ },
40
+ read_post_metrics: {
41
+ status: "limited",
42
+ source: "provider",
43
+ provider_operation: "linkedin.posts_get",
44
+ external_write: false,
45
+ limitation: "coarse_public_counters_only",
46
+ },
47
+ list_comments: {
48
+ status: "available",
49
+ source: "provider",
50
+ provider_operation: "linkedin.posts_comments_list",
51
+ external_write: false,
52
+ limitation: null,
53
+ },
54
+ reply_to_comment: {
55
+ status: "available",
56
+ source: "provider",
57
+ provider_operation: "linkedin.posts_comment_create",
58
+ external_write: true,
59
+ limitation: null,
60
+ },
61
+ },
62
+ metrics: {
63
+ impressions: { status: "unavailable", provider_field: null, reason: NOT_EXPOSED_BY_UNIPILE },
64
+ reactions: { status: "available", provider_field: "reaction_count", reason: null },
65
+ comments: { status: "available", provider_field: "comment_count", reason: null },
66
+ reposts: { status: "available", provider_field: "repost_count", reason: null },
67
+ saves: { status: "unavailable", provider_field: null, reason: NOT_EXPOSED_BY_UNIPILE },
68
+ sends: { status: "unavailable", provider_field: null, reason: NOT_EXPOSED_BY_UNIPILE },
69
+ clicks: { status: "unavailable", provider_field: null, reason: NOT_EXPOSED_BY_UNIPILE },
70
+ video_views: { status: "unavailable", provider_field: null, reason: NOT_EXPOSED_BY_UNIPILE },
71
+ },
72
+ ingestion: {
73
+ polling: true,
74
+ webhooks: false,
75
+ },
76
+ };
77
+ const SOCIAL_CAPABILITY_PROFILES = [
78
+ LINKEDIN_UNIPILE_CAPABILITIES,
79
+ ];
80
+ export function listSocialCapabilityProfiles() {
81
+ return SOCIAL_CAPABILITY_PROFILES;
82
+ }
83
+ export function getSocialCapabilityProfile(channel, providerRail) {
84
+ return SOCIAL_CAPABILITY_PROFILES.find((profile) => profile.channel === channel && profile.provider_rail === providerRail) ?? null;
85
+ }
@@ -1,3 +1,3 @@
1
- export declare const OXYGEN_VERSION = "1.627.4";
1
+ export declare const OXYGEN_VERSION = "1.632.1";
2
2
  export declare const OXYGEN_MINIMUM_CLI_VERSION = "1.181.0";
3
3
  export declare const MANAGED_INBOX_MINIMUM_CLI_VERSION = "1.326.2";
@@ -1,4 +1,4 @@
1
- export const OXYGEN_VERSION = "1.627.4";
1
+ export const OXYGEN_VERSION = "1.632.1";
2
2
  // The GLOBAL CLI compatibility floor: the oldest CLI allowed to call any
3
3
  // operational route. Raising it hard-rejects every older CLI from the entire
4
4
  // product, so it obeys one law, enforced by scripts/ci/cli-min-version-gate.mjs:
@@ -0,0 +1,63 @@
1
+ import { type WorkspaceEventCatalogEntry } from "./workspace-event-catalog.js";
2
+ /**
3
+ * A workflow event trigger's filter, loosely typed so callers can pass either
4
+ * the canonical `WorkflowEventFilter` (packages/workflows) or a trigger row's
5
+ * persisted `metadata.filters` JSON without this module depending on either
6
+ * shape's owning package.
7
+ */
8
+ export type WorkflowTriggerFilterLike = {
9
+ path: string;
10
+ op: string;
11
+ value?: unknown;
12
+ };
13
+ /**
14
+ * Title-cased trigger-type label ("API", "Webhook", "Cron", "Event") so a
15
+ * trigger reads the same word on every surface that summarizes it. Falls back
16
+ * to a generic capitalization for a trigger type this list doesn't know about
17
+ * yet, never to the raw lowercase identifier — the bug this module fixes was
18
+ * exactly that fallback leaking "event" next to three title-cased siblings.
19
+ */
20
+ export declare function workflowTriggerTypeLabel(type: string | null | undefined): string;
21
+ /**
22
+ * Order-independent identity for a filter set.
23
+ *
24
+ * MIRRORED by `normalizedFilterSignature` in the visual editor's picker
25
+ * (apps/web/src/lib/workflow-builder/workspace-event-option.ts), which cannot
26
+ * import this one: that module reaches a client component, and client
27
+ * components must not pull the @oxygen/shared barrel into the page bundle.
28
+ * Both answer the same question — "are these the same preset?" — so a drift
29
+ * would let the editor treat a preset as selected while the list labels it as
30
+ * something else. workspace-event-option.test.ts pins them to equal output.
31
+ */
32
+ export declare function workflowTriggerFilterSignature(filters: readonly WorkflowTriggerFilterLike[] | null | undefined): string;
33
+ /**
34
+ * Match an event trigger's (source, event, filters) against the workspace
35
+ * event catalog. Filters are part of the identity on purpose: ~28 catalog
36
+ * presets share one (source, event) pair (e.g. `sequencer.contact_activity`)
37
+ * and differ ONLY by filters — "fires on a bounce" and "fires on a reply" must
38
+ * resolve to two different entries, never the same one.
39
+ */
40
+ export declare function matchWorkspaceEventCatalogEntry(input: {
41
+ source: string;
42
+ event: string;
43
+ filters?: readonly WorkflowTriggerFilterLike[] | null | undefined;
44
+ }): WorkspaceEventCatalogEntry | null;
45
+ export type WorkflowEventTriggerLabel = {
46
+ /** The catalog's human label, or the raw "source · event" identifier when unmatched. Never blank. */
47
+ label: string;
48
+ /** True when a catalog entry matched — callers use this to style a human label differently from a raw identifier. */
49
+ matched: boolean;
50
+ /** The matched catalog entry's group (e.g. "Sequencer"), or null when unmatched. */
51
+ group: string | null;
52
+ };
53
+ /**
54
+ * Human label for an event trigger, resolved against the workspace event
55
+ * catalog. Falls back to the raw "source · event" identifier — never to a
56
+ * blank string — when no catalog entry matches: a generic integration
57
+ * subscription event, or a preset that predates the catalog.
58
+ */
59
+ export declare function workflowEventTriggerLabel(input: {
60
+ source: string | null | undefined;
61
+ event: string | null | undefined;
62
+ filters?: readonly WorkflowTriggerFilterLike[] | null | undefined;
63
+ }): WorkflowEventTriggerLabel;
@@ -0,0 +1,85 @@
1
+ // Human-facing trigger labels shared by every surface that lists workflows
2
+ // OUTSIDE the visual editor: the web workflows table
3
+ // (apps/web/src/components/workflows/workflows-table.tsx) and the MCP
4
+ // `oxygen.workflow-overview` widget (packages/mcp-server/src/widgets/workflow-overview.ts).
5
+ // Both render a trigger as a one-line summary and need the same two answers:
6
+ // what does this TYPE of trigger call itself ("Webhook", not "webhook"), and —
7
+ // for an event trigger — what does the workspace event catalog call this exact
8
+ // (source, event, filters) combination.
9
+ //
10
+ // Dependency-free on purpose, like schedule-label.ts: consumed by web, CLI, and
11
+ // the MCP widgets (packages/mcp-server depends only on @oxygen/shared).
12
+ import { WORKSPACE_EVENT_CATALOG } from "./workspace-event-catalog.js";
13
+ const WORKFLOW_TRIGGER_TYPE_LABELS = {
14
+ api: "API",
15
+ webhook: "Webhook",
16
+ cron: "Cron",
17
+ event: "Event",
18
+ };
19
+ /**
20
+ * Title-cased trigger-type label ("API", "Webhook", "Cron", "Event") so a
21
+ * trigger reads the same word on every surface that summarizes it. Falls back
22
+ * to a generic capitalization for a trigger type this list doesn't know about
23
+ * yet, never to the raw lowercase identifier — the bug this module fixes was
24
+ * exactly that fallback leaking "event" next to three title-cased siblings.
25
+ */
26
+ export function workflowTriggerTypeLabel(type) {
27
+ if (!type)
28
+ return "—";
29
+ return WORKFLOW_TRIGGER_TYPE_LABELS[type] ?? `${type.charAt(0).toUpperCase()}${type.slice(1)}`;
30
+ }
31
+ /**
32
+ * Order-independent identity for a filter set.
33
+ *
34
+ * MIRRORED by `normalizedFilterSignature` in the visual editor's picker
35
+ * (apps/web/src/lib/workflow-builder/workspace-event-option.ts), which cannot
36
+ * import this one: that module reaches a client component, and client
37
+ * components must not pull the @oxygen/shared barrel into the page bundle.
38
+ * Both answer the same question — "are these the same preset?" — so a drift
39
+ * would let the editor treat a preset as selected while the list labels it as
40
+ * something else. workspace-event-option.test.ts pins them to equal output.
41
+ */
42
+ export function workflowTriggerFilterSignature(filters) {
43
+ return (filters ?? [])
44
+ .map((filter) => JSON.stringify({
45
+ path: filter.path,
46
+ op: filter.op,
47
+ ...(filter.value !== undefined ? { value: filter.value } : {}),
48
+ }))
49
+ .sort()
50
+ .join("|");
51
+ }
52
+ /**
53
+ * Match an event trigger's (source, event, filters) against the workspace
54
+ * event catalog. Filters are part of the identity on purpose: ~28 catalog
55
+ * presets share one (source, event) pair (e.g. `sequencer.contact_activity`)
56
+ * and differ ONLY by filters — "fires on a bounce" and "fires on a reply" must
57
+ * resolve to two different entries, never the same one.
58
+ */
59
+ export function matchWorkspaceEventCatalogEntry(input) {
60
+ const signature = workflowTriggerFilterSignature(input.filters);
61
+ return WORKSPACE_EVENT_CATALOG.find((entry) => entry.source === input.source
62
+ && entry.event === input.event
63
+ && workflowTriggerFilterSignature(entry.filters) === signature) ?? null;
64
+ }
65
+ /**
66
+ * Human label for an event trigger, resolved against the workspace event
67
+ * catalog. Falls back to the raw "source · event" identifier — never to a
68
+ * blank string — when no catalog entry matches: a generic integration
69
+ * subscription event, or a preset that predates the catalog.
70
+ */
71
+ export function workflowEventTriggerLabel(input) {
72
+ const source = input.source?.trim() || null;
73
+ const event = input.event?.trim() || null;
74
+ const entry = source && event
75
+ ? matchWorkspaceEventCatalogEntry({ source, event, filters: input.filters })
76
+ : null;
77
+ const rawIdentifier = source && event
78
+ ? `${source} · ${event}`
79
+ : source ?? event ?? "event";
80
+ return {
81
+ label: entry?.label ?? rawIdentifier,
82
+ matched: entry !== null,
83
+ group: entry?.group ?? null,
84
+ };
85
+ }
@@ -67,7 +67,11 @@ export function lintWorkflowGraphManifest(value, options = {}) {
67
67
  if (!isNonEmptyString(value.source_hash)) {
68
68
  add("$.source_hash", "missing_source_hash", "Manifest source_hash is required.");
69
69
  }
70
- const nodesSound = lintNodes(value.nodes, add, options);
70
+ // Built once and threaded through every value-ref check below: the second
71
+ // segment of a `steps.` / `loop.` path names a NODE, and that id is the one
72
+ // part of a hand-typed path this linter can always check.
73
+ const scope = nodeScopeIndex(value.nodes);
74
+ const nodesSound = lintNodes(value.nodes, add, scope, options);
71
75
  const edgesSound = lintEdges(value.edges, value.nodes, add);
72
76
  // Graph-level analysis dereferences every node and edge, so it only runs once
73
77
  // the element shapes are known good. Reporting "unreachable" on a manifest
@@ -104,12 +108,21 @@ function lintWorkflowMetadata(workflow, add) {
104
108
  add("$.workflow.status", "invalid_workflow_status", "Workflow status must be active or disabled.");
105
109
  }
106
110
  }
107
- // ---------------------------------------------------------------------------
108
- // Nodes
109
- // ---------------------------------------------------------------------------
111
+ function nodeScopeIndex(value) {
112
+ const ids = new Set();
113
+ const loops = new Set();
114
+ for (const node of asArray(value) ?? []) {
115
+ if (!isRecord(node) || !isNonEmptyString(node.id))
116
+ continue;
117
+ ids.add(node.id);
118
+ if (node.kind === "loop")
119
+ loops.add(node.id);
120
+ }
121
+ return { ids, loops };
122
+ }
110
123
  /** Returns true when every node is shaped well enough for topology analysis. */
111
124
  function lintNodes(// skipcq: JS-R1005
112
- value, add, options) {
125
+ value, add, scope, options) {
113
126
  const nodes = asArray(value);
114
127
  if (!nodes || nodes.length === 0) {
115
128
  add("$.nodes", "missing_nodes", "At least one workflow node is required.");
@@ -133,7 +146,7 @@ value, add, options) {
133
146
  add(`${path}.kind`, "invalid_node_kind", `Node kind must be one of ${[...NODE_KINDS].join(", ")}.`);
134
147
  return;
135
148
  }
136
- lintNodeByKind(node, path, add, options);
149
+ lintNodeByKind(node, path, add, scope, options);
137
150
  });
138
151
  if (triggerCount === 0) {
139
152
  add("$.nodes", "missing_trigger_node", "A workflow graph requires exactly one trigger node.");
@@ -210,21 +223,21 @@ function lintNodeRetry(node, path, add) {
210
223
  }
211
224
  }
212
225
  function lintNodeByKind(// skipcq: JS-R1005
213
- node, path, add, options) {
226
+ node, path, add, scope, options) {
214
227
  switch (node.kind) {
215
228
  case "trigger":
216
229
  return;
217
230
  case "tool":
218
- lintToolNode(node, path, add, options);
231
+ lintToolNode(node, path, add, scope, options);
219
232
  return;
220
233
  case "filter":
221
- validateCondition(node.when, `${path}.when`, add);
234
+ validateCondition(node.when, `${path}.when`, add, scope);
222
235
  return;
223
236
  case "switch":
224
- lintSwitchNode(node, path, add);
237
+ lintSwitchNode(node, path, add, scope);
225
238
  return;
226
239
  case "loop":
227
- lintLoopNode(node, path, add);
240
+ lintLoopNode(node, path, add, scope);
228
241
  return;
229
242
  case "merge":
230
243
  if (typeof node.strategy !== "string" || !MERGE_STRATEGIES.has(node.strategy)) {
@@ -232,13 +245,13 @@ node, path, add, options) {
232
245
  }
233
246
  return;
234
247
  case "set":
235
- lintSetNode(node, path, add);
248
+ lintSetNode(node, path, add, scope);
236
249
  return;
237
250
  case "wait":
238
- lintWaitNode(node, path, add);
251
+ lintWaitNode(node, path, add, scope);
239
252
  return;
240
253
  case "approval":
241
- lintApprovalNode(node, path, add);
254
+ lintApprovalNode(node, path, add, scope);
242
255
  return;
243
256
  case "code":
244
257
  // v2 `code` nodes run through the same sandbox as v1 transform steps, so
@@ -254,7 +267,7 @@ node, path, add, options) {
254
267
  if (!isNonEmptyString(node.workflow_id)) {
255
268
  add(`${path}.workflow_id`, "invalid_workflow_ref", "Workflow node workflow_id is required.");
256
269
  }
257
- validateValueRefRecord(node.input, `${path}.input`, add);
270
+ validateValueRefRecord(node.input, `${path}.input`, add, scope);
258
271
  // Authoring-time refusal, not just a runtime one. A sub-workflow needs its
259
272
  // own durable run, lease, spend ceiling and cancellation semantics, none of
260
273
  // which exist yet — so the worker rejects the node terminally. Without this
@@ -267,7 +280,7 @@ node, path, add, options) {
267
280
  return;
268
281
  }
269
282
  }
270
- function lintToolNode(node, path, add, options) {
283
+ function lintToolNode(node, path, add, scope, options) {
271
284
  if (!isNonEmptyString(node.tool)) {
272
285
  add(`${path}.tool`, "missing_tool", "Tool node tool id is required.");
273
286
  }
@@ -339,9 +352,9 @@ function lintToolNode(node, path, add, options) {
339
352
  add(`${path}.connection_id`, "invalid_connection_id", "Tool node connection_id must be a non-empty string.");
340
353
  }
341
354
  validateOptionalMaxCredits(node.max_credits, `${path}.max_credits`, add);
342
- validateValueRefRecord(node.params, `${path}.params`, add);
355
+ validateValueRefRecord(node.params, `${path}.params`, add, scope);
343
356
  }
344
- function lintSwitchNode(node, path, add) {
357
+ function lintSwitchNode(node, path, add, scope) {
345
358
  const cases = asArray(node.cases);
346
359
  if (!cases || cases.length === 0) {
347
360
  add(`${path}.cases`, "invalid_switch_case", "Switch node requires at least one case.");
@@ -374,11 +387,11 @@ function lintSwitchNode(node, path, add) {
374
387
  if (!isNonEmptyString(entry.label)) {
375
388
  add(`${casePath}.label`, "invalid_switch_case", "Switch case label is required.");
376
389
  }
377
- validateCondition(entry.when, `${casePath}.when`, add);
390
+ validateCondition(entry.when, `${casePath}.when`, add, scope);
378
391
  });
379
392
  }
380
- function lintLoopNode(node, path, add) {
381
- validateValueRef(node.over, `${path}.over`, add);
393
+ function lintLoopNode(node, path, add, scope) {
394
+ validateValueRef(node.over, `${path}.over`, add, scope);
382
395
  if (node.item_name !== undefined && !isNonEmptyString(node.item_name)) {
383
396
  add(`${path}.item_name`, "invalid_value_ref", "Loop item_name must be a non-empty string.");
384
397
  }
@@ -398,7 +411,7 @@ function lintLoopNode(node, path, add) {
398
411
  add(`${path}.concurrency`, "invalid_loop_target", "Loop concurrency must be a positive integer.");
399
412
  }
400
413
  }
401
- function lintSetNode(node, path, add) {
414
+ function lintSetNode(node, path, add, scope) {
402
415
  const fields = asArray(node.fields);
403
416
  if (!fields || fields.length === 0) {
404
417
  add(`${path}.fields`, "invalid_set_field", "Set node requires at least one field.");
@@ -416,16 +429,22 @@ function lintSetNode(node, path, add) {
416
429
  else if (RESERVED_NODE_IDS.has(field.key)) {
417
430
  add(`${fieldPath}.key`, "invalid_set_field", `Set field key '${field.key}' is reserved and would be dropped from the node output.`);
418
431
  }
419
- validateValueRef(field.value, `${fieldPath}.value`, add);
432
+ validateValueRef(field.value, `${fieldPath}.value`, add, scope);
420
433
  });
421
434
  }
422
- function lintApprovalNode(node, path, add) {
435
+ function lintApprovalNode(node, path, add, scope) {
436
+ // A blank `reason` is deliberately NOT refused here. It leaves the approver
437
+ // guessing, but it is legal and every gate saved to date is missing one — so
438
+ // the nudge lives in the editor's warning layer (graphWarnings in
439
+ // apps/web/src/lib/workflows/graph-ops.ts), where it can be said without
440
+ // making an existing workflow unsaveable.
423
441
  if (node.reason !== undefined)
424
- validateValueRef(node.reason, `${path}.reason`, add);
425
- if (node.request !== undefined)
426
- validateValueRefRecord(node.request, `${path}.request`, add);
442
+ validateValueRef(node.reason, `${path}.reason`, add, scope);
443
+ if (node.request !== undefined) {
444
+ validateValueRefRecord(node.request, `${path}.request`, add, scope);
445
+ }
427
446
  }
428
- function lintWaitNode(node, path, add) {
447
+ function lintWaitNode(node, path, add, scope) {
429
448
  const hasDuration = node.duration_seconds !== undefined;
430
449
  const hasUntil = node.until !== undefined;
431
450
  if (hasDuration === hasUntil) {
@@ -440,7 +459,7 @@ function lintWaitNode(node, path, add) {
440
459
  }
441
460
  return;
442
461
  }
443
- validateValueRef(node.until, `${path}.until`, add);
462
+ validateValueRef(node.until, `${path}.until`, add, scope);
444
463
  }
445
464
  function isPositiveInteger(value) {
446
465
  return typeof value === "number" && Number.isInteger(value) && value > 0;
@@ -593,7 +612,7 @@ function lintGraphTopology(graph, add) {
593
612
  // ---------------------------------------------------------------------------
594
613
  // Value refs and conditions
595
614
  // ---------------------------------------------------------------------------
596
- function validateValueRefRecord(value, path, add) {
615
+ function validateValueRefRecord(value, path, add, scope) {
597
616
  if (value === undefined) {
598
617
  add(path, "invalid_value_ref", "Node bindings are required.");
599
618
  return;
@@ -603,10 +622,11 @@ function validateValueRefRecord(value, path, add) {
603
622
  return;
604
623
  }
605
624
  for (const [key, entry] of Object.entries(value)) {
606
- validateValueRef(entry, `${path}.${key}`, add);
625
+ validateValueRef(entry, `${path}.${key}`, add, scope);
607
626
  }
608
627
  }
609
- function validateValueRef(value, path, add) {
628
+ function validateValueRef(// skipcq: JS-R1005
629
+ value, path, add, scope) {
610
630
  if (!isRecord(value)) {
611
631
  add(path, "invalid_value_ref", "Value ref must be an object.");
612
632
  return;
@@ -626,21 +646,21 @@ function validateValueRef(value, path, add) {
626
646
  // Only OUR tokens are checked: `{{column}}`, `{{RANDOM|a|b}}` and other
627
647
  // foreign templating share the delimiters, travel inside these same
628
648
  // parameters, and are none of the linter's business.
629
- validateStructuredLiteralPaths(value.value, `${path}.value`, add);
649
+ validateStructuredLiteralPaths(value.value, `${path}.value`, add, scope);
630
650
  return;
631
651
  case "template":
632
652
  if (typeof value.value !== "string") {
633
653
  add(`${path}.value`, "invalid_value_ref", "Template value ref requires a string.");
634
654
  return;
635
655
  }
636
- validateTemplatePaths(value.value, `${path}.value`, add);
656
+ validateTemplatePaths(value.value, `${path}.value`, add, scope);
637
657
  return;
638
658
  case "ref":
639
659
  if (!isNonEmptyString(value.path)) {
640
660
  add(`${path}.path`, "invalid_value_ref", "Ref value ref requires a path.");
641
661
  return;
642
662
  }
643
- validateScopePath(value.path, `${path}.path`, add);
663
+ validateScopePath(value.path, `${path}.path`, add, scope);
644
664
  return;
645
665
  case "formula":
646
666
  if (!isNonEmptyString(value.expression)) {
@@ -673,7 +693,7 @@ function validateValueRef(value, path, add) {
673
693
  add(`${path}.type`, "invalid_value_ref", "Value ref type is invalid.");
674
694
  }
675
695
  }
676
- function validateScopePath(path, issuePath, add) {
696
+ function validateScopePath(path, issuePath, add, scope) {
677
697
  const segments = parseScopePath(path);
678
698
  if (!segments) {
679
699
  add(issuePath, "invalid_value_ref", `Path '${path}' is not a valid scope path (it is malformed or reaches a reserved key).`);
@@ -682,6 +702,49 @@ function validateScopePath(path, issuePath, add) {
682
702
  const root = segments[0] ?? "";
683
703
  if (!SCOPE_PATH_ROOTS.has(root)) {
684
704
  add(issuePath, "invalid_value_ref", `Path '${path}' must start with ${[...SCOPE_PATH_ROOTS].join(", ")}.`);
705
+ return;
706
+ }
707
+ validateScopeNodeId(path, segments, issuePath, add, scope);
708
+ }
709
+ /**
710
+ * The node a `steps.` / `loop.` path reads from must exist.
711
+ *
712
+ * Checking only the ROOT, as this linter did until this rule landed, let
713
+ * `steps.doesnotexist.output` and `steps.enrich.otuput` both save, publish, and
714
+ * resolve to `undefined` mid-run with nothing reported anywhere — a silent
715
+ * wrong-answer, since a missing binding is indistinguishable from a provider
716
+ * that returned nothing. Authors reach for the free-text path constantly,
717
+ * because most Oxygen tools publish no output schema and the guided picker can
718
+ * only offer "Whole output" until the workflow has run once.
719
+ *
720
+ * DELIBERATELY STOPS AT THE NODE ID. The field after it is not checked and must
721
+ * not be: the manifest carries no output schema for a tool, so
722
+ * `steps.enrich.output.anything` is a legitimate binding whose shape is only
723
+ * known once the tool answers. Flagging it would refuse correct workflows, which
724
+ * is strictly worse than the silence this rule removes. The id is the part that
725
+ * is always knowable, and it is where a typo is unrecoverable.
726
+ *
727
+ * Upstream-ness is deliberately NOT checked either. A ref to a node that has not
728
+ * run resolves to `undefined` just as surely, but "has run" is not a static
729
+ * property of the graph: a body node's outputs survive its loop under a keyed
730
+ * step id, a `fallthrough` switch runs several branches, and an author normally
731
+ * configures a node before wiring it in. Every one of those is a legitimate
732
+ * graph that a reachability rule would refuse.
733
+ */
734
+ function validateScopeNodeId(path, segments, issuePath, add, scope) {
735
+ const root = segments[0];
736
+ if (root !== "steps" && root !== "loop")
737
+ return; // `trigger` names no node.
738
+ const id = segments[1];
739
+ // A bare `steps` / `loop` is the whole scope object, not a reference to a node.
740
+ if (id === undefined)
741
+ return;
742
+ if (!scope.ids.has(id)) {
743
+ add(issuePath, "unknown_node_ref", `Path '${path}' reads from node '${id}', which this workflow does not have. Check the node id.`);
744
+ return;
745
+ }
746
+ if (root === "loop" && !scope.loops.has(id)) {
747
+ add(issuePath, "unknown_node_ref", `Path '${path}' reads a loop item from node '${id}', which is not a loop node.`);
685
748
  }
686
749
  }
687
750
  /** Paths inside a context value have no scope root — only syntax to check. */
@@ -690,12 +753,12 @@ function validateOptionalRefPath(path, issuePath, add) {
690
753
  add(issuePath, "invalid_value_ref", "Context path is malformed or reaches a reserved key.");
691
754
  }
692
755
  }
693
- function validateTemplatePaths(template, issuePath, add) {
756
+ function validateTemplatePaths(template, issuePath, add, scope) {
694
757
  for (const match of template.matchAll(/\{\{([^{}]*)\}\}/g)) {
695
758
  const path = (match[1] ?? "").trim();
696
759
  // "{{}}" is literal text by design, so it is not a binding to validate.
697
760
  if (path)
698
- validateScopePath(path, issuePath, add);
761
+ validateScopePath(path, issuePath, add, scope);
699
762
  }
700
763
  }
701
764
  /**
@@ -718,7 +781,7 @@ function validateTemplatePaths(template, issuePath, add) {
718
781
  * empty string. Guessing which of `{{lop.x}}` and `{{company}}` was meant to be
719
782
  * ours is not something a linter can do correctly.
720
783
  */
721
- function validateStructuredLiteralPaths(value, issuePath, add, depth = 0) {
784
+ function validateStructuredLiteralPaths(value, issuePath, add, scope, depth = 0) {
722
785
  if (depth > 12)
723
786
  return;
724
787
  if (typeof value === "string") {
@@ -731,24 +794,25 @@ function validateStructuredLiteralPaths(value, issuePath, add, depth = 0) {
731
794
  continue;
732
795
  const root = path.split(/[.[]/, 1)[0];
733
796
  if (root !== undefined && SCOPE_PATH_ROOTS.has(root)) {
734
- validateScopePath(path, issuePath, add);
797
+ validateScopePath(path, issuePath, add, scope);
735
798
  }
736
799
  }
737
800
  return;
738
801
  }
739
802
  if (Array.isArray(value)) {
740
- for (const entry of value)
741
- validateStructuredLiteralPaths(entry, issuePath, add, depth + 1);
803
+ for (const entry of value) {
804
+ validateStructuredLiteralPaths(entry, issuePath, add, scope, depth + 1);
805
+ }
742
806
  return;
743
807
  }
744
808
  if (isRecord(value)) {
745
809
  for (const entry of Object.values(value)) {
746
- validateStructuredLiteralPaths(entry, issuePath, add, depth + 1);
810
+ validateStructuredLiteralPaths(entry, issuePath, add, scope, depth + 1);
747
811
  }
748
812
  }
749
813
  }
750
814
  function validateCondition(// skipcq: JS-R1005
751
- value, path, add, depth = 0) {
815
+ value, path, add, scope, depth = 0) {
752
816
  if (!isRecord(value)) {
753
817
  add(path, "invalid_condition", "Condition must be an object.");
754
818
  return;
@@ -764,17 +828,33 @@ value, path, add, depth = 0) {
764
828
  if (value.not !== undefined && typeof value.not !== "boolean") {
765
829
  add(`${path}.not`, "invalid_condition", "Condition group not must be a boolean.");
766
830
  }
767
- if (!Array.isArray(value.children) || value.children.length === 0) {
831
+ // An empty group is refused EXCEPT the one form that is vacuously TRUE:
832
+ // `all` with no children and no `not`, which evaluateCondition returns true
833
+ // for by the Array.every identity (expression.ts). That form is how a
834
+ // switch spells an "otherwise" arm — a final always-matching case which,
835
+ // because switch is first-match-wins, fires only when nothing above did.
836
+ //
837
+ // The other two empty shapes stay refused BECAUSE they are the opposite:
838
+ // empty `any` is vacuously false, and empty `all` inverted by `not` is
839
+ // false too, so either one is an arm that can never fire — the same silent
840
+ // dead end this rule exists to prevent, wearing different clothes.
841
+ if (!Array.isArray(value.children)) {
768
842
  add(`${path}.children`, "invalid_condition", "Condition group requires at least one child.");
769
843
  return;
770
844
  }
845
+ if (value.children.length === 0 && !(value.op === "all" && value.not !== true)) {
846
+ add(`${path}.children`, "invalid_condition", value.op === "any"
847
+ ? "An empty 'any' group never matches, so this arm could never run. Add a condition, or use an empty 'all' group as a catch-all."
848
+ : "An inverted empty group never matches, so this arm could never run. Remove the inversion to make it a catch-all.");
849
+ return;
850
+ }
771
851
  value.children.forEach((child, index) => {
772
- validateCondition(child, `${path}.children.${index}`, add, depth + 1);
852
+ validateCondition(child, `${path}.children.${index}`, add, scope, depth + 1);
773
853
  });
774
854
  return;
775
855
  }
776
856
  if (value.type === "compare") {
777
- validateValueRef(value.left, `${path}.left`, add);
857
+ validateValueRef(value.left, `${path}.left`, add, scope);
778
858
  if (!isCompareOp(value.op)) {
779
859
  add(`${path}.op`, "invalid_condition", "Condition compare op is invalid.");
780
860
  return;
@@ -789,7 +869,7 @@ value, path, add, depth = 0) {
789
869
  add(`${path}.right`, "invalid_condition", `Operator '${value.op}' requires a right operand.`);
790
870
  return;
791
871
  }
792
- validateValueRef(value.right, `${path}.right`, add);
872
+ validateValueRef(value.right, `${path}.right`, add, scope);
793
873
  if (value.op === "matches")
794
874
  validateMatchPattern(value.right, `${path}.right`, add);
795
875
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxygen-agent/cli",
3
- "version": "1.627.4",
3
+ "version": "1.632.1",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",