@gr8ful/spf 0.18.0 → 0.19.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.
Files changed (50) hide show
  1. package/README.md +8 -0
  2. package/assets/skill/references/config.md +1 -0
  3. package/dist/cli/commands/doctor.js +25 -3
  4. package/dist/cli/commands/estimate.d.ts +22 -6
  5. package/dist/cli/commands/estimate.js +32 -10
  6. package/dist/cli/commands/loop.d.ts +20 -0
  7. package/dist/cli/commands/loop.js +20 -1
  8. package/dist/cli/commands/ui.js +2 -1
  9. package/dist/cli/commands/watch.js +1 -1
  10. package/dist/cli/index.js +2 -2
  11. package/dist/cli/interview.js +13 -0
  12. package/dist/cli/ui/run_dashboard.js +13 -7
  13. package/dist/core/agent_cc.d.ts +19 -3
  14. package/dist/core/agent_cc.js +38 -18
  15. package/dist/core/agent_flue.js +51 -14
  16. package/dist/core/agent_opencode.d.ts +62 -25
  17. package/dist/core/agent_opencode.js +71 -30
  18. package/dist/core/agents.d.ts +51 -4
  19. package/dist/core/agents.js +79 -4
  20. package/dist/core/console.d.ts +24 -4
  21. package/dist/core/console.js +20 -7
  22. package/dist/core/data_types.d.ts +300 -19
  23. package/dist/core/data_types.js +134 -5
  24. package/dist/core/issues/jira_provider.d.ts +58 -3
  25. package/dist/core/issues/jira_provider.js +89 -18
  26. package/dist/core/issues/markdown_adf.d.ts +58 -0
  27. package/dist/core/issues/markdown_adf.js +705 -0
  28. package/dist/core/issues/provider.d.ts +23 -0
  29. package/dist/core/loop.d.ts +39 -1
  30. package/dist/core/loop.js +33 -2
  31. package/dist/core/ollama_provider.d.ts +96 -13
  32. package/dist/core/ollama_provider.js +172 -26
  33. package/dist/core/otel.js +10 -1
  34. package/dist/core/otel_propagation.d.ts +168 -24
  35. package/dist/core/otel_propagation.js +219 -43
  36. package/dist/core/permissions.d.ts +16 -1
  37. package/dist/core/permissions.js +91 -3
  38. package/dist/core/providers.js +8 -3
  39. package/dist/core/refine.js +13 -1
  40. package/dist/core/runner.d.ts +33 -2
  41. package/dist/core/runner.js +40 -5
  42. package/dist/core/tiering.js +7 -3
  43. package/dist/core/tracer.d.ts +7 -1
  44. package/dist/core/tracer.js +15 -3
  45. package/dist/ui/server/db.d.ts +8 -1
  46. package/dist/ui/server/db.js +21 -4
  47. package/dist/ui/server/serve.d.ts +7 -0
  48. package/dist/ui/server/serve.js +10 -7
  49. package/dist/ui/shared/types.d.ts +16 -0
  50. package/package.json +3 -3
