oh-my-opencode-dashboard 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/App.tsx CHANGED
@@ -2,6 +2,11 @@ import * as React from "react";
2
2
  import { computeWaitingDing } from "./ding-policy";
3
3
  import { playDing, unlockAudio } from "./sound";
4
4
 
5
+ const APP_VERSION =
6
+ typeof __APP_VERSION__ === "string" && __APP_VERSION__.trim().length > 0 ? __APP_VERSION__ : "0.0.0";
7
+
8
+ const APP_TITLE = `Agent Dashboard (v${APP_VERSION})`;
9
+
5
10
  type BackgroundTask = {
6
11
  id: string;
7
12
  description: string;
@@ -13,6 +18,31 @@ type BackgroundTask = {
13
18
  timeline: string;
14
19
  };
15
20
 
21
+ type TimeSeriesTone = "muted" | "teal" | "red" | "green";
22
+
23
+ type TimeSeriesSeriesId =
24
+ | "overall-main"
25
+ | "agent:sisyphus"
26
+ | "agent:prometheus"
27
+ | "agent:atlas"
28
+ | "background-total";
29
+
30
+ type TimeSeriesSeries = {
31
+ id: TimeSeriesSeriesId;
32
+ label: string;
33
+ tone: TimeSeriesTone;
34
+ values: number[];
35
+ };
36
+
37
+ type TimeSeries = {
38
+ windowMs: number;
39
+ buckets: number;
40
+ bucketMs: number;
41
+ anchorMs: number;
42
+ serverNowMs: number;
43
+ series: TimeSeriesSeries[];
44
+ };
45
+
16
46
  type DashboardPayload = {
17
47
  mainSession: {
18
48
  agent: string;
@@ -27,11 +57,121 @@ type DashboardPayload = {
27
57
  total: number;
28
58
  path: string;
29
59
  statusPill: string;
60
+ steps?: Array<{ checked: boolean; text: string }>;
30
61
  };
31
62
  backgroundTasks: BackgroundTask[];
63
+ timeSeries: TimeSeries;
32
64
  raw: unknown;
33
65
  };
34
66
 
67
+ const TIME_SERIES_DEFAULT_WINDOW_MS = 300_000;
68
+ const TIME_SERIES_DEFAULT_BUCKET_MS = 2_000;
69
+ const TIME_SERIES_DEFAULT_BUCKETS = Math.floor(TIME_SERIES_DEFAULT_WINDOW_MS / TIME_SERIES_DEFAULT_BUCKET_MS);
70
+
71
+ const TIME_SERIES_SERIES_DEFS: Array<Pick<TimeSeriesSeries, "id" | "label" | "tone">> = [
72
+ { id: "overall-main", label: "Overall", tone: "muted" },
73
+ { id: "agent:sisyphus", label: "Sisyphus", tone: "teal" },
74
+ { id: "agent:prometheus", label: "Prometheus", tone: "red" },
75
+ { id: "agent:atlas", label: "Atlas", tone: "green" },
76
+ { id: "background-total", label: "Background tasks (total)", tone: "muted" },
77
+ ];
78
+
79
+ function toFiniteNumber(value: unknown): number | null {
80
+ if (typeof value !== "number") return null;
81
+ if (!Number.isFinite(value)) return null;
82
+ return value;
83
+ }
84
+
85
+ function toNonNegativeCount(value: unknown): number {
86
+ const n = typeof value === "number" ? value : Number(value);
87
+ if (!Number.isFinite(n)) return 0;
88
+ return Math.max(0, Math.floor(n));
89
+ }
90
+
91
+ function makeZeroTimeSeries(opts: {
92
+ windowMs?: number;
93
+ buckets?: number;
94
+ bucketMs?: number;
95
+ anchorMs?: number;
96
+ serverNowMs?: number;
97
+ }): TimeSeries {
98
+ const windowMs = Math.max(1, Math.floor(opts.windowMs ?? TIME_SERIES_DEFAULT_WINDOW_MS));
99
+ const bucketMs = Math.max(1, Math.floor(opts.bucketMs ?? TIME_SERIES_DEFAULT_BUCKET_MS));
100
+ const derivedBuckets = Math.floor(windowMs / bucketMs);
101
+ const buckets = Math.max(
102
+ 1,
103
+ Math.floor(opts.buckets ?? (derivedBuckets > 0 ? derivedBuckets : TIME_SERIES_DEFAULT_BUCKETS))
104
+ );
105
+ const serverNowMs = Math.floor(opts.serverNowMs ?? 0);
106
+ const anchorMs = Math.floor(opts.anchorMs ?? 0);
107
+
108
+ const values = new Array<number>(buckets).fill(0);
109
+ return {
110
+ windowMs,
111
+ buckets,
112
+ bucketMs,
113
+ anchorMs,
114
+ serverNowMs,
115
+ series: TIME_SERIES_SERIES_DEFS.map((def) => ({ ...def, values: values.slice() })),
116
+ };
117
+ }
118
+
119
+ function normalizeTimeSeries(input: unknown, nowMsFallback: number): TimeSeries {
120
+ if (!input || typeof input !== "object") {
121
+ const bucketMs = TIME_SERIES_DEFAULT_BUCKET_MS;
122
+ const serverNowMs = nowMsFallback;
123
+ const anchorMs = Math.floor(serverNowMs / bucketMs) * bucketMs;
124
+ return makeZeroTimeSeries({
125
+ windowMs: TIME_SERIES_DEFAULT_WINDOW_MS,
126
+ bucketMs,
127
+ buckets: TIME_SERIES_DEFAULT_BUCKETS,
128
+ anchorMs,
129
+ serverNowMs,
130
+ });
131
+ }
132
+
133
+ const rec = input as Record<string, unknown>;
134
+
135
+ const windowMsRaw = toFiniteNumber(rec.windowMs ?? rec.window_ms);
136
+ const bucketsRaw = toFiniteNumber(rec.buckets);
137
+ const bucketMsRaw = toFiniteNumber(rec.bucketMs ?? rec.bucket_ms);
138
+ const bucketMs = Math.max(1, Math.floor(bucketMsRaw ?? TIME_SERIES_DEFAULT_BUCKET_MS));
139
+
140
+ const bucketsFromWindow = windowMsRaw && bucketMs ? Math.floor(windowMsRaw / bucketMs) : null;
141
+ const buckets = Math.max(1, Math.floor(bucketsRaw ?? bucketsFromWindow ?? TIME_SERIES_DEFAULT_BUCKETS));
142
+
143
+ const windowMs = Math.max(1, Math.floor(windowMsRaw ?? buckets * bucketMs));
144
+
145
+ const serverNowRaw = toFiniteNumber(rec.serverNowMs ?? rec.server_now_ms);
146
+ const serverNowMs = Math.floor(serverNowRaw ?? nowMsFallback);
147
+
148
+ const anchorRaw = toFiniteNumber(rec.anchorMs ?? rec.anchor_ms);
149
+ const anchorMs = Math.floor(anchorRaw ?? Math.floor(serverNowMs / bucketMs) * bucketMs);
150
+
151
+ const seriesInput = rec.series;
152
+ const byId = new Map<string, unknown>();
153
+ if (Array.isArray(seriesInput)) {
154
+ for (const s of seriesInput) {
155
+ if (!s || typeof s !== "object") continue;
156
+ const srec = s as Record<string, unknown>;
157
+ const id = typeof srec.id === "string" ? srec.id : typeof srec.key === "string" ? srec.key : null;
158
+ if (!id) continue;
159
+ if (!byId.has(id)) byId.set(id, srec);
160
+ }
161
+ }
162
+
163
+ const series: TimeSeriesSeries[] = TIME_SERIES_SERIES_DEFS.map((def) => {
164
+ const found = byId.get(def.id);
165
+ const valuesRaw = found && typeof found === "object" ? (found as Record<string, unknown>).values : null;
166
+ const parsed = Array.isArray(valuesRaw) ? valuesRaw.map(toNonNegativeCount) : [];
167
+ const trimmed = parsed.length > buckets ? parsed.slice(parsed.length - buckets) : parsed;
168
+ const padded = trimmed.length < buckets ? trimmed.concat(new Array<number>(buckets - trimmed.length).fill(0)) : trimmed;
169
+ return { ...def, values: padded };
170
+ });
171
+
172
+ return { windowMs, buckets, bucketMs, anchorMs, serverNowMs, series };
173
+ }
174
+
35
175
  const FALLBACK_DATA: DashboardPayload = {
36
176
  mainSession: {
37
177
  agent: "sisyphus",
@@ -59,6 +199,13 @@ const FALLBACK_DATA: DashboardPayload = {
59
199
  timeline: "2026-01-01T00:00:00Z: 2m",
60
200
  },
61
201
  ],
202
+ timeSeries: makeZeroTimeSeries({
203
+ windowMs: TIME_SERIES_DEFAULT_WINDOW_MS,
204
+ bucketMs: TIME_SERIES_DEFAULT_BUCKET_MS,
205
+ buckets: TIME_SERIES_DEFAULT_BUCKETS,
206
+ anchorMs: 0,
207
+ serverNowMs: 0,
208
+ }),
62
209
  raw: {
63
210
  ok: false,
64
211
  hint: "API not reachable yet. Using placeholder data.",
@@ -93,6 +240,21 @@ function statusTone(status: string): "teal" | "sand" | "red" {
93
240
  return "sand";
94
241
  }
95
242
 
243
+ function maxCount(values: number[]): number {
244
+ let max = 0;
245
+ for (const v of values) {
246
+ if (typeof v !== "number") continue;
247
+ if (!Number.isFinite(v)) continue;
248
+ if (v > max) max = v;
249
+ }
250
+ return max;
251
+ }
252
+
253
+ function barHeight(value: number, max: number, chartHeight: number): number {
254
+ if (!max || value <= 0) return 0;
255
+ return Math.max(1, Math.round((value / max) * chartHeight));
256
+ }
257
+
96
258
  async function safeFetchJson(url: string): Promise<unknown> {
97
259
  const controller = new AbortController();
98
260
  const t = window.setTimeout(() => controller.abort(), 1600);
@@ -144,6 +306,8 @@ function toDashboardPayload(json: unknown): DashboardPayload {
144
306
  const completed = Number(plan.completed ?? plan.done ?? 0) || 0;
145
307
  const total = Number(plan.total ?? plan.count ?? 0) || 0;
146
308
 
309
+ const timeSeries = normalizeTimeSeries(anyJson.timeSeries, Date.now());
310
+
147
311
  return {
148
312
  mainSession: {
149
313
  agent: String(main.agent ?? FALLBACK_DATA.mainSession.agent),
@@ -160,6 +324,7 @@ function toDashboardPayload(json: unknown): DashboardPayload {
160
324
  statusPill: String(plan.statusPill ?? plan.status ?? FALLBACK_DATA.planProgress.statusPill),
161
325
  },
162
326
  backgroundTasks,
327
+ timeSeries,
163
328
  raw: json,
164
329
  };
165
330
  }
@@ -171,6 +336,7 @@ export default function App() {
171
336
  const [copyState, setCopyState] = React.useState<"idle" | "ok" | "err">("idle");
172
337
  const [soundEnabled, setSoundEnabled] = React.useState(false);
173
338
  const [soundUnlocked, setSoundUnlocked] = React.useState(false);
339
+ const [planOpen, setPlanOpen] = React.useState(false);
174
340
  const [errorHint, setErrorHint] = React.useState<string | null>(null);
175
341
 
176
342
  const timerRef = React.useRef<number | null>(null);
@@ -186,6 +352,11 @@ export default function App() {
186
352
  return window.location.origin;
187
353
  }, []);
188
354
 
355
+ React.useEffect(() => {
356
+ if (typeof document === "undefined") return;
357
+ document.title = APP_TITLE;
358
+ }, []);
359
+
189
360
  React.useEffect(() => {
190
361
  soundEnabledRef.current = soundEnabled;
191
362
  }, [soundEnabled]);
@@ -231,7 +402,7 @@ export default function App() {
231
402
  return hasSession && idle && noTool;
232
403
  }
233
404
 
234
- function maybePlayDings(next: DashboardPayload) {
405
+ function maybePlayDings(prev: DashboardPayload | null, next: DashboardPayload) {
235
406
  if (!soundEnabledRef.current) return;
236
407
  if (!hadSuccessRef.current) return;
237
408
 
@@ -258,6 +429,12 @@ export default function App() {
258
429
  }
259
430
  }
260
431
 
432
+ const tool = String(next.mainSession.currentTool ?? "").trim().toLowerCase();
433
+ const prevTool = String(prev?.mainSession.currentTool ?? "").trim().toLowerCase();
434
+ if (tool === "question" && prevTool !== "question") {
435
+ void playDing("question");
436
+ }
437
+
261
438
  const waitingDecision = computeWaitingDing({
262
439
  prev: {
263
440
  prevWaiting,
@@ -299,10 +476,12 @@ export default function App() {
299
476
  hadSuccessRef.current = true;
300
477
  setConnected(true);
301
478
  setErrorHint(null);
302
- const next = toDashboardPayload(json);
303
- maybePlayDings(next);
304
- setData(next);
305
- setLastUpdate(Date.now());
479
+ const next = toDashboardPayload(json);
480
+ setData((prev) => {
481
+ maybePlayDings(prev, next);
482
+ return next;
483
+ });
484
+ setLastUpdate(Date.now());
306
485
  } catch (err) {
307
486
  if (!alive) return;
308
487
  nextConnected = false;
@@ -351,6 +530,23 @@ export default function App() {
351
530
  const liveLabel = connected ? "Live" : "Disconnected";
352
531
  const liveTone = connected ? "teal" : "sand";
353
532
 
533
+ const timeSeriesById = React.useMemo(() => {
534
+ const map = new Map<TimeSeriesSeriesId, TimeSeriesSeries>();
535
+ for (const s of data.timeSeries.series) {
536
+ if (s && typeof s.id === "string") {
537
+ map.set(s.id, s);
538
+ }
539
+ }
540
+ return map;
541
+ }, [data.timeSeries.series]);
542
+
543
+ const buckets = Math.max(1, data.timeSeries.buckets);
544
+ const bucketMs = Math.max(1, data.timeSeries.bucketMs);
545
+ const viewBox = `0 0 ${buckets} 28`;
546
+ const minuteStep = Math.max(1, Math.round(60_000 / bucketMs));
547
+
548
+ const overallValues = timeSeriesById.get("overall-main")?.values ?? [];
549
+
354
550
  return (
355
551
  <div className="page">
356
552
  <div className="container">
@@ -358,7 +554,7 @@ export default function App() {
358
554
  <div className="brand">
359
555
  <div className="brandMark" aria-hidden="true" />
360
556
  <div className="brandText">
361
- <h1>Agent Dashboard</h1>
557
+ <h1>{APP_TITLE}</h1>
362
558
  <p>
363
559
  Live view (no prompts or tool arguments rendered).
364
560
  {!connected && errorHint ? <span className="hint"> - {errorHint}</span> : null}
@@ -395,6 +591,137 @@ export default function App() {
395
591
  </header>
396
592
 
397
593
  <main className="stack">
594
+ <section className="timeSeries">
595
+ <div className="timeSeriesHeader">
596
+ <h2 className="timeSeriesTitle">Time-series activity</h2>
597
+ <p className="timeSeriesSub">Last 5 minutes</p>
598
+ </div>
599
+
600
+ <div className="timeSeriesAxisTop" aria-hidden="true">
601
+ <div />
602
+ <div className="timeSeriesAxisTopLabels">
603
+ <span className="timeSeriesAxisTopLabel">-5m</span>
604
+ <span className="timeSeriesAxisTopLabel">-4m</span>
605
+ <span className="timeSeriesAxisTopLabel">-3m</span>
606
+ <span className="timeSeriesAxisTopLabel">-1m</span>
607
+ </div>
608
+ </div>
609
+
610
+ <div className="timeSeriesRows">
611
+ {(
612
+ [
613
+ {
614
+ label: "Sisyphus",
615
+ tone: "teal" as const,
616
+ overlayId: "agent:sisyphus" as const,
617
+ baseline: false,
618
+ },
619
+ {
620
+ label: "Prometheus",
621
+ tone: "red" as const,
622
+ overlayId: "agent:prometheus" as const,
623
+ baseline: false,
624
+ },
625
+ {
626
+ label: "Atlas",
627
+ tone: "green" as const,
628
+ overlayId: "agent:atlas" as const,
629
+ baseline: false,
630
+ },
631
+ {
632
+ label: "background tasks (total)",
633
+ tone: "muted" as const,
634
+ overlayId: "background-total" as const,
635
+ baseline: false,
636
+ },
637
+ ] as const
638
+ ).map((row) => {
639
+ const H = 28;
640
+ const padTop = 2;
641
+ const padBottom = 2;
642
+ const chartHeight = H - padTop - padBottom;
643
+ const baselineY = H - padBottom;
644
+ const barW = 0.85;
645
+ const barInset = (1 - barW) / 2;
646
+
647
+ const overlayValues = timeSeriesById.get(row.overlayId)?.values ?? [];
648
+ const baselineMax = row.baseline ? maxCount(overallValues) : 0;
649
+ const overlayMax = maxCount(overlayValues);
650
+ const scaleMax = Math.max(1, row.baseline ? Math.max(baselineMax, overlayMax) : overlayMax || 1);
651
+
652
+ return (
653
+ <div key={row.overlayId} className="timeSeriesRow" data-tone={row.tone}>
654
+ <div className="timeSeriesRowLabel">{row.label}</div>
655
+ <div className="timeSeriesSvgWrap">
656
+ <svg className="timeSeriesSvg" viewBox={viewBox} preserveAspectRatio="none" aria-hidden="true">
657
+ {Array.from({ length: Math.floor(buckets / minuteStep) + 1 }, (_, idx) => {
658
+ const x = idx * minuteStep;
659
+ if (x < 0 || x > buckets) return null;
660
+ return (
661
+ <line
662
+ key={`g-${idx}`}
663
+ className="timeSeriesGridline"
664
+ x1={x}
665
+ x2={x}
666
+ y1={0}
667
+ y2={H}
668
+ />
669
+ );
670
+ })}
671
+
672
+ {row.baseline
673
+ ? overallValues.slice(0, buckets).map((v, i) => {
674
+ const h = barHeight(v ?? 0, scaleMax, chartHeight);
675
+ if (!h) return null;
676
+ const barX = i + barInset;
677
+ return (
678
+ <rect
679
+ key={`b-${i}`}
680
+ className="timeSeriesBarBaseline"
681
+ x={barX}
682
+ y={baselineY - h}
683
+ width={barW}
684
+ height={h}
685
+ />
686
+ );
687
+ })
688
+ : null}
689
+
690
+ {overlayValues.slice(0, buckets).map((v, i) => {
691
+ const h = barHeight(v ?? 0, scaleMax, chartHeight);
692
+ if (!h) return null;
693
+ const barX = i + barInset;
694
+ return (
695
+ <rect
696
+ key={`o-${i}`}
697
+ className="timeSeriesBar"
698
+ x={barX}
699
+ y={baselineY - h}
700
+ width={barW}
701
+ height={h}
702
+ />
703
+ );
704
+ })}
705
+ </svg>
706
+ </div>
707
+ </div>
708
+ );
709
+ })}
710
+ </div>
711
+
712
+ <div className="timeSeriesAxisBottom" aria-hidden="true">
713
+ <div />
714
+ <div className="timeSeriesAxisBottomLabels">
715
+ <span className="timeSeriesAxisBottomLabel">-5m</span>
716
+ <span className="timeSeriesAxisBottomLabel">-4m</span>
717
+ <span className="timeSeriesAxisBottomLabel">-3m</span>
718
+ <span className="timeSeriesAxisBottomLabel">-2m</span>
719
+ <span className="timeSeriesAxisBottomLabel">-1m</span>
720
+ <span className="timeSeriesAxisBottomLabel">Now</span>
721
+ </div>
722
+ </div>
723
+ </section>
724
+
398
725
  <section className="grid2">
399
726
  <article className="card">
400
727
  <div className="cardHeader">
@@ -427,6 +754,16 @@ export default function App() {
427
754
  <h2>Plan progress</h2>
428
755
  <span className={`pill pill-${statusTone(data.planProgress.statusPill)}`}>{data.planProgress.statusPill}</span>
429
756
  </div>
757
+ <div className="cardHeader" style={{ marginTop: 8 }}>
758
+ <button
759
+ className="button"
760
+ type="button"
761
+ onClick={() => setPlanOpen((v) => !v)}
762
+ aria-expanded={planOpen}
763
+ >
764
+ {planOpen ? "Hide steps" : "Show steps"}
765
+ </button>
766
+ </div>
430
767
  <div className="kv">
431
768
  <div className="kvRow">
432
769
  <div className="kvKey">NAME</div>
@@ -442,7 +779,19 @@ export default function App() {
442
779
  </div>
443
780
  </div>
444
781
  </div>
445
- <div className="progressWrap" aria-label="progress">
782
+ {planOpen ? (
783
+ <div className="divider" />
784
+ ) : null}
785
+ {planOpen ? (
786
+ <div className="mono" style={{ fontSize: 12, lineHeight: 1.5 }}>
787
+ {(data.planProgress.steps ?? []).length > 0
788
+ ? (data.planProgress.steps ?? []).map((s, idx) => (
789
+ <div key={`${idx}-${s.checked ? "x" : "_"}-${s.text}`}>[{s.checked ? "x" : " "}] {s.text || "(empty)"}</div>
790
+ ))
791
+ : "(no steps detected)"}
792
+ </div>
793
+ ) : null}
794
+ <div className="progressWrap">
446
795
  <div className="progressTrack">
447
796
  <div className="progressFill" style={{ width: `${planPercent}%` }} />
448
797
  </div>
@@ -454,7 +803,7 @@ export default function App() {
454
803
  <section className="card">
455
804
  <div className="cardHeader">
456
805
  <h2>Background tasks</h2>
457
- <span className="badge" aria-label="count">
806
+ <span className="badge">
458
807
  {data.backgroundTasks.length}
459
808
  </span>
460
809
  </div>
@@ -483,7 +832,7 @@ export default function App() {
483
832
  </td>
484
833
  <td className="mono">{t.toolCalls}</td>
485
834
  <td className="mono">{t.lastTool}</td>
486
- <td className="mono muted">{t.timeline || "-"}</td>
835
+ <td className="mono muted">{t.status.toLowerCase() === "queued" ? "-" : t.timeline || "-"}</td>
487
836
  </tr>
488
837
  ))}
489
838
  </tbody>
@@ -0,0 +1 @@
1
+ declare const __APP_VERSION__: string;
@@ -16,6 +16,11 @@ export type PlanProgress = {
16
16
  missing: boolean
17
17
  }
18
18
 
19
+ export type PlanStep = {
20
+ checked: boolean
21
+ text: string
22
+ }
23
+
19
24
  export function readBoulderState(projectRoot: string): BoulderState | null {
20
25
  const filePath = assertAllowedPath({
21
26
  candidatePath: path.join(projectRoot, ".sisyphus", "boulder.json"),
@@ -46,6 +51,22 @@ export function getPlanProgressFromMarkdown(content: string): Omit<PlanProgress,
46
51
  }
47
52
  }
48
53
 
54
+ export function getPlanStepsFromMarkdown(content: string): PlanStep[] {
55
+ const lines = content.split(/\r?\n/)
56
+ const steps: PlanStep[] = []
57
+
58
+ for (const raw of lines) {
59
+ const line = raw.trim()
60
+ const m = line.match(/^[-*]\s*\[(\s|x|X)\]\s*(.*)$/)
61
+ if (!m) continue
62
+ const checked = m[1] === "x" || m[1] === "X"
63
+ const text = (m[2] ?? "").trim()
64
+ steps.push({ checked, text })
65
+ }
66
+
67
+ return steps
68
+ }
69
+
49
70
  export function readPlanProgress(projectRoot: string, planPath: string): PlanProgress {
50
71
  let planReal: string
51
72
  try {
@@ -69,3 +90,26 @@ export function readPlanProgress(projectRoot: string, planPath: string): PlanPro
69
90
  return { total: 0, completed: 0, isComplete: false, missing: true }
70
91
  }
71
92
  }
93
+
94
+ export function readPlanSteps(projectRoot: string, planPath: string): { missing: boolean; steps: PlanStep[] } {
95
+ let planReal: string
96
+ try {
97
+ planReal = assertAllowedPath({
98
+ candidatePath: planPath,
99
+ allowedRoots: [projectRoot],
100
+ })
101
+ } catch {
102
+ return { missing: true, steps: [] }
103
+ }
104
+
105
+ if (!fs.existsSync(planReal)) {
106
+ return { missing: true, steps: [] }
107
+ }
108
+
109
+ try {
110
+ const content = fs.readFileSync(planReal, "utf8")
111
+ return { missing: false, steps: getPlanStepsFromMarkdown(content) }
112
+ } catch {
113
+ return { missing: true, steps: [] }
114
+ }
115
+ }