@gr8ful/spf 0.18.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.
@@ -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;
@@ -0,0 +1,705 @@
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
+ * Canonical mark order, used both when building a text node's `marks`
43
+ * array (so two equivalent inputs always produce byte-identical mark
44
+ * ordering — load-bearing for round-trip stability, since
45
+ * `assert.deepEqual`-style comparison and re-parsing both depend on a
46
+ * single canonical shape rather than "any order that happens to result
47
+ * from parse order") and when reading one back. `code` sits last because
48
+ * `renderTextNode` (below) treats it as exclusive of the others — see that
49
+ * function's own comment.
50
+ */
51
+ const MARK_ORDER = ["link", "strong", "em", "strike", "code"];
52
+ function addMark(marks, mark) {
53
+ if (marks.some((m) => m.type === mark.type))
54
+ return [...marks]; // already applied (e.g. `**a**` nested inside another `**...**`) — don't duplicate
55
+ const next = [...marks, mark];
56
+ return next.sort((a, b) => MARK_ORDER.indexOf(a.type) - MARK_ORDER.indexOf(b.type));
57
+ }
58
+ function makeText(text, marks) {
59
+ if (text.length === 0)
60
+ return null; // ADF text nodes must be non-empty — silently drop, never emit `{type:"text",text:""}`
61
+ const node = { type: "text", text };
62
+ if (marks.length > 0)
63
+ node["marks"] = marks.map((m) => (m.attrs ? { type: m.type, attrs: m.attrs } : { type: m.type }));
64
+ return node;
65
+ }
66
+ /**
67
+ * CommonMark-style "flanking delimiter run" rules for a single `*`/`_`
68
+ * character, applied at the character immediately before/after it. Without
69
+ * these, ANY `*`/`_` paired with the next occurrence of the same character
70
+ * regardless of context — which is what let a bare multiplication `*`
71
+ * between spaces (`2 * 3`) and a `snake_case`/`file_path.ts` identifier's
72
+ * underscores get silently paired up and italicized across unrelated words.
73
+ * `_` additionally can't open/close when it's flanking on BOTH sides
74
+ * without adjacent punctuation (the "intraword underscore" rule — real
75
+ * Markdown never treats `foo_bar_baz` as emphasis); `*` has no such
76
+ * restriction, matching CommonMark's own intraword-`*emphasis*` allowance.
77
+ */
78
+ function isWsBoundary(ch) {
79
+ return ch === undefined || /\s/.test(ch);
80
+ }
81
+ function isPunctBoundary(ch) {
82
+ return ch !== undefined && /[\p{P}\p{S}]/u.test(ch);
83
+ }
84
+ function isLeftFlanking(before, after) {
85
+ if (isWsBoundary(after))
86
+ return false;
87
+ if (!isPunctBoundary(after))
88
+ return true;
89
+ return isWsBoundary(before) || isPunctBoundary(before);
90
+ }
91
+ function isRightFlanking(before, after) {
92
+ if (isWsBoundary(before))
93
+ return false;
94
+ if (!isPunctBoundary(before))
95
+ return true;
96
+ return isWsBoundary(after) || isPunctBoundary(after);
97
+ }
98
+ function canOpenEmphasis(ch, before, after) {
99
+ if (!isLeftFlanking(before, after))
100
+ return false;
101
+ if (ch === "*")
102
+ return true;
103
+ return !isRightFlanking(before, after) || isPunctBoundary(before);
104
+ }
105
+ function canCloseEmphasis(ch, before, after) {
106
+ if (!isRightFlanking(before, after))
107
+ return false;
108
+ if (ch === "*")
109
+ return true;
110
+ return !isLeftFlanking(before, after) || isPunctBoundary(after);
111
+ }
112
+ /**
113
+ * Characters a `\`-prefixed occurrence should parse as a plain literal
114
+ * character rather than markup (standard Markdown backslash-escaping —
115
+ * e.g. `\*not italic\*`). Two groups share this one set:
116
+ * - INLINE delimiters this module's tokenizer treats specially inside a
117
+ * line (`` ` * _ ~ [ ] ( ) ``), for input Markdown source that wants a
118
+ * literal one of these;
119
+ * - BLOCK-start markers (`- + # > .`) that only matter at column 0 of a
120
+ * line — `escapeParagraphLine` (this module's render side) uses these
121
+ * to stop a rendered paragraph line from being reparsed as a
122
+ * fence/rule/heading/blockquote/list; see that function's comment.
123
+ * `adfToMarkdown` deliberately does NOT escape the first (inline) group on
124
+ * the way out for plain text — see `renderTextNode`'s comment on why.
125
+ */
126
+ const ESCAPABLE_INLINE_CHARS = new Set(["\\", "`", "*", "_", "~", "[", "]", "(", ")", "-", "+", "#", ">", "."]);
127
+ /**
128
+ * The inline tokenizer. Recursive-descent over the raw string: each
129
+ * delimiter (code span, link, bold, italic, strike) is resolved by
130
+ * scanning forward for its matching close and, if found, recursing on the
131
+ * inner text with the new mark added — which is what lets `**a *b* c**`
132
+ * nest italic inside bold correctly (the inner recursive call sees the
133
+ * accumulated `[strong]` marks list). If no matching close exists (an
134
+ * unterminated `**`, a `[` with no `](url)`), the opening characters fall
135
+ * through to plain buffered text — the same "degrade to literal, never
136
+ * throw" rule the module comment describes, applied at the character
137
+ * level instead of the block level.
138
+ *
139
+ * Bold-vs-italic precedence: `**`/`__` are checked (via `startsWith`)
140
+ * BEFORE the single-char `*`/`_` case, so `**x**` is never misread as two
141
+ * adjacent unmatched italics. Images (`![alt](url)`) are deliberately
142
+ * excluded from link detection by checking the character before `[` isn't
143
+ * `!` — without that check an image would silently become a LINK (a
144
+ * structural corruption, not a safe literal fallback); with it, the whole
145
+ * `![alt](url)` run falls through untouched as literal text, per the
146
+ * out-of-scope contract above.
147
+ *
148
+ * `\u0000` is a private sentinel `buildParagraphSource` uses to mark a
149
+ * hard line break's position — chosen because real Markdown input can't
150
+ * contain a literal NUL, so it can never collide with real content.
151
+ */
152
+ function parseInlineWithMarks(text, marks) {
153
+ const nodes = [];
154
+ let buf = "";
155
+ let i = 0;
156
+ const flush = () => {
157
+ const node = makeText(buf, marks);
158
+ if (node)
159
+ nodes.push(node);
160
+ buf = "";
161
+ };
162
+ while (i < text.length) {
163
+ const ch = text[i];
164
+ if (ch === "\u0000") {
165
+ flush();
166
+ nodes.push({ type: "hardBreak" });
167
+ i++;
168
+ continue;
169
+ }
170
+ if (ch === "\\" && i + 1 < text.length && ESCAPABLE_INLINE_CHARS.has(text[i + 1])) {
171
+ // A backslash-escaped metacharacter in the INPUT: consume both and
172
+ // emit the next char as plain literal content, never as a delimiter.
173
+ // Must be checked before every delimiter branch so e.g. `\*` can
174
+ // never be misread as an opening `*`.
175
+ buf += text[i + 1];
176
+ i += 2;
177
+ continue;
178
+ }
179
+ if (ch === "`") {
180
+ const close = text.indexOf("`", i + 1);
181
+ if (close !== -1) {
182
+ flush();
183
+ const code = text.slice(i + 1, close);
184
+ const node = makeText(code, addMark(marks, { type: "code" }));
185
+ if (node)
186
+ nodes.push(node);
187
+ i = close + 1;
188
+ continue;
189
+ }
190
+ }
191
+ if (ch === "[" && text[i - 1] !== "!") {
192
+ const closeBracket = text.indexOf("]", i + 1);
193
+ if (closeBracket !== -1 && text[closeBracket + 1] === "(") {
194
+ const closeParen = text.indexOf(")", closeBracket + 2);
195
+ // An empty `href` (`[docs]()`) is NOT a valid ADF link mark — Jira's
196
+ // ADF validator requires a non-empty URL and would 400 the whole
197
+ // request, unlike every other malformed construct here, which
198
+ // degrades to literal text. So an empty (or whitespace-only) target
199
+ // falls through to literal text too, same as an unmatched bracket.
200
+ if (closeParen !== -1 && text.slice(closeBracket + 2, closeParen).trim().length > 0) {
201
+ flush();
202
+ const linkText = text.slice(i + 1, closeBracket);
203
+ const href = text.slice(closeBracket + 2, closeParen);
204
+ nodes.push(...parseInlineWithMarks(linkText, addMark(marks, { type: "link", attrs: { href } })));
205
+ i = closeParen + 1;
206
+ continue;
207
+ }
208
+ }
209
+ }
210
+ if (text.startsWith("**", i) || text.startsWith("__", i)) {
211
+ const delim = text.slice(i, i + 2);
212
+ const close = text.indexOf(delim, i + 2);
213
+ if (close !== -1) {
214
+ flush();
215
+ nodes.push(...parseInlineWithMarks(text.slice(i + 2, close), addMark(marks, { type: "strong" })));
216
+ i = close + 2;
217
+ continue;
218
+ }
219
+ }
220
+ if (text.startsWith("~~", i)) {
221
+ const close = text.indexOf("~~", i + 2);
222
+ if (close !== -1) {
223
+ flush();
224
+ nodes.push(...parseInlineWithMarks(text.slice(i + 2, close), addMark(marks, { type: "strike" })));
225
+ i = close + 2;
226
+ continue;
227
+ }
228
+ }
229
+ if (ch === "*" || ch === "_") {
230
+ const openBefore = i > 0 ? text[i - 1] : undefined;
231
+ const openAfter = i + 1 < text.length ? text[i + 1] : undefined;
232
+ if (canOpenEmphasis(ch, openBefore, openAfter)) {
233
+ // Scan forward for the NEAREST same-character delimiter that is
234
+ // itself flanking-valid to close (not just "the next occurrence of
235
+ // the char", which is what let `*`/`_` pair across unrelated words
236
+ // — see `canOpenEmphasis`/`canCloseEmphasis` above).
237
+ let close = -1;
238
+ for (let j = i + 1; j < text.length; j++) {
239
+ if (text[j] !== ch)
240
+ continue;
241
+ if (j <= i + 1)
242
+ continue; // no empty-content emphasis
243
+ const closeBefore = text[j - 1];
244
+ const closeAfter = j + 1 < text.length ? text[j + 1] : undefined;
245
+ if (canCloseEmphasis(ch, closeBefore, closeAfter)) {
246
+ close = j;
247
+ break;
248
+ }
249
+ }
250
+ if (close !== -1) {
251
+ flush();
252
+ nodes.push(...parseInlineWithMarks(text.slice(i + 1, close), addMark(marks, { type: "em" })));
253
+ i = close + 1;
254
+ continue;
255
+ }
256
+ }
257
+ }
258
+ buf += ch;
259
+ i++;
260
+ }
261
+ flush();
262
+ return nodes;
263
+ }
264
+ /**
265
+ * Strips a hard-break marker from one raw line — either a trailing
266
+ * backslash or two-or-more trailing spaces — and reports whether one was
267
+ * found. A trailing `\\` (escaped backslash, i.e. a literal backslash the
268
+ * author meant to keep) is deliberately NOT treated as a break: only an
269
+ * ODD-length run of trailing backslashes ends in an unescaped one.
270
+ */
271
+ function stripHardBreakMarker(line) {
272
+ const backslashes = /\\+$/.exec(line);
273
+ if (backslashes && backslashes[0].length % 2 === 1) {
274
+ return { content: line.slice(0, -1), hardBreak: true };
275
+ }
276
+ const spacesMatch = / {2,}$/.exec(line);
277
+ if (spacesMatch) {
278
+ return { content: line.slice(0, line.length - spacesMatch[0].length), hardBreak: true };
279
+ }
280
+ return { content: line, hardBreak: false };
281
+ }
282
+ /**
283
+ * Two adjacent lines that both look like pipe-delimited table rows must
284
+ * NOT be soft-joined with a space — tables are out-of-scope syntax (module
285
+ * comment), and this module's contract for out-of-scope constructs is that
286
+ * they "never corrupt structure", not that they get flattened. A real
287
+ * (un-blank-line-separated) Markdown table fed through the old plain-space
288
+ * join collapsed `"| a | b |\n| - | - |\n| 1 | 2 |"` into one line —
289
+ * destroying it in both the Jira description AND the `Issue.body` later
290
+ * read back for the agent prompt — regressing the OLD `toAdf`/`adfToText`
291
+ * pair's verbatim round-trip for exactly this content. Preserving each row
292
+ * on its own visual line (via the same hardBreak sentinel a real hard
293
+ * break uses) is the best this constrained subset can do without adding a
294
+ * real ADF table node, but it keeps the rows intact and readable instead of
295
+ * mashing them together. Ordinary hard-wrapped prose (no `|`) is
296
+ * deliberately unaffected — soft-joining that IS correct Markdown
297
+ * behavior, tested elsewhere in this module.
298
+ */
299
+ function looksLikeTableRow(line) {
300
+ return line.includes("|");
301
+ }
302
+ /**
303
+ * Joins a paragraph's raw lines into one source string for
304
+ * `parseInlineWithMarks`, inserting the `\u0000` hard-break sentinel where
305
+ * a line ended with a break marker (or where both it and the next line look
306
+ * like table rows — see `looksLikeTableRow`), or a plain space otherwise —
307
+ * "otherwise consecutive non-blank lines join as one paragraph, matching
308
+ * how Markdown actually works" (a soft line break renders as a space, not
309
+ * a newline).
310
+ */
311
+ function buildParagraphSource(lines) {
312
+ const parts = [];
313
+ lines.forEach((line, idx) => {
314
+ const { content, hardBreak } = stripHardBreakMarker(line);
315
+ parts.push(content);
316
+ if (idx < lines.length - 1) {
317
+ const preserveLine = hardBreak || (looksLikeTableRow(content) && looksLikeTableRow(lines[idx + 1]));
318
+ parts.push(preserveLine ? "\u0000" : " ");
319
+ }
320
+ });
321
+ return parts.join("");
322
+ }
323
+ function makeParagraph(lines) {
324
+ return { type: "paragraph", content: parseInlineWithMarks(buildParagraphSource(lines), []) };
325
+ }
326
+ /**
327
+ * A paragraph whose text is NOT run through `parseInlineWithMarks` at
328
+ * all — for fallback cases where a line must render as truly literal text
329
+ * rather than "safe but still markdown-interpreted" text. Needed because a
330
+ * malformed construct can itself contain characters (odd backtick counts,
331
+ * in particular — an unterminated ` ``` ` fence line has three backticks,
332
+ * an odd number that the inline code-span scanner would otherwise pair up
333
+ * wrongly, e.g. into an empty code span plus a stray literal backtick)
334
+ * that the normal inline scanner would reinterpret rather than preserve.
335
+ */
336
+ function literalParagraph(line) {
337
+ const node = makeText(line, []);
338
+ return { type: "paragraph", content: node ? [node] : [] };
339
+ }
340
+ function makeHeading(level, text) {
341
+ return { type: "heading", attrs: { level }, content: parseInlineWithMarks(text, []) };
342
+ }
343
+ function makeCodeBlock(code, language) {
344
+ const node = { type: "codeBlock" };
345
+ if (language)
346
+ node["attrs"] = { language };
347
+ if (code.length > 0)
348
+ node["content"] = [{ type: "text", text: code }];
349
+ return node;
350
+ }
351
+ function makeBlockquote(lines) {
352
+ return { type: "blockquote", content: [makeParagraph(lines)] };
353
+ }
354
+ function matchBullet(line) {
355
+ const m = /^( *)([-*+]) +(.*)$/.exec(line);
356
+ return m ? { indent: m[1].length, text: m[3] } : null;
357
+ }
358
+ function matchOrdered(line) {
359
+ const m = /^( *)(\d+)\. +(.*)$/.exec(line);
360
+ return m ? { indent: m[1].length, text: m[3], start: parseInt(m[2], 10) } : null;
361
+ }
362
+ function isFenceOpen(line) {
363
+ return /^```(\S*)\s*$/.test(line);
364
+ }
365
+ function isRule(line) {
366
+ return /^(-{3,}|\*{3,}|_{3,})$/.test(line.trim());
367
+ }
368
+ function isHeading(line) {
369
+ return /^#{1,6}(\s|$)/.test(line);
370
+ }
371
+ /** Whether `line` starts a new (non-paragraph) block — used to stop paragraph-line accumulation before it swallows the next heading/list/etc. */
372
+ function isBlockStart(line) {
373
+ if (isFenceOpen(line))
374
+ return true;
375
+ if (isRule(line))
376
+ return true;
377
+ if (isHeading(line))
378
+ return true;
379
+ if (/^>/.test(line))
380
+ return true;
381
+ const b = matchBullet(line);
382
+ if (b && b.indent === 0)
383
+ return true;
384
+ const o = matchOrdered(line);
385
+ if (o && o.indent === 0)
386
+ return true;
387
+ return false;
388
+ }
389
+ /** Finds the closing ` ``` ` line at or after `from`; returns -1 (never throws/loops) if the fence is never closed. */
390
+ function findFenceClose(lines, from) {
391
+ for (let i = from; i < lines.length; i++) {
392
+ if (lines[i].trimEnd() === "```")
393
+ return i;
394
+ }
395
+ return -1;
396
+ }
397
+ /**
398
+ * Parses one list (bullet or ordered) starting at `lines[start]`, whose
399
+ * marker sits at `indent` columns. Supports exactly ONE level of nesting:
400
+ * immediately after an item's own line, if the next line is a list marker
401
+ * (either kind) indented deeper than `indent`, it's parsed as one nested
402
+ * list and attached as the item's second content block — a further-nested
403
+ * marker inside THAT recursive call would need an even deeper indent than
404
+ * the nested list's own base, which nothing here ever requests, so nesting
405
+ * naturally bottoms out at one level rather than needing an explicit depth
406
+ * check.
407
+ */
408
+ function parseList(lines, start, indent) {
409
+ const ordered = matchOrdered(lines[start]) !== null && matchBullet(lines[start]) === null;
410
+ const items = [];
411
+ let i = start;
412
+ let orderStart;
413
+ while (i < lines.length) {
414
+ const line = lines[i];
415
+ if (line.trim() === "")
416
+ break; // v1 simplification: a blank line ends the list rather than starting a "loose" list
417
+ const match = ordered ? matchOrdered(line) : matchBullet(line);
418
+ if (!match || match.indent !== indent)
419
+ break;
420
+ if (ordered && orderStart === undefined)
421
+ orderStart = matchOrdered(line)?.start;
422
+ i++;
423
+ const itemContent = [makeParagraph([match.text])];
424
+ if (i < lines.length) {
425
+ const nestedBullet = matchBullet(lines[i]);
426
+ const nestedOrdered = matchOrdered(lines[i]);
427
+ const nested = nestedBullet ?? nestedOrdered;
428
+ if (nested && nested.indent > indent) {
429
+ const sub = parseList(lines, i, nested.indent);
430
+ itemContent.push(sub.node);
431
+ i = sub.nextIndex;
432
+ }
433
+ }
434
+ items.push({ type: "listItem", content: itemContent });
435
+ }
436
+ const node = ordered
437
+ ? { type: "orderedList", ...(orderStart !== undefined && orderStart !== 1 ? { attrs: { order: orderStart } } : {}), content: items }
438
+ : { type: "bulletList", content: items };
439
+ return { node, nextIndex: i };
440
+ }
441
+ function parseBlocks(lines) {
442
+ const nodes = [];
443
+ let i = 0;
444
+ while (i < lines.length) {
445
+ const line = lines[i];
446
+ if (line.trim() === "") {
447
+ i++;
448
+ continue;
449
+ }
450
+ if (isFenceOpen(line)) {
451
+ const closeIdx = findFenceClose(lines, i + 1);
452
+ if (closeIdx !== -1) {
453
+ const lang = /^```(\S*)/.exec(line)[1];
454
+ nodes.push(makeCodeBlock(lines.slice(i + 1, closeIdx).join("\n"), lang || undefined));
455
+ i = closeIdx + 1;
456
+ continue;
457
+ }
458
+ // Unterminated fence — fall back to literal text for just this line, not the whole rest of the document.
459
+ nodes.push(literalParagraph(line));
460
+ i++;
461
+ continue;
462
+ }
463
+ if (isRule(line)) {
464
+ nodes.push({ type: "rule" });
465
+ i++;
466
+ continue;
467
+ }
468
+ const headingMatch = /^(#{1,6})(?:\s+(.*))?$/.exec(line);
469
+ if (headingMatch) {
470
+ nodes.push(makeHeading(headingMatch[1].length, (headingMatch[2] ?? "").trim()));
471
+ i++;
472
+ continue;
473
+ }
474
+ if (/^>/.test(line)) {
475
+ const quoteLines = [];
476
+ while (i < lines.length && /^>/.test(lines[i])) {
477
+ quoteLines.push(lines[i].replace(/^>\s?/, ""));
478
+ i++;
479
+ }
480
+ nodes.push(makeBlockquote(quoteLines));
481
+ continue;
482
+ }
483
+ const bullet = matchBullet(line);
484
+ const ordered = matchOrdered(line);
485
+ if ((bullet && bullet.indent === 0) || (ordered && ordered.indent === 0)) {
486
+ const { node, nextIndex } = parseList(lines, i, 0);
487
+ nodes.push(node);
488
+ i = nextIndex;
489
+ continue;
490
+ }
491
+ const paraLines = [line];
492
+ i++;
493
+ while (i < lines.length && lines[i].trim() !== "" && !isBlockStart(lines[i])) {
494
+ paraLines.push(lines[i]);
495
+ i++;
496
+ }
497
+ nodes.push(makeParagraph(paraLines));
498
+ }
499
+ return nodes;
500
+ }
501
+ /**
502
+ * Parses the constrained Markdown subset (module comment) into an ADF
503
+ * document node. Never throws: every unrecognized or malformed construct
504
+ * degrades to literal text inside a plain paragraph rather than raising,
505
+ * because this feeds `spf watch`'s unattended comment/description writes.
506
+ */
507
+ export function markdownToAdf(text) {
508
+ const lines = text.split(/\r\n|\r|\n/);
509
+ const content = parseBlocks(lines);
510
+ return { type: "doc", version: 1, content: content.length > 0 ? content : [{ type: "paragraph", content: [] }] };
511
+ }
512
+ function renderFallbackText(node) {
513
+ if (!node || typeof node !== "object")
514
+ return "";
515
+ const n = node;
516
+ if (n.type === "text" && typeof n.text === "string")
517
+ return n.text;
518
+ if (Array.isArray(n.content))
519
+ return n.content.map(renderFallbackText).join("");
520
+ return "";
521
+ }
522
+ /**
523
+ * Renders one text node with its marks back to Markdown. `code` is treated
524
+ * as exclusive of the other marks — real Jira/CommonMark editors don't let
525
+ * you combine inline code with bold/italic/strike/link either, so a text
526
+ * node built with `code` alongside another mark (reachable only via a hand-
527
+ * built ADF doc, never via this module's own `markdownToAdf`) renders as
528
+ * just the code span; that's a deliberate, documented lossy edge, not a
529
+ * bug — `adfToMarkdown(markdownToAdf(x))` re-parsed a second time still
530
+ * converges, which is the only round-trip guarantee this module promises.
531
+ *
532
+ * `em` renders as `_..._` instead of `*...*` specifically when `strong` is
533
+ * ALSO present: rendering both with `*` would emit an ambiguous triple-run
534
+ * (`***text***`), which the recursive-descent parser above cannot reliably
535
+ * re-split back into nested strong+em (a classic CommonMark delimiter-
536
+ * ambiguity case) — breaking the round-trip convergence guarantee. `_` is
537
+ * safe here specifically because it's nested inside strong's own `**`
538
+ * delimiters, so there's no surrounding word-adjacency for the intraword-
539
+ * underscore rule to reject; a standalone `em` (no `strong`) keeps using
540
+ * `*` so intraword emphasis (`foo*bar*baz`) still round-trips.
541
+ *
542
+ * Deliberately does NOT backslash-escape stray `*`/`_`/`` ` ``/`~`/`[`
543
+ * bytes in plain (unmarked) text: `adfToMarkdown` is this codebase's ONE
544
+ * ADF-to-text implementation (see `jira_provider.ts`'s module comment),
545
+ * shared with `findMarkerComment`/`readMarker`'s `[spf-watch-marker]` JSON
546
+ * payload, which is read back via a direct regex + `JSON.parse` on this
547
+ * function's output — never re-fed through `markdownToAdf`. Escaping here
548
+ * would inject literal backslashes into that JSON's underscores/brackets
549
+ * and corrupt it. The flanking-delimiter rules in `parseInlineWithMarks`
550
+ * already keep ordinary stray metacharacters in prose from being misread
551
+ * as markup on re-parse, without needing that.
552
+ */
553
+ function renderTextNode(text, marksRaw) {
554
+ const marks = marksRaw.filter((m) => Boolean(m) && typeof m === "object" && typeof m.type === "string");
555
+ const has = (t) => marks.some((m) => m.type === t);
556
+ if (has("code"))
557
+ return `\`${text}\``;
558
+ let out = text;
559
+ if (has("strike"))
560
+ out = `~~${out}~~`;
561
+ if (has("em"))
562
+ out = has("strong") ? `_${out}_` : `*${out}*`;
563
+ if (has("strong"))
564
+ out = `**${out}**`;
565
+ const link = marks.find((m) => m.type === "link");
566
+ if (link) {
567
+ const href = typeof link.attrs?.["href"] === "string" ? link.attrs["href"] : "";
568
+ out = `[${out}](${href})`;
569
+ }
570
+ return out;
571
+ }
572
+ function renderInline(nodes) {
573
+ let out = "";
574
+ for (const raw of nodes) {
575
+ if (!raw || typeof raw !== "object")
576
+ continue;
577
+ const node = raw;
578
+ if (node.type === "text" && typeof node.text === "string") {
579
+ out += renderTextNode(node.text, Array.isArray(node.marks) ? node.marks : []);
580
+ }
581
+ else if (node.type === "hardBreak") {
582
+ out += " \n"; // trailing two spaces + newline — what `stripHardBreakMarker` recognizes on re-parse
583
+ }
584
+ else {
585
+ out += renderFallbackText(node);
586
+ }
587
+ }
588
+ return out;
589
+ }
590
+ function renderCodeBlock(node) {
591
+ const language = typeof node.attrs?.language === "string" ? node.attrs.language : "";
592
+ const codeNodes = Array.isArray(node.content) ? node.content : [];
593
+ const code = codeNodes.map((n) => (n && typeof n === "object" && typeof n.text === "string" ? n.text : "")).join("");
594
+ return "```" + language + "\n" + code + "\n```";
595
+ }
596
+ function renderList(node, indent) {
597
+ const ordered = node.type === "orderedList";
598
+ const items = Array.isArray(node.content) ? node.content : [];
599
+ const orderStart = ordered && typeof node.attrs?.order === "number" ? node.attrs.order : 1;
600
+ return items
601
+ .map((rawItem, idx) => {
602
+ if (!rawItem || typeof rawItem !== "object")
603
+ return "";
604
+ const item = rawItem;
605
+ const blocks = Array.isArray(item.content) ? item.content : [];
606
+ const paragraph = blocks.find((b) => Boolean(b) && typeof b === "object" && b.type === "paragraph");
607
+ const nestedList = blocks.find((b) => Boolean(b) && typeof b === "object" && (b.type === "bulletList" || b.type === "orderedList"));
608
+ const text = paragraph ? renderInline(Array.isArray(paragraph.content) ? paragraph.content : []) : renderFallbackText(item);
609
+ const marker = ordered ? `${orderStart + idx}. ` : "- ";
610
+ let line = indent + marker + text;
611
+ if (nestedList)
612
+ line += "\n" + renderList(nestedList, indent + " ");
613
+ return line;
614
+ })
615
+ .join("\n");
616
+ }
617
+ function renderBlockquote(node) {
618
+ const blocks = Array.isArray(node.content) ? node.content : [];
619
+ const lines = [];
620
+ for (const raw of blocks) {
621
+ const text = raw && typeof raw === "object" && raw.type === "paragraph"
622
+ ? renderInline(Array.isArray(raw.content) ? raw.content : [])
623
+ : renderFallbackText(raw);
624
+ for (const l of text.split("\n"))
625
+ lines.push(`> ${l}`);
626
+ }
627
+ return lines.join("\n");
628
+ }
629
+ function clampHeadingLevel(level) {
630
+ const n = typeof level === "number" ? Math.trunc(level) : 1;
631
+ return Math.min(6, Math.max(1, n || 1));
632
+ }
633
+ /** Whether `line`, if `parseBlocks` scanned it fresh, would start a fence/rule/heading/blockquote/list rather than plain paragraph text — see `escapeParagraphLine`, the only caller. */
634
+ function looksLikeBlockStart(line) {
635
+ return isFenceOpen(line) || isRule(line) || isHeading(line) || /^>/.test(line) || matchBullet(line) !== null || matchOrdered(line) !== null;
636
+ }
637
+ /**
638
+ * Guards one line of a RENDERED paragraph against being misread as a
639
+ * different block type on the next `markdownToAdf` pass. `parseBlocks`
640
+ * treats ANY line of an in-progress paragraph — not just a block's first —
641
+ * that matches a fence/rule/heading/blockquote/list pattern as ending
642
+ * paragraph accumulation right there (see its continuation-line `while`
643
+ * loop). That means two lines that were never a list in the source — a
644
+ * bare `-` alone on one line, soft-joined with the next line's text into
645
+ * one rendered `"- word"` line — silently become a real bulletList the
646
+ * next time this text is parsed, which is exactly the kind of "out-of-
647
+ * scope constructs never corrupt structure" violation this module's own
648
+ * doc comment rules out. Escaping just the line's first character is
649
+ * enough to defeat every one of those patterns, all of which anchor a
650
+ * specific character at column 0; `ESCAPABLE_INLINE_CHARS` is what lets
651
+ * `markdownToAdf` unescape it back to the original literal character.
652
+ */
653
+ function escapeParagraphLine(line) {
654
+ return looksLikeBlockStart(line) ? `\\${line}` : line;
655
+ }
656
+ function renderBlock(raw) {
657
+ if (!raw || typeof raw !== "object")
658
+ return undefined;
659
+ const node = raw;
660
+ switch (node.type) {
661
+ case "paragraph":
662
+ return renderInline(Array.isArray(node.content) ? node.content : [])
663
+ .split("\n")
664
+ .map(escapeParagraphLine)
665
+ .join("\n");
666
+ case "heading": {
667
+ const level = clampHeadingLevel(node.attrs?.level);
668
+ const text = renderInline(Array.isArray(node.content) ? node.content : []);
669
+ return text ? `${"#".repeat(level)} ${text}` : "#".repeat(level);
670
+ }
671
+ case "codeBlock":
672
+ return renderCodeBlock(node);
673
+ case "bulletList":
674
+ case "orderedList":
675
+ return renderList(node, "");
676
+ case "blockquote":
677
+ return renderBlockquote(node);
678
+ case "rule":
679
+ return "---";
680
+ default: {
681
+ const fallback = renderFallbackText(node);
682
+ return fallback.length > 0 ? fallback : undefined;
683
+ }
684
+ }
685
+ }
686
+ /**
687
+ * The inverse of `markdownToAdf`: renders an ADF document back into the
688
+ * same Markdown subset. NOT required to byte-match the original input
689
+ * (canonical forms are fine — e.g. always `-` for bullets even if the
690
+ * source used `*`), only to be semantically recognizable and to converge
691
+ * after one more round trip through `markdownToAdf`. Handles ADF built by
692
+ * this module OR hand-built elsewhere (e.g. Jira's own rich-text editor):
693
+ * any node or mark type it doesn't recognize degrades to its nested text
694
+ * content via `renderFallbackText` rather than throwing or dropping it.
695
+ */
696
+ export function adfToMarkdown(adf) {
697
+ if (!adf || typeof adf !== "object")
698
+ return "";
699
+ const doc = adf;
700
+ const content = Array.isArray(doc.content) ? doc.content : [];
701
+ return content
702
+ .map((n) => renderBlock(n))
703
+ .filter((s) => s !== undefined)
704
+ .join("\n\n");
705
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gr8ful/spf",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Super Portable Factory — a global CLI for repeatable agents-plus-code workflows (ADWs)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -42,8 +42,8 @@
42
42
  },
43
43
  "dependencies": {
44
44
  "@earendil-works/pi-ai": "0.83.0",
45
- "@flue/opentelemetry": "2.0.4",
46
- "@flue/runtime": "2.0.3",
45
+ "@flue/opentelemetry": "2.0.5",
46
+ "@flue/runtime": "2.0.5",
47
47
  "@hono/node-server": "^2.1.1",
48
48
  "@inkjs/ui": "^2.0.0",
49
49
  "@opentelemetry/api": "1.9.1",