@@ -528,6 +528,46 @@ export const ConfigDefaultsSchema = v.object({
528
528
  // the machinery that decides whether its work passed.
529
529
  // .spf/ is the whole per-repo footprint now — no adws/ tree to protect.
530
530
  protected_files: v.optional(v.array(v.string()), () => [".spf/", "spf.config.yaml"]),
531
+ /**
532
+ * Paths a READ-ONLY (or write-restricted) agent may touch WITHOUT failing
533
+ * the phase — `core/permissions.ts`'s `enforce()` still rolls every one of
534
+ * them back (an agent's claimed report must never rest on a change that
535
+ * didn't survive), it just does not count that rollback as a breach.
536
+ *
537
+ * WHY THIS EXISTS: a lockfile is dependency-manager BOOKKEEPING, not the
538
+ * repo's intent — an agent that ran `npm install` (to read a package's
539
+ * real shape, say) rewrites `package-lock.json` as a side effect of a
540
+ * read, not an edit. Observed live: a read-only scout phase failed with
541
+ * "scout is read-only but modified 1 path(s): factory/content/
542
+ * package-lock.json — rolled back" over exactly this, for work that
543
+ * changed nothing an operator would call "the code."
544
+ *
545
+ * The four defaults are the lockfiles of every package manager this repo
546
+ * already builds against (npm, pnpm, yarn, bun) — additive, not
547
+ * exhaustive; a repo using another one adds its own pattern here. Same
548
+ * glob syntax as `protected_files`/`agents[].writes` (`permissions.ts`'s
549
+ * `globToRegex`), where a leading "**" followed by a path separator
550
+ * matches at any depth INCLUDING the repo root — so the packaged
551
+ * defaults below match a lockfile whether it sits at the top of the repo
552
+ * or nested under a subdirectory.
553
+ *
554
+ * Emptying this list (`read_only_ignore: []`) restores today's strict
555
+ * behavior exactly — every touched path outside an agent's own allowlist
556
+ * fails the phase, lockfiles included.
557
+ *
558
+ * PRECEDENCE: `protected_files` always wins. A path matching
559
+ * `protected_files` is never ignorable via `read_only_ignore`, no matter
560
+ * how narrowly write-restricted the agent is — a pattern here that
561
+ * happens to also match a protected path is not read as "exempt this
562
+ * from protected_files too"; it stays a real breach. See
563
+ * `permissions.ts`'s `isSafeToIgnore` for the enforcement.
564
+ */
565
+ read_only_ignore: v.optional(v.array(v.string()), () => [
566
+ "**/package-lock.json",
567
+ "**/pnpm-lock.yaml",
568
+ "**/yarn.lock",
569
+ "**/bun.lockb",
570
+ ]),
531
571
  data_dir: v.optional(v.string(), ".spf/data"),
532
572
  /**
533
573
  * RUN BUDGET CEILINGS — the two knobs that bound what one adw_id may spend.
@@ -550,11 +590,20 @@ export const ConfigDefaultsSchema = v.object({
550
590
  * `agents.ts`'s `BudgetExceeded`.
551
591
  *
552
592
  * `max_run_cost` is USD (the same unit the provider's own usage.cost
553
- * arrives in, summed by `UsageBreakdown`); `max_run_tokens` is TOTAL
554
- * tokens, i.e. the spend number — every turn re-sends the whole
555
- * conversation, so this counts cached re-reads too, exactly like the
556
- * `total_tokens` column in `sessions` (see `ui/server/db.ts`'s `usage()`
557
- * for why that number is much larger than "material moved").
593
+ * arrives in, summed by `UsageBreakdown`); `max_run_tokens` is BILLABLE
594
+ * tokens — `UsageBreakdown.billable_tokens` (input + cache-write + output),
595
+ * checked against `Run.billable_tokens`, NOT the `total_tokens` column
596
+ * `sessions` also carries for display. A prompt-caching backend (Ollama
597
+ * Cloud's kimi models, Anthropic's own caching) re-sends the whole
598
+ * conversation every turn as CACHE READS, which `total_tokens` counts and
599
+ * this ceiling does not: cache reads are billed (when billed at all) at a
600
+ * small fraction of input price, sometimes free, so a ceiling measured
601
+ * against the bigger number trips on bulk that cost nothing — observed
602
+ * live, one scout phase alone reported 1,311,740 total_tokens against a
603
+ * gateway that billed 189,321 uncached input + 17,908 output for it.
604
+ * `total_tokens` is kept exactly as before for anything display-only
605
+ * (the sessions-panel "tokens" line, the UI) — only the budget check
606
+ * changed which number it reads.
558
607
  *
559
608
  * Both are `> 0`, not `>= 0`: a zero ceiling would mean "no agent may ever
560
609
  * run", which is a config mistake, not a budget — it would fail the first
@@ -573,6 +622,43 @@ export const ConfigDefaultsSchema = v.object({
573
622
  */
574
623
  max_run_cost: v.optional(v.pipe(v.number(), v.gtValue(0))),
575
624
  max_run_tokens: v.optional(v.pipe(v.number(), v.integer(), v.gtValue(0))),
625
+ /**
626
+ * REQUEST TIMEOUT — how long a single agent dispatch may run before it is
627
+ * aborted and settled as failed, rather than hanging on a connection that
628
+ * silently died mid-call with nothing to notice.
629
+ *
630
+ * FLUE-SPECIFIC, unlike every other key in this schema: it maps straight
631
+ * onto `AgentStatics.durability.timeoutMs` (see `@flue/runtime`'s own
632
+ * docs), a `flue`-backend-only mechanism. `claude_code`/`opencode` are
633
+ * subprocess backends with no such knob today — this field is silently
634
+ * ignored for them, the same way `flue_db_path` on `AgentRequest` already
635
+ * is. Not a bug to fix here: a subprocess backend needs its own separate
636
+ * process-level timeout story, which is out of scope for this key.
637
+ *
638
+ * ABSENT BY DEFAULT, and absence is a total no-op: Flue's own default
639
+ * applies unchanged (1 hour, 10 attempts) — the same "surprise mid-run
640
+ * failure on a ceiling nobody chose is worse than the spend" reasoning as
641
+ * `max_run_cost`/`max_run_tokens` above. Set this when a hung connection
642
+ * should surface as an attributable failure (and feed the normal
643
+ * gate-correction / `spf watch` retry loop) in minutes, not however long
644
+ * Flue's own default takes — e.g. `300_000` for a five-minute ceiling.
645
+ * NOT a precise deadline, though: manual verification against a socket
646
+ * that accepts a connection and then sends nothing (see
647
+ * `request_timeout.test.ts`'s header comment) saw Flue's own timeout check
648
+ * fire on a coarser periodic sweep — a 3s ceiling settled at ~15s, not 3s.
649
+ * Bounded-but-imprecise is still a firm improvement over unbounded.
650
+ *
651
+ * SCOPE IS ONE SUBMISSION (one agent dispatch — the first prompt, one
652
+ * JSON-repair retry, one gate correction), NOT the accumulated run, unlike
653
+ * `max_run_cost`/`max_run_tokens` above. It is still process-scoped, not
654
+ * per-agent: Flue's `durability` is a static on the single shared agent
655
+ * function `agent_flue.ts` dispatches everything through, set once before
656
+ * the first dispatch of the process — see that file's `ensureRuntime()`.
657
+ * Deliberately NOT in `loadConfig`'s per-agent back-fill list for the same
658
+ * reason `max_run_cost`/`max_run_tokens` aren't: a per-agent copy would
659
+ * read as "this agent gets its own timeout", which nothing enforces.
660
+ */
661
+ request_timeout_ms: v.optional(v.pipe(v.number(), v.integer(), v.gtValue(0))),
576
662
  });
