pi-do-always 0.13.0 → 0.16.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.
@@ -67,6 +67,25 @@ export interface DoAlwaysTask {
67
67
  * configs that predate this field keep working.
68
68
  */
69
69
  browser?: BrowserType;
70
+ /**
71
+ * Whether the plan questionnaire is offered after this task's run: the
72
+ * reply's "plan" block (tiers + action items) becomes a selectable list
73
+ * the user confirms or withdraws. Default true (the global config
74
+ * "questionnaire" sets the fallback); set false to keep the plain summary
75
+ * notification. Only meaningful for auto-run tasks (Plan category by
76
+ * default).
77
+ */
78
+ questionnaire?: boolean;
79
+ /**
80
+ * Whether the raw "plan" block is hidden from the transcript after this
81
+ * task's run: the fenced block is stripped from the finalized reply in
82
+ * the TUI (the questionnaire still parses the captured raw text; in
83
+ * non-TUI modes the block is always kept). Default true (the global
84
+ * config "hidePlan" sets the fallback); set false to keep the block
85
+ * visible in the transcript. Only meaningful for auto-run tasks (Plan
86
+ * category by default).
87
+ */
88
+ hidePlan?: boolean;
70
89
  }
71
90
 
72
91
  /** The set of known guard types (used for validation at parse time). */
@@ -115,6 +134,19 @@ type DoAlwaysConfig =
115
134
  * the project root). Default true; set false to disable.
116
135
  */
117
136
  report?: boolean;
137
+ /**
138
+ * Whether completed auto-run tasks whose reply carries a "plan"
139
+ * block offer the selection questionnaire. Default true; set false
140
+ * to keep the plain summary notification.
141
+ */
142
+ questionnaire?: boolean;
143
+ /**
144
+ * Whether the raw "plan" block is stripped from the transcript
145
+ * after a completed auto-run task (TUI only — in non-TUI modes
146
+ * the block is always kept). Default true; set false to keep the
147
+ * block visible in the transcript.
148
+ */
149
+ hidePlan?: boolean;
118
150
  };
119
151
 
120
152
  /** Shortcut used when neither config file specifies one. */
@@ -138,6 +170,18 @@ interface ParsedDoAlwaysConfig {
138
170
  * report file. undefined when the file does not set one (default: on).
139
171
  */
140
172
  report: boolean | undefined;
173
+ /**
174
+ * The `questionnaire` field, if present: whether completed auto-run
175
+ * tasks whose reply carries a plan block offer the selection
176
+ * questionnaire. undefined when the file does not set one (default: on).
177
+ */
178
+ questionnaire: boolean | undefined;
179
+ /**
180
+ * The `hidePlan` field, if present: whether the raw plan block is
181
+ * stripped from the transcript after a completed auto-run task.
182
+ * undefined when the file does not set one (default: on).
183
+ */
184
+ hidePlan: boolean | undefined;
141
185
  }
142
186
 
143
187
  import { existsSync } from "node:fs";
@@ -418,7 +462,7 @@ export const DEFAULT_TASKS: DoAlwaysTask[] = [
418
462
  description: "Prepare a release (version, changelog, tag)",
419
463
  when: "git",
420
464
  prompt:
421
- "Prepare a release for this project (branch {{branch}}): check `git log` since the last tag, update the version in package.json (or the equivalent location), add a changelog entry summarizing the changes, and create a git tag if git present. Do not push.",
465
+ "Prepare a release for this project (branch {{branch}}): check `git log` since the last tag, then bump the version in package.json (or the equivalent location) to the next version, and add a changelog entry under that exact version summarizing the changes. The changelog entry must use the same version number now set in package.json — never add a changelog section for a version that package.json does not yet contain, and never leave an 'Unreleased' or placeholder version heading. Create a git tag if git present. Do not push.",
422
466
  },
