@gr8ful/spf 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -146,6 +146,12 @@ interface RunForAgents {
146
146
  envelopeRow: (phase: Phase, agent: string, outputType: string, payloadJson: string, valid: boolean, attempt: number) => Promise<void>;
147
147
  gateRow: (phase: Phase, gate: string, report: GateReport, attempt: number) => Promise<void>;
148
148
  agentSessionRow: (adwId: string, agent: AgentConfig, sessionId: string, contextTokens?: number, contextWindow?: number) => Promise<void>;
149
+ otel?: {
150
+ agentCallTraceContext: (phaseId: string, agentName: string) => {
151
+ traceparent: string;
152
+ spanId: string;
153
+ } | null;
154
+ } | null;
149
155
  };
150
156
  console: {
151
157
  agentStarted: (name: string, model: string, sessionId: string) => Promise<void>;
@@ -808,6 +808,19 @@ export async function execute(run, phase, call) {
808
808
  // WHOLE run rather than on the first call of each phase. See
809
809
  // `assertRunBudget` for why it is checked before, not after.
810
810
  assertRunBudget(run);
811
+ // Outbound OTel propagation (SPF's otel-sdk extension): the CURRENTLY
812
+ // OPEN agent-call span's own trace context, when one exists — `null`
813
+ // whenever `observability.otel` is unconfigured for this run (the
814
+ // common case, and byte-identical to before this field existed) or the
815
+ // exporter has no agent call open (shouldn't happen here — agent_start
816
+ // fires before `send()` is ever reached — but a `null` is a silent
817
+ // no-op either way, never an error). `otelBlock` threads the SAME
818
+ // endpoint/headers/service_name through so `agent_flue.ts` can install
819
+ // its own (separate, process-scoped) http/undici propagation without
820
+ // needing the whole `SFConfig` — see `data_types.ts`'s `AgentRequest.
821
+ // otel` doc comment.
822
+ const otelBlock = run.cfg.observability.otel;
823
+ const otelCtx = otelBlock ? (run.tracer.otel?.agentCallTraceContext(phase.phase_id, agent.name) ?? null) : null;
811
824
  const request = {
812
825
  prompt: promptText,
813
826
  system_prompt: systemText,
@@ -822,6 +835,15 @@ export async function execute(run, phase, call) {
822
835
  flue_db_path: path.join(run.data_dir, "flue.db"),
823
836
  env: agentEnv(agent),
824
837
  sandbox: spec,
838
+ otel: otelCtx && otelBlock
839
+ ? {
840
+ traceparent: otelCtx.traceparent,
841
+ x_request_id: otelCtx.spanId,
842
+ endpoint: otelBlock.endpoint,
843
+ headers: otelBlock.headers,
844
+ service_name: otelBlock.service_name,
845
+ }
846
+ : undefined,
825
847
  };
826
848
  const forward = eventForwarder(run, phase, agent.name, agent.coding_agent);
827
849
  // Best-effort, fire-and-forget: these fire from a plain process-lifecycle
@@ -685,6 +685,7 @@ export declare const AgentConfigSchema: v.ObjectSchema<{
685
685
  readonly writes: v.OptionalSchema<v.NullableSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>, undefined>;
686
686
  readonly env_allowlist: v.OptionalSchema<v.NullableSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>, undefined>;
687
687
  readonly sandbox: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<["local", "opensandbox", "cloudflare"], undefined>, undefined>, undefined>;
688
+ readonly lora_adapter: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
688
689
  }, undefined>;
689
690
  export type AgentConfig = v.InferOutput<typeof AgentConfigSchema>;
690
691
  export declare const ConfigDefaultsSchema: v.ObjectSchema<{
@@ -756,13 +757,45 @@ export type ConfigDefaults = v.InferOutput<typeof ConfigDefaultsSchema>;
756
757
  * (`https://collector:4318/v1/traces`) or a bare origin (`/v1/traces` is
757
758
  * appended — see `resolveTracesUrl`). `headers` is where a collector's auth
758
759
  * token goes; its VALUES are treated as secrets and never logged.
760
+ *
761
+ * `metrics` (default `true`) additionally gates `otel_metrics.ts`'s
762
+ * process-scoped meter — set `false` to keep trace export on while opting
763
+ * out of the metrics pipeline entirely. It has no effect on activation
764
+ * either way: `endpoint`'s presence is still the sole switch for BOTH
765
+ * signals, this field only narrows what a configured block sends.
766
+ *
767
+ * `allow_env` (default `false`) is a narrow, opt-in exception to EXPLICIT
768
+ * CONFIG ONLY (see `core/otel.ts`'s header): when `true` AND this block is
769
+ * ALREADY active (`endpoint` set here, in the file), the standard
770
+ * `OTEL_EXPORTER_OTLP_ENDPOINT`/`OTEL_EXPORTER_OTLP_HEADERS` env vars may
771
+ * SUPPLEMENT it — e.g. a CI-injected collector token added to `headers`
772
+ * without checking it into the repo. It can NEVER activate export from
773
+ * nothing (there is no `endpoint` to fall back to when this block is absent
774
+ * at all — that case is unaffected by this flag either way) and a
775
+ * config-declared value always wins over the env on conflict. Defaults to
776
+ * `false` because this is still a deliberate loosening of an adversarially-
777
+ * reviewed, test-pinned invariant — an operator opts in per-repo.
759
778
  */
760
779
  export declare const OTelConfigSchema: v.ObjectSchema<{
761
780
  readonly endpoint: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.UrlAction<string, undefined>]>;
762
781
  readonly headers: v.OptionalSchema<v.RecordSchema<v.StringSchema<undefined>, v.StringSchema<undefined>, undefined>, undefined>;
763
782
  readonly service_name: v.OptionalSchema<v.StringSchema<undefined>, "spf">;
783
+ readonly metrics: v.OptionalSchema<v.BooleanSchema<undefined>, true>;
784
+ readonly allow_env: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
764
785
  }, undefined>;
765
786
  export type OTelConfig = v.InferOutput<typeof OTelConfigSchema>;
787
+ /**
788
+ * `allow_env`'s narrow env-supplement path (see `OTelConfigSchema`'s doc
789
+ * comment above): called ONLY when `cfg.observability.otel` already exists
790
+ * with a real `endpoint` — this function never activates anything on its
791
+ * own and is never called when the block is absent. `OTEL_EXPORTER_OTLP_
792
+ * HEADERS` follows the OTel spec's env-var shape (comma-separated
793
+ * `key=value` pairs, URL-decoded); config-declared headers win over the env
794
+ * on a key collision. `OTEL_EXPORTER_OTLP_ENDPOINT`, when set, overrides
795
+ * `endpoint` itself — the one field this can change, since a CI runner
796
+ * commonly injects a different collector per job/environment.
797
+ */
798
+ export declare function applyOtelEnvSupplement(otel: OTelConfig, env?: NodeJS.ProcessEnv): OTelConfig;
766
799
  /**
767
800
  * MIGRATION NOTE (BT-issue #66, 3 PRs): `observability.db` used to be ONLY a
768
801
  * bare string (a local sqlite path, defaulting to ".spf/data/spf.db"). This
@@ -868,6 +901,8 @@ export declare const ObservabilityConfigSchema: v.ObjectSchema<{
868
901
  readonly endpoint: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.UrlAction<string, undefined>]>;
869
902
  readonly headers: v.OptionalSchema<v.RecordSchema<v.StringSchema<undefined>, v.StringSchema<undefined>, undefined>, undefined>;
870
903
  readonly service_name: v.OptionalSchema<v.StringSchema<undefined>, "spf">;
904
+ readonly metrics: v.OptionalSchema<v.BooleanSchema<undefined>, true>;
905
+ readonly allow_env: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
871
906
  }, undefined>, undefined>;
872
907
  }, undefined>;
873
908
  export type ObservabilityConfig = v.InferOutput<typeof ObservabilityConfigSchema>;
@@ -1525,6 +1560,8 @@ export declare const SFConfigSchema: v.ObjectSchema<{
1525
1560
  readonly endpoint: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.UrlAction<string, undefined>]>;
1526
1561
  readonly headers: v.OptionalSchema<v.RecordSchema<v.StringSchema<undefined>, v.StringSchema<undefined>, undefined>, undefined>;
1527
1562
  readonly service_name: v.OptionalSchema<v.StringSchema<undefined>, "spf">;
1563
+ readonly metrics: v.OptionalSchema<v.BooleanSchema<undefined>, true>;
1564
+ readonly allow_env: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
1528
1565
  }, undefined>, undefined>;
1529
1566
  }, undefined>, () => {
1530
1567
  db: string | {
@@ -1543,6 +1580,8 @@ export declare const SFConfigSchema: v.ObjectSchema<{
1543
1580
  [x: string]: string;
1544
1581
  } | undefined;
1545
1582
  service_name: string;
1583
+ metrics: boolean;
1584
+ allow_env: boolean;
1546
1585
  } | undefined;
1547
1586
  }>;
1548
1587
  readonly agents: v.OptionalSchema<v.ArraySchema<v.ObjectSchema<{
@@ -1561,6 +1600,7 @@ export declare const SFConfigSchema: v.ObjectSchema<{
1561
1600
  readonly writes: v.OptionalSchema<v.NullableSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>, undefined>;
1562
1601
  readonly env_allowlist: v.OptionalSchema<v.NullableSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>, undefined>;
1563
1602
  readonly sandbox: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<["local", "opensandbox", "cloudflare"], undefined>, undefined>, undefined>;
1603
+ readonly lora_adapter: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
1564
1604
  }, undefined>, undefined>, () => never[]>;
1565
1605
  readonly quality: v.OptionalSchema<v.ObjectSchema<{
1566
1606
  readonly checks: v.OptionalSchema<v.ArraySchema<v.ObjectSchema<{
@@ -1974,6 +2014,26 @@ export interface AgentRequest {
1974
2014
  env?: Record<string, string>;
1975
2015
  /** Absent (the default) => local(), byte-identical to before this field existed. See sandbox.ts. */
1976
2016
  sandbox?: SandboxSpec;
2017
+ /**
2018
+ * Outbound OTel trace-context propagation — set by `agents.ts`'s `send()`
2019
+ * from `otel.ts`'s `OtelExporter.agentCallTraceContext()` ONLY when
2020
+ * `observability.otel` is configured for this run; absent otherwise, and
2021
+ * every backend that ignores it (`opencode` today) is byte-identical to
2022
+ * before this field existed. `traceparent`/`x_request_id` are this call's
2023
+ * own span context (`agent_cc.ts`'s single `spawn()` choke point turns
2024
+ * them into `TRACEPARENT`/`ANTHROPIC_CUSTOM_HEADERS`); `endpoint`/
2025
+ * `headers`/`service_name` are the SAME `observability.otel` block,
2026
+ * carried through so `agent_flue.ts` can install its own (separate,
2027
+ * process-scoped — see `otel_propagation.ts`) global http/undici
2028
+ * propagation without needing the full `SFConfig`.
2029
+ */
2030
+ otel?: {
2031
+ traceparent: string;
2032
+ x_request_id: string;
2033
+ endpoint: string;
2034
+ headers?: Record<string, string>;
2035
+ service_name: string;
2036
+ };
1977
2037
  }
1978
2038
  /**
1979
2039
  * Tokens and the dollars they cost, per component, summed over a call.
@@ -508,6 +508,13 @@ export const AgentConfigSchema = v.object({
508
508
  // "local" -> force local for this agent
509
509
  // null -> same as unset (the writes/env_allowlist spelling)
510
510
  sandbox: v.optional(v.nullable(SandboxBackendSchema)),
511
+ // Explicit override for `otel.ts`'s `spf.lora_adapter` span attribute —
512
+ // the unambiguous source of truth when set. Unset (the common case) falls
513
+ // back to parsing `model` itself; see `loraAdapterFor()`'s own doc comment
514
+ // in `core/otel.ts` for the two zero-config conventions it recognizes.
515
+ // Purely descriptive: it does not change routing, dispatch, or which
516
+ // model actually serves the call — only what an OTel backend sees.
517
+ lora_adapter: v.optional(v.string()),
511
518
  });
512
519
  export const ConfigDefaultsSchema = v.object({
513
520
  coding_agent: v.optional(v.picklist(["flue", "claude_code", "opencode"]), "flue"),
@@ -581,12 +588,74 @@ export const ConfigDefaultsSchema = v.object({
581
588
  * (`https://collector:4318/v1/traces`) or a bare origin (`/v1/traces` is
582
589
  * appended — see `resolveTracesUrl`). `headers` is where a collector's auth
583
590
  * token goes; its VALUES are treated as secrets and never logged.
591
+ *
592
+ * `metrics` (default `true`) additionally gates `otel_metrics.ts`'s
593
+ * process-scoped meter — set `false` to keep trace export on while opting
594
+ * out of the metrics pipeline entirely. It has no effect on activation
595
+ * either way: `endpoint`'s presence is still the sole switch for BOTH
596
+ * signals, this field only narrows what a configured block sends.
597
+ *
598
+ * `allow_env` (default `false`) is a narrow, opt-in exception to EXPLICIT
599
+ * CONFIG ONLY (see `core/otel.ts`'s header): when `true` AND this block is
600
+ * ALREADY active (`endpoint` set here, in the file), the standard
601
+ * `OTEL_EXPORTER_OTLP_ENDPOINT`/`OTEL_EXPORTER_OTLP_HEADERS` env vars may
602
+ * SUPPLEMENT it — e.g. a CI-injected collector token added to `headers`
603
+ * without checking it into the repo. It can NEVER activate export from
604
+ * nothing (there is no `endpoint` to fall back to when this block is absent
605
+ * at all — that case is unaffected by this flag either way) and a
606
+ * config-declared value always wins over the env on conflict. Defaults to
607
+ * `false` because this is still a deliberate loosening of an adversarially-
608
+ * reviewed, test-pinned invariant — an operator opts in per-repo.
584
609
  */
585
610
  export const OTelConfigSchema = v.object({
586
611
  endpoint: v.pipe(v.string(), v.url()),
587
612
  headers: v.optional(v.record(v.string(), v.string()), undefined),
588
613
  service_name: v.optional(v.string(), "spf"),
614
+ metrics: v.optional(v.boolean(), true),
615
+ allow_env: v.optional(v.boolean(), false),
589
616
  });
617
+ /**
618
+ * `allow_env`'s narrow env-supplement path (see `OTelConfigSchema`'s doc
619
+ * comment above): called ONLY when `cfg.observability.otel` already exists
620
+ * with a real `endpoint` — this function never activates anything on its
621
+ * own and is never called when the block is absent. `OTEL_EXPORTER_OTLP_
622
+ * HEADERS` follows the OTel spec's env-var shape (comma-separated
623
+ * `key=value` pairs, URL-decoded); config-declared headers win over the env
624
+ * on a key collision. `OTEL_EXPORTER_OTLP_ENDPOINT`, when set, overrides
625
+ * `endpoint` itself — the one field this can change, since a CI runner
626
+ * commonly injects a different collector per job/environment.
627
+ */
628
+ export function applyOtelEnvSupplement(otel, env = process.env) {
629
+ if (!otel.allow_env)
630
+ return otel;
631
+ const envEndpoint = env["OTEL_EXPORTER_OTLP_ENDPOINT"];
632
+ const envHeadersRaw = env["OTEL_EXPORTER_OTLP_HEADERS"];
633
+ const envHeaders = {};
634
+ if (envHeadersRaw) {
635
+ for (const pair of envHeadersRaw.split(",")) {
636
+ const eq = pair.indexOf("=");
637
+ if (eq <= 0)
638
+ continue;
639
+ const key = pair.slice(0, eq).trim();
640
+ const value = pair.slice(eq + 1).trim();
641
+ if (key) {
642
+ try {
643
+ envHeaders[key] = decodeURIComponent(value);
644
+ }
645
+ catch {
646
+ envHeaders[key] = value;
647
+ }
648
+ }
649
+ }
650
+ }
651
+ if (!envEndpoint && Object.keys(envHeaders).length === 0)
652
+ return otel;
653
+ return {
654
+ ...otel,
655
+ endpoint: envEndpoint || otel.endpoint,
656
+ headers: { ...envHeaders, ...(otel.headers ?? {}) }, // config wins on conflict
657
+ };
658
+ }
590
659
  /**
591
660
  * MIGRATION NOTE (BT-issue #66, 3 PRs): `observability.db` used to be ONLY a
592
661
  * bare string (a local sqlite path, defaulting to ".spf/data/spf.db"). This
@@ -14,8 +14,13 @@
14
14
  * now is `/rest/api/3/search/jql`.
15
15
  * - Comment/description bodies in API v3 are Atlassian Document Format
16
16
  * (ADF) JSON, not plain strings — `{"body": "text"}` is rejected
17
- * outright. `toAdf`/`adfToText` are the minimal round-trip this needs:
18
- * one paragraph of plain text, nothing richer.
17
+ * outright. Human/LLM-authored content (`comment()`, `createIssue()`)
18
+ * goes through `markdownToAdf()`/`adfToMarkdown()` (`./markdown_adf.ts`)
19
+ * for real Jira formatting; the local `toAdf()` minimal round-trip (one
20
+ * paragraph of plain text, nothing richer) survives ONLY for
21
+ * `writeMarker()`'s hidden `[spf-watch-marker]` JSON payload, which must
22
+ * never pass through a real Markdown parser — see `toAdf`'s own doc
23
+ * comment.
19
24
  *
20
25
  * State is modeled as Jira labels (`<prefix>:ready`, etc.), mirroring
21
26
  * `github_provider.ts` exactly — labels are spf's ACTUAL state machine and
@@ -1,4 +1,5 @@
1
1
  import { fetchRetryTransient } from "../utils.js";
2
+ import { adfToMarkdown, markdownToAdf } from "./markdown_adf.js";
2
3
  const STATES = [
3
4
  "ready",
4
5
  "working",
@@ -27,6 +28,19 @@ const STATES = [
27
28
  * here because nothing follows the JSON in this format at all.
28
29
  */
29
30
  const MARKER_RE = /\[spf-watch-marker\]\s*(\{.*\})/s;
31
+ /**
32
+ * The ONLY remaining caller is `writeMarker()`'s hidden `[spf-watch-marker]`
33
+ * JSON payload — deliberately kept as this minimal, un-parsed shape (see
34
+ * this file's module comment) rather than routed through
35
+ * `markdownToAdf()`, because that payload must round-trip byte-for-byte
36
+ * through `MARKER_RE` and must never have its JSON characters (underscores,
37
+ * brackets, backticks) reinterpreted as Markdown syntax. Do not change this
38
+ * call site or add other callers — human/LLM-authored content
39
+ * (`comment()`, `createIssue()`) goes through `markdownToAdf()` instead, and
40
+ * reading ADF back to text goes through `adfToMarkdown()` (both imported
41
+ * from `./markdown_adf.ts`) — there is exactly one ADF-to-text
42
+ * implementation in this codebase now, not two divergent ones.
43
+ */
30
44
  function toAdf(text) {
31
45
  return {
32
46
  type: "doc",
@@ -34,17 +48,6 @@ function toAdf(text) {
34
48
  content: [{ type: "paragraph", content: [{ type: "text", text }] }],
35
49
  };
36
50
  }
37
- /** Walks an ADF document's `text` nodes and joins them — the minimal inverse of `toAdf`, not a full ADF renderer. */
38
- function adfToText(adf) {
39
- if (!adf || typeof adf !== "object")
40
- return "";
41
- const node = adf;
42
- if (node.type === "text" && typeof node.text === "string")
43
- return node.text;
44
- if (Array.isArray(node.content))
45
- return node.content.map(adfToText).join("");
46
- return "";
47
- }
48
51
  export class JiraProvider {
49
52
  baseUrl;
50
53
  projectKey;
@@ -101,7 +104,7 @@ export class JiraProvider {
101
104
  return {
102
105
  id: raw.key,
103
106
  title: raw.fields.summary,
104
- body: raw.fields.description ? adfToText(raw.fields.description) : "",
107
+ body: raw.fields.description ? adfToMarkdown(raw.fields.description) : "",
105
108
  labels: raw.fields.labels,
106
109
  };
107
110
  }
@@ -163,7 +166,7 @@ export class JiraProvider {
163
166
  fields: {
164
167
  project: { key: this.projectKey },
165
168
  summary: input.title,
166
- description: toAdf(input.body),
169
+ description: markdownToAdf(input.body),
167
170
  issuetype: { name: this.issueTypes[input.kind] },
168
171
  labels: input.labels,
169
172
  },
@@ -296,7 +299,7 @@ export class JiraProvider {
296
299
  .map(([state, jiraStatus]) => ({ state, jiraStatus, exists: available.has(jiraStatus) }));
297
300
  }
298
301
  async comment(issue, body) {
299
- await this.jira(`/rest/api/3/issue/${issue.id}/comment`, { method: "POST", body: JSON.stringify({ body: toAdf(body) }) });
302
+ await this.jira(`/rest/api/3/issue/${issue.id}/comment`, { method: "POST", body: JSON.stringify({ body: markdownToAdf(body) }) });
300
303
  }
301
304
  /** The single fetch every comment-reading method (`findMarkerComment`, `listComments`) builds on. */
302
305
  async fetchComments(issueId) {
@@ -307,7 +310,7 @@ export class JiraProvider {
307
310
  const comments = await this.fetchComments(issueId);
308
311
  let found = null;
309
312
  for (const c of comments) {
310
- const match = MARKER_RE.exec(adfToText(c.body));
313
+ const match = MARKER_RE.exec(adfToMarkdown(c.body));
311
314
  if (!match)
312
315
  continue;
313
316
  try {
@@ -323,8 +326,8 @@ export class JiraProvider {
323
326
  async listComments(issue) {
324
327
  const comments = await this.fetchComments(issue.id);
325
328
  return comments
326
- .filter((c) => !MARKER_RE.test(adfToText(c.body)))
327
- .map((c) => ({ id: c.id, author: c.author?.displayName ?? "unknown", created_at: c.created, body: adfToText(c.body) }));
329
+ .filter((c) => !MARKER_RE.test(adfToMarkdown(c.body)))
330
+ .map((c) => ({ id: c.id, author: c.author?.displayName ?? "unknown", created_at: c.created, body: adfToMarkdown(c.body) }));
328
331
  }
329
332
  async readMarker(issue) {
330
333
  const found = await this.findMarkerComment(issue.id);
@@ -0,0 +1,58 @@
1
+ /**
2
+ * A hand-rolled Markdown <-> Atlassian Document Format (ADF) converter for
3
+ * Jira comment/description bodies — deliberately NOT built on a markdown
4
+ * parsing library. This project has zero markdown dependencies today and
5
+ * that's a deliberate choice (see `package.json`): `jira_provider.ts`'s own
6
+ * `toAdf`/`adfToText` pair is the existing precedent (one paragraph of
7
+ * plain text, nothing richer), and this module is the same philosophy
8
+ * extended to a small, explicitly-bounded Markdown subset, not a general
9
+ * CommonMark implementation.
10
+ *
11
+ * SUPPORTED (confirmed scope, see the module's originating task — do not
12
+ * silently grow this list): ATX headings (`#`..`######`), bold
13
+ * (`**x**`/`__x__`), italic (`*x*`/`_x_`), strikethrough (`~~x~~`), inline
14
+ * code (`` `x` ``), fenced code blocks with optional language, bullet
15
+ * (`-`/`*`/`+`) and ordered (`1.`) lists with ONE level of nesting,
16
+ * blockquotes (consecutive `>` lines as one block), horizontal rules
17
+ * (`---`/`***`/`___` alone on a line), links (`[text](url)`), paragraphs
18
+ * separated by blank lines, and hard line breaks (a line ending in two-plus
19
+ * trailing spaces, or a lone trailing backslash).
20
+ *
21
+ * OUT OF SCOPE (tables, images, @mentions, raw HTML, nested blockquotes-in-
22
+ * lists, >1 level of list nesting): never crashes and never corrupts
23
+ * structure on these — `markdownToAdf` falls through to literal text in a
24
+ * plain paragraph (matching `toAdf`'s existing behavior for "everything"
25
+ * before this module existed), and `adfToMarkdown` degrades any node/mark
26
+ * type it doesn't recognize to its nested text content. `markdownToAdf`
27
+ * must NEVER throw on any input string — a malformed fence, an unmatched
28
+ * `**`, an empty link target, all degrade to literal text rather than
29
+ * erroring, because this runs unattended inside `spf watch`.
30
+ *
31
+ * ADF node/mark shapes below are taken from Atlassian's published document
32
+ * structure (developer.atlassian.com/cloud/jira/platform/apis/document/
33
+ * structure/), not guessed — in particular the strikethrough mark's real
34
+ * type name is `"strike"`, NOT `"strikethrough"` (an easy guess to get
35
+ * wrong), and ADF text nodes must never carry an empty `text` string (the
36
+ * schema requires non-empty), which is why every text-emitting path here
37
+ * checks for empty content before emitting a node instead of always
38
+ * emitting one unconditionally the way `jira_provider.ts`'s `toAdf` does
39
+ * for its single fixed paragraph.
40
+ */
41
+ /**
42
+ * Parses the constrained Markdown subset (module comment) into an ADF
43
+ * document node. Never throws: every unrecognized or malformed construct
44
+ * degrades to literal text inside a plain paragraph rather than raising,
45
+ * because this feeds `spf watch`'s unattended comment/description writes.
46
+ */
47
+ export declare function markdownToAdf(text: string): unknown;
48
+ /**
49
+ * The inverse of `markdownToAdf`: renders an ADF document back into the
50
+ * same Markdown subset. NOT required to byte-match the original input
51
+ * (canonical forms are fine — e.g. always `-` for bullets even if the
52
+ * source used `*`), only to be semantically recognizable and to converge
53
+ * after one more round trip through `markdownToAdf`. Handles ADF built by
54
+ * this module OR hand-built elsewhere (e.g. Jira's own rich-text editor):
55
+ * any node or mark type it doesn't recognize degrades to its nested text
56
+ * content via `renderFallbackText` rather than throwing or dropping it.
57
+ */
58
+ export declare function adfToMarkdown(adf: unknown): string;