577
663
  /**
578
664
  * OpenTelemetry span export — OFF unless this block exists, and `endpoint` is
@@ -869,6 +955,30 @@ export const WatchJiraConfigSchema = v.object({
869
955
  project_key: v.optional(v.string(), ""), // e.g. "PROJ"
870
956
  issue_types: v.optional(JiraIssueTypeMapSchema, () => v.parse(JiraIssueTypeMapSchema, {})),
871
957
  status_map: v.optional(JiraStatusMapSchema, () => v.parse(JiraStatusMapSchema, {})),
958
+ /**
959
+ * The Jira issue-link `type` name `refine.ts`'s `publish()` uses to
960
+ * connect a freshly-published tree's ROOT issue(s) back to the spec they
961
+ * were refined from (`JiraProvider.linkToSpec`) — a plain, symmetric
962
+ * "issue link" (Jira's generic relate-two-issues mechanism), never the
963
+ * hierarchical `parent` field `linkChild` sets: the spec's own issue type
964
+ * defaults to Story (`issue_types.spec`), and a root node is often an
965
+ * Epic/Task — Jira's issue-type hierarchy frequently refuses a Story as
966
+ * one of those types' PARENT, so the hierarchy field is not a safe choice
967
+ * here regardless of which type actually published. "Relates" is a
968
+ * built-in link type on every Jira Cloud project; override this only if a
969
+ * project's admin has renamed or restricted it.
970
+ *
971
+ * MUST NAME A SYMMETRIC LINK TYPE. `JiraProvider.linkToSpec` fixes which
972
+ * side is `inwardIssue`/`outwardIssue` (the published root is always
973
+ * inward, the spec always outward) and does not expose direction as a
974
+ * separate knob — harmless for a symmetric type like "Relates" (Jira's UI
975
+ * does not even surface a direction for one), but pointing this at a
976
+ * DIRECTIONAL type (e.g. "blocks"/"is blocked by") would silently record
977
+ * the opposite relationship from the one intended. Only rename this to
978
+ * another symmetric type; a directional one needs code changes, not just
979
+ * config.
980
+ */
981
+ link_type: v.optional(v.string(), "Relates"),
872
982
  });