423
467
  {
424
468
  name: "Commit",
@@ -474,14 +518,14 @@ export function parseConfig(
474
518
  data = JSON.parse(raw);
475
519
  } catch (err) {
476
520
  onError(`do-always: invalid JSON in ${path}: ${err}`);
477
- return { tasks: [], shortcut: undefined, report: undefined };
521
+ return { tasks: [], shortcut: undefined, report: undefined, questionnaire: undefined, hidePlan: undefined };
478
522
  }
479
523
 
480
524
  const list = Array.isArray(data) ? data : data?.tasks;
481
525
 
482
526
  if (!Array.isArray(list)) {
483
527
  onError(`do-always: ${path} must be a JSON array of tasks or {"tasks": [...]}`);
484
- return { tasks: [], shortcut: undefined, report: undefined };
528
+ return { tasks: [], shortcut: undefined, report: undefined, questionnaire: undefined, hidePlan: undefined };
485
529
  }
486
530
 
487
531
  const tasks: DoAlwaysTask[] = [];
@@ -498,6 +542,8 @@ export function parseConfig(
498
542
  if (typeof t.requireDirty === "boolean") task.requireDirty = t.requireDirty;
499
543
  if (typeof t.hidden === "boolean") task.hidden = t.hidden;
500
544
  if (typeof t.notForCommits === "boolean") task.notForCommits = t.notForCommits;
545
+ if (typeof t.questionnaire === "boolean") task.questionnaire = t.questionnaire;
546
+ if (typeof t.hidePlan === "boolean") task.hidePlan = t.hidePlan;
501
547
  if (t.browser !== undefined) {
502
548
  if (typeof t.browser === "string" && BROWSER_TYPES.includes(t.browser as BrowserType)) {
503
549
  task.browser = t.browser as BrowserType;
@@ -546,8 +592,18 @@ export function parseConfig(
546
592
  if (typeof data.report === "boolean") report = data.report;
547
593
  else onError(`do-always: ignoring invalid "report" in ${path} (expected true or false)`);
548
594
  }
595
+ let questionnaire: boolean | undefined;
596
+ if (!Array.isArray(data) && "questionnaire" in data) {
597
+ if (typeof data.questionnaire === "boolean") questionnaire = data.questionnaire;
598
+ else onError(`do-always: ignoring invalid "questionnaire" in ${path} (expected true or false)`);
599
+ }
600
+ let hidePlan: boolean | undefined;
601
+ if (!Array.isArray(data) && "hidePlan" in data) {
602
+ if (typeof data.hidePlan === "boolean") hidePlan = data.hidePlan;
603
+ else onError(`do-always: ignoring invalid "hidePlan" in ${path} (expected true or false)`);
604
+ }
549
605
 
550
- return { tasks, shortcut, merge, report };
606
+ return { tasks, shortcut, merge, report, questionnaire, hidePlan };
551
607
  }
552
608
 
553
609
  const KEY_MODIFIERS = new Set(["ctrl", "shift", "alt", "super"]);
@@ -807,7 +863,7 @@ export function orderTasksByCategory(
807
863
  */
808
864
  export function shouldAutoRun(task: DoAlwaysTask): boolean {
809
865
  if (typeof task.autoRun === "boolean") return task.autoRun;
810
- return (task.category ?? "").trim().toLowerCase() === "plan";
866
+ return isPlanTask(task);
811
867
  }
812
868
 
813
869
  /**
@@ -1491,3 +1547,352 @@ export function formatCommitReviewPrompt(commits: SelectedCommit[]): string {
1491
1547
  `Summarize your findings for each commit and propose a plan for any fixes if needed. Do not make changes yet.`
1492
1548
  );
1493
1549
  }
1550
+
1551
+ // ── Plan proposal ────────────────────────────────────────────────────────────
1552
+ //
1553
+ // Auto-run Plan tasks end their reply with a machine-readable plan block:
1554
+ // a fenced code block tagged "plan" carrying the proposed action items as
1555
+ // JSON (summary + tiers of items). PLAN_OUTPUT_INSTRUCTION is appended to
1556
+ // Plan task prompts at render time so the agent emits the block;
1557
+ // parsePlanProposal extracts and normalizes it; the selection state machine
1558
+ // and the execution prompt builder are pure so they can be unit-tested
1559
+ // without the Pi runtime.
1560
+
1561
+ /**
1562
+ * Instruction appended to Plan-category task prompts at render time: it
1563
+ * requires the reply to end with a fenced "plan" code block containing the
1564
+ * proposed action items as JSON (summary + tiers of items). Kept in one
1565
+ * place so every Plan prompt shares the same contract.
1566
+ */
1567
+ export const PLAN_OUTPUT_INSTRUCTION =
1568
+ "End your reply with a machine-readable plan block: a fenced code block tagged plan (```plan) containing JSON of exactly this shape: " +
1569
+ '{"summary":"one-line summary","tiers":[{"id":"P0","label":"Critical","items":[{"title":"short action","detail":"where and why (file:line if known)"}]}]}. ' +
1570
+ "One tier per priority level, most urgent first (P0, P1, P2, ...). " +
1571
+ "Each item must be one concrete, independently doable action. " +
1572
+ 'Use an empty "tiers" array when no action is needed.';
1573
+
1574
+ /** One concrete action item proposed by a Plan run. */
1575
+ export interface PlanItem {
1576
+ /** Short action description. */
1577
+ title: string;
1578
+ /** Where and why — file:line, rationale (optional). */
1579
+ detail?: string;
1580
+ }
1581
+
1582
+ /** A priority tier grouping action items (most urgent tier first). */
1583
+ export interface PlanTier {
1584
+ /** Tier id as emitted by the agent (e.g. "P0"); defaulted by position when absent. */
1585
+ id: string;
1586
+ /** Human label (e.g. "Critical"); defaults to the id when absent. */
1587
+ label: string;
1588
+ /** The tier's action items, in the order the agent emitted them. */
1589
+ items: PlanItem[];
1590
+ }
1591
+
1592
+ /** A parsed plan proposal: the agent's reply, normalized for selection. */
1593
+ export interface PlanProposal {
1594
+ /** One-line summary from the block (undefined when absent or blank). */
1595
+ summary?: string;
1596
+ /** Tiers in the order the agent emitted them (most urgent first). */
1597
+ tiers: PlanTier[];
1598
+ }
1599
+
1600
+ /** True when the task belongs to the Plan category (case-insensitive). */
1601
+ export function isPlanTask(task: DoAlwaysTask): boolean {
1602
+ return (task.category ?? "").trim().toLowerCase() === "plan";
1603
+ }
1604
+
1605
+ /**
1606
+ * Matches a fenced code block whose info string is "plan" (trailing
1607
+ * whitespace allowed). The lazy body stops at the first closing fence.
1608
+ */
1609
+ const PLAN_FENCE_RE = /```plan[^\S\n]*\r?\n([\s\S]*?)```/gi;
1610
+
1611
+ /**
1612
+ * Remove every fenced plan block from `text`, collapsing the blank lines
1613
+ * they leave behind and trimming the ends. The stripped text is what the
1614
+ * transcript shows (the message_end handler in index.ts replaces the
1615
+ * finalized message with it); the raw text is captured separately for the
1616
+ * questionnaire parser. `removed` is false when no plan fence was present
1617
+ * (the text is returned unchanged).
1618
+ */
1619
+ export function stripPlanBlocks(text: string): { text: string; removed: boolean } {
1620
+ if (typeof text !== "string" || text === "") return { text, removed: false };
1621
+ if (!text.includes("```plan")) return { text, removed: false };
1622
+ // An unclosed fence (no closing ```) matches nothing: leave the text
1623
+ // alone instead of claiming a removal that didn't happen.
1624
+ if ([...text.matchAll(PLAN_FENCE_RE)].length === 0) return { text, removed: false };
1625
+ const stripped = text
1626
+ .replace(PLAN_FENCE_RE, "")
1627
+ .replace(/\n{3,}/g, "\n\n")
1628
+ .replace(/^\s+/, "")
1629
+ .replace(/\s+$/, "");
1630
+ return { text: stripped, removed: true };
1631
+ }
1632
+
1633
+ /**
1634
+ * Parse a plan proposal from a Plan run's reply text. Finds the fenced
1635
+ * "plan" code blocks, tries them from last to first (the agent may emit an
1636
+ * early malformed one and correct it), and returns the first that yields at
1637
+ * least one valid item. Returns null when there is no plan block, the JSON
1638
+ * is malformed, or nothing valid survives normalization — callers then fall
1639
+ * back to the plain summary notification.
1640
+ */
1641
+ export function parsePlanProposal(text: string): PlanProposal | null {
1642
+ if (typeof text !== "string" || text === "") return null;
1643
+ const fences = [...text.matchAll(PLAN_FENCE_RE)];
1644
+ for (let i = fences.length - 1; i >= 0; i--) {
1645
+ const proposal = normalizePlanJson(fences[i][1]);
1646
+ if (proposal) return proposal;
1647
+ }
1648
+ return null;
1649
+ }
1650
+
1651
+ /**
1652
+ * Why (or why not) a reply's plan block is usable for the questionnaire.
1653
+ * Mirrors parsePlanProposal's block precedence (last to first), so the
1654
+ * reported reason matches what the parser decided: "ok" is exactly the case
1655
+ * where parsePlanProposal returns a proposal.
1656
+ */
1657
+ export type PlanBlockDiagnostic =
1658
+ | { kind: "none" } // no fenced plan block at all
1659
+ | { kind: "malformed"; detail: string } // the last block's JSON does not parse (detail: the parse error)
1660
+ | { kind: "empty" } // JSON parsed, but no valid items survived normalization
1661
+ | { kind: "ok"; itemCount: number };
1662
+
1663
+ export function planBlockDiagnostics(text: string): PlanBlockDiagnostic {
1664
+ if (typeof text !== "string" || text === "") return { kind: "none" };
1665
+ const fences = [...text.matchAll(PLAN_FENCE_RE)];
1666
+ if (fences.length === 0) return { kind: "none" };
1667
+ let lastError: string | null = null;
1668
+ let parsedButEmpty = false;
1669
+ for (let i = fences.length - 1; i >= 0; i--) {
1670
+ try {
1671
+ JSON.parse(fences[i][1].trim());
1672
+ } catch (err) {
1673
+ // The loop runs last-to-first, so the first error seen is the
1674
+ // last block's — the agent's final answer, which is the one to
1675
+ // report. Strip the position suffix from JSON.parse errors
1676
+ // ("at position N (line L column C)") so the message is readable
1677
+ // in a large plan block where the position number is meaningless.
1678
+ if (lastError === null) {
1679
+ const raw = err instanceof Error ? err.message : String(err);
1680
+ lastError = raw.replace(/\s+at\s+position\s+\d+(?:\s*\(line\s+\d+\s+column\s+\d+\))?/, "");
1681
+ }
1682
+ continue;
1683
+ }
1684
+ const proposal = normalizePlanJson(fences[i][1]);
1685
+ if (proposal) {
1686
+ const count = proposal.tiers.reduce((n, t) => n + t.items.length, 0);
1687
+ if (count > 0) return { kind: "ok", itemCount: count };
1688
+ }
1689
+ parsedButEmpty = true;
1690
+ }
1691
+ if (lastError !== null) return { kind: "malformed", detail: lastError };
1692
+ if (parsedButEmpty) return { kind: "empty" };
1693
+ return { kind: "malformed", detail: "invalid JSON" };
1694
+ }
1695
+
1696
+ /**
1697
+ * Normalize one plan block's JSON body. Accepts the tiered shape
1698
+ * ({tiers: [{id, label, items: [...]}]}) and a lenient flat shape
1699
+ * ({items: [{tier, title, ...}]}, grouped preserving first-seen tier
1700
+ * order). Invalid entries are dropped; a result with no valid items is
1701
+ * null.
1702
+ */
1703
+ function normalizePlanJson(raw: string): PlanProposal | null {
1704
+ let data: unknown;
1705
+ try {
1706
+ data = JSON.parse(raw.trim());
1707
+ } catch {
1708
+ return null;
1709
+ }
1710
+ if (typeof data !== "object" || data === null || Array.isArray(data)) return null;
1711
+ const obj = data as Record<string, unknown>;
1712
+ const summary =
1713
+ typeof obj.summary === "string" && obj.summary.trim() !== "" ? obj.summary.trim() : undefined;
1714
+ const tiers: PlanTier[] = [];
1715
+ if (Array.isArray(obj.tiers)) {
1716
+ obj.tiers.forEach((t, i) => {
1717
+ const tier = normalizePlanTier(t, i);
1718
+ if (tier) tiers.push(tier);
1719
+ });
1720
+ } else if (Array.isArray(obj.items)) {
1721
+ const byTier = new Map<string, PlanTier>();
1722
+ for (const entry of obj.items) {
1723
+ const item = normalizePlanItem(entry);
1724
+ if (!item) continue;
1725
+ const tierField =
1726
+ typeof entry === "object" && entry !== null
1727
+ ? (entry as Record<string, unknown>).tier
1728
+ : undefined;
1729
+ const id = typeof tierField === "string" && tierField.trim() !== "" ? tierField.trim() : "P0";
1730
+ if (!byTier.has(id)) byTier.set(id, { id, label: id, items: [] });
1731
+ byTier.get(id)!.items.push(item);
1732
+ }
1733
+ for (const tier of byTier.values()) tiers.push(tier);
1734
+ }
1735
+ if (tiers.length === 0) return null;
1736
+ return { summary, tiers };
1737
+ }
1738
+
1739
+ /** Normalize one tier entry; null when it carries no valid items. */
1740
+ function normalizePlanTier(raw: unknown, index: number): PlanTier | null {
1741
+ if (typeof raw !== "object" || raw === null) return null;
1742
+ const obj = raw as Record<string, unknown>;
1743
+ const items: PlanItem[] = [];
1744
+ if (Array.isArray(obj.items)) {
1745
+ for (const entry of obj.items) {
1746
+ const item = normalizePlanItem(entry);
1747
+ if (item) items.push(item);
1748
+ }
1749
+ }
1750
+ if (items.length === 0) return null;
1751
+ const id = typeof obj.id === "string" && obj.id.trim() !== "" ? obj.id.trim() : `P${index}`;
1752
+ const label = typeof obj.label === "string" && obj.label.trim() !== "" ? obj.label.trim() : id;
1753
+ return { id, label, items };
1754
+ }
1755
+
1756
+ /**
1757
+ * Normalize one item entry: a plain string is a title-only item; an object
1758
+ * needs a non-blank string "title" ("detail" is optional). Null otherwise.
1759
+ */
1760
+ function normalizePlanItem(raw: unknown): PlanItem | null {
1761
+ if (typeof raw === "string") {
1762
+ const title = raw.trim();
1763
+ return title === "" ? null : { title };
1764
+ }
1765
+ if (typeof raw !== "object" || raw === null) return null;
1766
+ const obj = raw as Record<string, unknown>;
1767
+ const title = typeof obj.title === "string" ? obj.title.trim() : "";
1768
+ if (title === "") return null;
1769
+ const item: PlanItem = { title };
1770
+ const detail = typeof obj.detail === "string" ? obj.detail.trim() : "";
1771
+ if (detail !== "") item.detail = detail;
1772
+ return item;
1773
+ }
1774
+
1775
+ // ── Plan questionnaire selection ─────────────────────────────────────────────
1776
+ //
1777
+ // The user's selection in the plan questionnaire: which item keys are
1778
+ // checked. A key is "tierIndex:itemIndex" (0-based positions in the
1779
+ // normalized proposal). Pure state — every operation returns a new Set,
1780
+ // never mutating the input (same style as the chain state).
1781
+
1782
+ /** The checked item keys of one questionnaire. */
1783
+ export type PlanSelection = Set<string>;
1784
+
1785
+ /** An empty selection. */
1786
+ export function planSelectionClear(): PlanSelection {
1787
+ return new Set();
1788
+ }
1789
+
1790
+ /** The selection key of one item (0-based tier and item positions). */
1791
+ export function planItemKey(tierIndex: number, itemIndex: number): string {
1792
+ return `${tierIndex}:${itemIndex}`;
1793
+ }
1794
+
1795
+ /** Toggle one item's membership. */
1796
+ export function planToggleItem(selection: PlanSelection, key: string): PlanSelection {
1797
+ const next = new Set(selection);
1798
+ if (next.has(key)) next.delete(key);
1799
+ else next.add(key);
1800
+ return next;
1801
+ }
1802
+
1803
+ /**
1804
+ * Toggle a whole tier: fully selected → clear all its items; none or
1805
+ * partial → select all of them. Returns the new selection and whether the
1806
+ * tier ended up selected. Unknown tier index: the selection is unchanged.
1807
+ */
1808
+ export function planToggleTier(
1809
+ proposal: PlanProposal,
1810
+ tierIndex: number,
1811
+ selection: PlanSelection,
1812
+ ): { selection: PlanSelection; selected: boolean } {
1813
+ const tier = proposal.tiers[tierIndex];
1814
+ if (!tier) return { selection, selected: false };
1815
+ const keys = tier.items.map((_, i) => planItemKey(tierIndex, i));
1816
+ const allSelected = keys.every((k) => selection.has(k));
1817
+ const next = new Set(selection);
1818
+ if (allSelected) for (const k of keys) next.delete(k);
1819
+ else for (const k of keys) next.add(k);
1820
+ return { selection: next, selected: !allSelected };
1821
+ }
1822
+
1823
+ /** Select every item in the proposal. */
1824
+ export function planSelectAll(proposal: PlanProposal, selection: PlanSelection): PlanSelection {
1825
+ const next = new Set(selection);
1826
+ proposal.tiers.forEach((tier, ti) => {
1827
+ tier.items.forEach((_, ii) => next.add(planItemKey(ti, ii)));
1828
+ });
1829
+ return next;
1830
+ }
1831
+
1832
+ /** One tier's aggregate state: no items, some, or all items selected. */
1833
+ export function planTierState(
1834
+ proposal: PlanProposal,
1835
+ tierIndex: number,
1836
+ selection: PlanSelection,
1837
+ ): "none" | "partial" | "all" {
1838
+ const tier = proposal.tiers[tierIndex];
1839
+ if (!tier) return "none";
1840
+ let count = 0;
1841
+ tier.items.forEach((_, ii) => {
1842
+ if (selection.has(planItemKey(tierIndex, ii))) count++;
1843
+ });
1844
+ if (count === 0) return "none";
1845
+ if (count === tier.items.length) return "all";
1846
+ return "partial";
1847
+ }
1848
+
1849
+ /** A selected item with its tier, for display and the execution prompt. */
1850
+ export interface PlanSelectionEntry {
1851
+ tier: PlanTier;
1852
+ item: PlanItem;
1853
+ /** A user note added in the questionnaire for this item, if any. */
1854
+ note?: string;
1855
+ }
1856
+
1857
+ /**
1858
+ * The selected items in execution order: tier order, item order within a
1859
+ * tier. Empty when nothing is selected. When `notes` is given, a non-empty
1860
+ * note for a selected item (keyed by planItemKey) is carried on the entry.
1861
+ */
1862
+ export function planSelectedItems(
1863
+ proposal: PlanProposal,
1864
+ selection: PlanSelection,
1865
+ notes?: ReadonlyMap<string, string>,
1866
+ ): PlanSelectionEntry[] {
1867
+ const out: PlanSelectionEntry[] = [];
1868
+ proposal.tiers.forEach((tier, ti) => {
1869
+ tier.items.forEach((item, ii) => {
1870
+ const key = planItemKey(ti, ii);
1871
+ if (!selection.has(key)) return;
1872
+ const note = notes?.get(key)?.trim();
1873
+ out.push(note ? { tier, item, note } : { tier, item });
1874
+ });
1875
+ });
1876
+ return out;
1877
+ }
1878
+
1879
+ /**
1880
+ * Build the follow-up prompt sent when the user confirms a questionnaire
1881
+ * selection. The proposal itself is already in the conversation (the Plan
1882
+ * run's reply), so the prompt references it and lists only the selected
1883
+ * items, in execution order.
1884
+ */
1885
+ export function formatPlanExecutionPrompt(selected: PlanSelectionEntry[], taskName: string): string {
1886
+ const lines: string[] = [
1887
+ `Execute the following action items from the "${taskName}" plan proposal, in exactly this order. ` +
1888
+ "Do ONLY these items — skip every other item from the proposal, and do not start anything else. " +
1889
+ "When done, summarize what you changed.",
1890
+ "",
1891
+ ];
1892
+ selected.forEach(({ tier, item, note }, i) => {
1893
+ lines.push(
1894
+ `${i + 1}. [${tier.id}] ${item.title}${item.detail ? ` — ${item.detail}` : ""}${note ? ` [note: ${note}]` : ""}`,
1895
+ );
1896
+ });
1897
+ return lines.join("\n");
1898
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-do-always",
3
- "version": "0.13.0",
3
+ "version": "0.16.0",
4
4
  "type": "module",
5
5
  "description": "Pi extension: /do-always — pick a common task by number, it fills your prompt",
6
6
  "author": {