873
983
  /**
874
984
  * Optional, per-repo `WatchState` -> GitHub Projects v2 "Status" option-name
@@ -1230,6 +1340,24 @@ export class UsageBreakdown {
1230
1340
  // at the output rate. Report it nested under output, never added to it.
1231
1341
  reasoning_tokens = 0;
1232
1342
  total_tokens = 0;
1343
+ /**
1344
+ * The SPEND number, as distinct from `total_tokens` (the SIZE number).
1345
+ * `input_tokens + output_tokens + cache_write_tokens` — `cache_read_tokens`
1346
+ * excluded on purpose: a cache read is Anthropic's own prompt-caching
1347
+ * discount (billed at a small fraction of the input rate, sometimes free
1348
+ * on some gateways) for context the conversation already sent, not new
1349
+ * material moved. `total_tokens` re-sends (and re-counts) the whole
1350
+ * conversation every turn, so a long-running scout/build session's cache
1351
+ * reads dwarf everything else in it (observed live: 1.31M total_tokens in
1352
+ * one phase, of which 1.15M were cache reads the gateway did not bill as
1353
+ * input) — a run-budget ceiling measured against `total_tokens` trips on
1354
+ * cache-driven bulk that cost nothing, not on real spend. `assertRunBudget`
1355
+ * (`agents.ts`) checks THIS field against `defaults.max_run_tokens`;
1356
+ * `total_tokens` is kept, unchanged, for display (the sessions-panel
1357
+ * "tokens" line, the UI) because an operator sizing context occupancy
1358
+ * still needs the real re-send count, not the billable one.
1359
+ */
1360
+ billable_tokens = 0;
1233
1361
  input_cost = 0.0;
1234
1362
  output_cost = 0.0;
1235
1363
  cache_read_cost = 0.0;
@@ -1249,6 +1377,7 @@ export class UsageBreakdown {
1249
1377
  this.cache_write_tokens += usage.cacheWrite || 0;
1250
1378
  this.reasoning_tokens += usage.reasoning || 0;
1251
1379
  this.total_tokens += totalTokens;
1380
+ this.billable_tokens += (usage.input || 0) + (usage.output || 0) + (usage.cacheWrite || 0);
1252
1381
  this.input_cost += cost.input || 0.0;
1253
1382
  this.output_cost += cost.output || 0.0;
1254
1383
  this.cache_read_cost += cost.cacheRead || 0.0;
@@ -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
@@ -66,6 +71,15 @@
66
71
  * `feature` (both mapping to Jira's Epic type by default) surfaces a real
67
72
  * Jira API error at publish time — a genuine platform difference, not
68
73
  * something this file tries to paper over.
74
+ *
75
+ * `linkToSpec` is the OTHER half of `IssueAuthoringProvider`'s hierarchy —
76
+ * a published tree's ROOT connected back to the spec it was refined FROM,
77
+ * which `linkChild` cannot express (the spec's own issue type, a Story by
78
+ * default, frequently cannot legally PARENT a root node's type). Uses
79
+ * Jira's plain issue-link API instead (`type` configurable via
80
+ * `watch.jira.link_type`, "Relates" by default), with a plain comment as
81
+ * its own fallback if that API is unavailable — see the method's own doc
82
+ * comment.
69
83
  */
70
84
  import type { JiraIssueTypeMap, JiraStatusMap } from "../data_types.ts";
71
85
  import type { EnsureLabelsResult, Issue, IssueAuthoringKind, IssueAuthoringProvider, IssueComment, IssueProvider, WatchMarker, WatchState } from "./provider.ts";
@@ -77,8 +91,9 @@ export declare class JiraProvider implements IssueProvider, IssueAuthoringProvid
77
91
  private readonly apiToken;
78
92
  private readonly issueTypes;
79
93
  private readonly statusMap;
94
+ private readonly linkType;
80
95
  constructor(baseUrl: string, // e.g. "https://your-domain.atlassian.net", no trailing slash
81
- projectKey: string, labelPrefix: string, email: string, apiToken: string, issueTypes: JiraIssueTypeMap, statusMap?: JiraStatusMap);
96
+ projectKey: string, labelPrefix: string, email: string, apiToken: string, issueTypes: JiraIssueTypeMap, statusMap?: JiraStatusMap, linkType?: string);
82
97
  private authHeader;
83
98
  private jira;
84
99
  private label;
@@ -129,6 +144,46 @@ export declare class JiraProvider implements IssueProvider, IssueAuthoringProvid
129
144
  * Epic-under-Epic limitation this implies.
130
145
  */
131
146
  linkChild(parent: Issue, child: Issue): Promise<void>;
147
+ /**
148
+ * `IssueAuthoringProvider.linkToSpec` — see its own doc comment
149
+ * (`issues/provider.ts`) for why this is a plain Jira "issue link"
150
+ * (`/rest/api/3/issueLink`) rather than `linkChild`'s hierarchy `parent`
151
+ * field: the spec's issue type (Story by default) frequently cannot
152
+ * legally PARENT a root node's type under Jira's issue-type hierarchy,
153
+ * while a generic issue link has no such restriction.
154
+ *
155
+ * `inwardIssue`/`outwardIssue` direction is arbitrary for a symmetric
156
+ * type like "Relates" — Jira does not surface it differently in the UI —
157
+ * so `issue` (the freshly published root) is the inward side and the
158
+ * spec is the outward side, consistently.
159
+ *
160
+ * BEST-EFFORT: this project's Jira instance may not have `linkType`
161
+ * enabled/named exactly this way (a renamed or removed link type, a
162
+ * permission scheme that disallows issue links for this project, ...) —
163
+ * a failure here must never fail the whole publish over what is, at
164
+ * bottom, a cosmetic cross-reference. Falls back to a plain comment
165
+ * naming the spec, so the relationship is visible SOMEWHERE even when
166
+ * the link API itself is unavailable.
167
+ *
168
+ * The fallback comment gets the SAME "never fail publish() over this"
169
+ * treatment as the link call itself: a rate limit or network blip on the
170
+ * comment call is just as cosmetic a failure as one on the link call, so
171
+ * it is caught and logged here rather than left to propagate out of
172
+ * `publish()` (`refine.ts`), which awaits this unguarded.
173
+ *
174
+ * `this.linkType` MUST name a SYMMETRIC link type ("Relates" and its
175
+ * project-renamed equivalents) — `inwardIssue`/`outwardIssue` above are
176
+ * fixed (the published root is always inward, the spec always outward)
177
+ * and not independently configurable, which is fine for a symmetric type
178
+ * (Jira does not surface the direction differently in the UI) but WRONG
179
+ * for a directional one (e.g. "blocks"/"is blocked by"): configuring
180
+ * `watch.jira.link_type` to a directional type would silently assert the
181
+ * opposite relationship from the one intended. See that field's own doc
182
+ * comment (`data_types.ts`) — direction is not exposed as a separate knob
183
+ * on purpose, to avoid a second config field only meaningful alongside a
184
+ * link type most projects never change from the "Relates" default.
185
+ */
186
+ linkToSpec(specId: string, issue: Issue): Promise<void>;
132
187
  /** The read-back half of `linkChild` — same JQL-in-body pattern as `searchByLabel`, since a GET with query params silently returns nothing on this endpoint (see the module comment). What makes container roll-up (`rollUp` in `watch.ts`) work on Jira too. */
133
188
  listChildren(parent: Issue): Promise<Issue[]>;
134
189
  /**
@@ -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;
@@ -53,8 +56,12 @@ export class JiraProvider {
53
56
  apiToken;
54
57
  issueTypes;
55
58
  statusMap;
59
+ linkType;
56
60
  constructor(baseUrl, // e.g. "https://your-domain.atlassian.net", no trailing slash
57
- projectKey, labelPrefix, email, apiToken, issueTypes, statusMap = {}) {
61
+ projectKey, labelPrefix, email, apiToken, issueTypes, statusMap = {},
62
+ // See `WatchJiraConfigSchema.link_type`'s own doc comment (`data_types.ts`)
63
+ // — the issue-link `type` name `linkToSpec` below creates.
64
+ linkType = "Relates") {
58
65
  this.baseUrl = baseUrl;
59
66
  this.projectKey = projectKey;
60
67
  this.labelPrefix = labelPrefix;
@@ -62,6 +69,7 @@ export class JiraProvider {
62
69
  this.apiToken = apiToken;
63
70
  this.issueTypes = issueTypes;
64
71
  this.statusMap = statusMap;
72
+ this.linkType = linkType;
65
73
  }
66
74
  authHeader() {
67
75
  return `Basic ${Buffer.from(`${this.email}:${this.apiToken}`).toString("base64")}`;
@@ -101,7 +109,7 @@ export class JiraProvider {
101
109
  return {
102
110
  id: raw.key,
103
111
  title: raw.fields.summary,
104
- body: raw.fields.description ? adfToText(raw.fields.description) : "",
112
+ body: raw.fields.description ? adfToMarkdown(raw.fields.description) : "",
105
113
  labels: raw.fields.labels,
106
114
  };
107
115
  }
@@ -163,7 +171,7 @@ export class JiraProvider {
163
171
  fields: {
164
172
  project: { key: this.projectKey },
165
173
  summary: input.title,
166
- description: toAdf(input.body),
174
+ description: markdownToAdf(input.body),
167
175
  issuetype: { name: this.issueTypes[input.kind] },
168
176
  labels: input.labels,
169
177
  },
@@ -182,6 +190,69 @@ export class JiraProvider {
182
190
  async linkChild(parent, child) {
183
191
  await this.jira(`/rest/api/3/issue/${child.id}`, { method: "PUT", body: JSON.stringify({ fields: { parent: { key: parent.id } } }) });
184
192
  }
193
+ /**
194
+ * `IssueAuthoringProvider.linkToSpec` — see its own doc comment
195
+ * (`issues/provider.ts`) for why this is a plain Jira "issue link"
196
+ * (`/rest/api/3/issueLink`) rather than `linkChild`'s hierarchy `parent`
197
+ * field: the spec's issue type (Story by default) frequently cannot
198
+ * legally PARENT a root node's type under Jira's issue-type hierarchy,
199
+ * while a generic issue link has no such restriction.
200
+ *
201
+ * `inwardIssue`/`outwardIssue` direction is arbitrary for a symmetric
202
+ * type like "Relates" — Jira does not surface it differently in the UI —
203
+ * so `issue` (the freshly published root) is the inward side and the
204
+ * spec is the outward side, consistently.
205
+ *
206
+ * BEST-EFFORT: this project's Jira instance may not have `linkType`
207
+ * enabled/named exactly this way (a renamed or removed link type, a
208
+ * permission scheme that disallows issue links for this project, ...) —
209
+ * a failure here must never fail the whole publish over what is, at
210
+ * bottom, a cosmetic cross-reference. Falls back to a plain comment
211
+ * naming the spec, so the relationship is visible SOMEWHERE even when
212
+ * the link API itself is unavailable.
213
+ *
214
+ * The fallback comment gets the SAME "never fail publish() over this"
215
+ * treatment as the link call itself: a rate limit or network blip on the
216
+ * comment call is just as cosmetic a failure as one on the link call, so
217
+ * it is caught and logged here rather than left to propagate out of
218
+ * `publish()` (`refine.ts`), which awaits this unguarded.
219
+ *
220
+ * `this.linkType` MUST name a SYMMETRIC link type ("Relates" and its
221
+ * project-renamed equivalents) — `inwardIssue`/`outwardIssue` above are
222
+ * fixed (the published root is always inward, the spec always outward)
223
+ * and not independently configurable, which is fine for a symmetric type
224
+ * (Jira does not surface the direction differently in the UI) but WRONG
225
+ * for a directional one (e.g. "blocks"/"is blocked by"): configuring
226
+ * `watch.jira.link_type` to a directional type would silently assert the
227
+ * opposite relationship from the one intended. See that field's own doc
228
+ * comment (`data_types.ts`) — direction is not exposed as a separate knob
229
+ * on purpose, to avoid a second config field only meaningful alongside a
230
+ * link type most projects never change from the "Relates" default.
231
+ */
232
+ async linkToSpec(specId, issue) {
233
+ try {
234
+ await this.jira("/rest/api/3/issueLink", {
235
+ method: "POST",
236
+ body: JSON.stringify({
237
+ type: { name: this.linkType },
238
+ inwardIssue: { key: issue.id },
239
+ outwardIssue: { key: specId },
240
+ }),
241
+ });
242
+ }
243
+ catch (linkError) {
244
+ try {
245
+ await this.comment(issue, `Refined from ${specId}.`);
246
+ }
247
+ catch (commentError) {
248
+ // Both the issue-link API and the comment fallback failed — the
249
+ // spec/root relationship is not recorded ANYWHERE on Jira this run,
250
+ // but that is still a cosmetic loss, not a reason to fail the whole
251
+ // publish (see this method's own doc comment).
252
+ console.error(`spf watch: ${issue.id} — could not link to spec ${specId} (${linkError instanceof Error ? linkError.message : String(linkError)}) and the fallback comment also failed — ${commentError instanceof Error ? commentError.message : String(commentError)}`);
253
+ }
254
+ }
255
+ }
185
256
  /** The read-back half of `linkChild` — same JQL-in-body pattern as `searchByLabel`, since a GET with query params silently returns nothing on this endpoint (see the module comment). What makes container roll-up (`rollUp` in `watch.ts`) work on Jira too. */
186
257
  async listChildren(parent) {
187
258
  const jql = `parent = ${JSON.stringify(parent.id)}`;
@@ -296,7 +367,7 @@ export class JiraProvider {
296
367
  .map(([state, jiraStatus]) => ({ state, jiraStatus, exists: available.has(jiraStatus) }));
297
368
  }
298
369
  async comment(issue, body) {
299
- await this.jira(`/rest/api/3/issue/${issue.id}/comment`, { method: "POST", body: JSON.stringify({ body: toAdf(body) }) });
370
+ await this.jira(`/rest/api/3/issue/${issue.id}/comment`, { method: "POST", body: JSON.stringify({ body: markdownToAdf(body) }) });
300
371
  }
301
372
  /** The single fetch every comment-reading method (`findMarkerComment`, `listComments`) builds on. */
302
373
  async fetchComments(issueId) {
@@ -307,7 +378,7 @@ export class JiraProvider {
307
378
  const comments = await this.fetchComments(issueId);
308
379
  let found = null;
309
380
  for (const c of comments) {
310
- const match = MARKER_RE.exec(adfToText(c.body));
381
+ const match = MARKER_RE.exec(adfToMarkdown(c.body));
311
382
  if (!match)
312
383
  continue;
313
384
  try {
@@ -323,8 +394,8 @@ export class JiraProvider {
323
394
  async listComments(issue) {
324
395
  const comments = await this.fetchComments(issue.id);
325
396
  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) }));
397
+ .filter((c) => !MARKER_RE.test(adfToMarkdown(c.body)))
398
+ .map((c) => ({ id: c.id, author: c.author?.displayName ?? "unknown", created_at: c.created, body: adfToMarkdown(c.body) }));
328
399
  }
329
400
  async readMarker(issue) {
330
401
  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;