opencode-extended-sidebar 0.2.90

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 (105) hide show
  1. package/.oesignore +17 -0
  2. package/CHANGELOG.md +95 -0
  3. package/LICENSE +21 -0
  4. package/README.md +235 -0
  5. package/oes.json +11 -0
  6. package/package.json +100 -0
  7. package/scripts/install-git-hooks.mjs +29 -0
  8. package/src/pware.oc.core/constants/index.ts +18 -0
  9. package/src/pware.oc.core/constants/pware.oc.core.constants.eventKind.ts +29 -0
  10. package/src/pware.oc.core/constants/pware.oc.core.constants.eventName.ts +13 -0
  11. package/src/pware.oc.core/constants/pware.oc.core.constants.eventType.ts +241 -0
  12. package/src/pware.oc.core/constants/pware.oc.core.constants.myWork.ts +55 -0
  13. package/src/pware.oc.core/constants/pware.oc.core.constants.partType.ts +65 -0
  14. package/src/pware.oc.core/constants/pware.oc.core.constants.phase.ts +58 -0
  15. package/src/pware.oc.core/constants/pware.oc.core.constants.pulse.ts +63 -0
  16. package/src/pware.oc.core/constants/pware.oc.core.constants.rowKind.ts +34 -0
  17. package/src/pware.oc.core/constants/pware.oc.core.constants.status.ts +74 -0
  18. package/src/pware.oc.core/constants/pware.oc.core.constants.toolName.ts +69 -0
  19. package/src/pware.oc.core/git/index.ts +7 -0
  20. package/src/pware.oc.core/git/pware.oc.core.git.ts +237 -0
  21. package/src/pware.oc.core/git/pware.oc.core.gitignore.ts +87 -0
  22. package/src/pware.oc.core/index.ts +18 -0
  23. package/src/pware.oc.core/pware.oc.core.bus.ts +42 -0
  24. package/src/pware.oc.core/pware.oc.core.cache.ts +48 -0
  25. package/src/pware.oc.core/pware.oc.core.clipboard.ts +97 -0
  26. package/src/pware.oc.core/pware.oc.core.debug.ts +280 -0
  27. package/src/pware.oc.core/pware.oc.core.events.ts +79 -0
  28. package/src/pware.oc.core/pware.oc.core.glyph.ts +135 -0
  29. package/src/pware.oc.core/pware.oc.core.layout.ts +234 -0
  30. package/src/pware.oc.core/pware.oc.core.oes.ts +89 -0
  31. package/src/pware.oc.core/pware.oc.core.paths.ts +240 -0
  32. package/src/pware.oc.core/pware.oc.core.preview.ts +74 -0
  33. package/src/pware.oc.core/pware.oc.core.pulse.ts +726 -0
  34. package/src/pware.oc.core/pware.oc.core.sqlite.ts +175 -0
  35. package/src/pware.oc.core/pware.oc.core.status.ts +214 -0
  36. package/src/pware.oc.core/pware.oc.core.timing.ts +63 -0
  37. package/src/pware.oc.core/pware.oc.core.width.ts +215 -0
  38. package/src/pware.oc.omo/constants/index.ts +15 -0
  39. package/src/pware.oc.omo/constants/pware.oc.omo.constants.backgroundTask.ts +18 -0
  40. package/src/pware.oc.omo/constants/pware.oc.omo.constants.boulderStatus.ts +49 -0
  41. package/src/pware.oc.omo/constants/pware.oc.omo.constants.docKind.ts +29 -0
  42. package/src/pware.oc.omo/constants/pware.oc.omo.constants.eventName.ts +11 -0
  43. package/src/pware.oc.omo/constants/pware.oc.omo.constants.planStatus.ts +60 -0
  44. package/src/pware.oc.omo/constants/pware.oc.omo.constants.reviewStatus.ts +49 -0
  45. package/src/pware.oc.omo/constants/pware.oc.omo.constants.startWork.ts +25 -0
  46. package/src/pware.oc.omo/constants/pware.oc.omo.constants.verdict.ts +38 -0
  47. package/src/pware.oc.omo/index.ts +7 -0
  48. package/src/pware.oc.omo/resolver/index.ts +19 -0
  49. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approval.ts +179 -0
  50. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approvalGroup.ts +116 -0
  51. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approvalState.ts +34 -0
  52. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.boulder.ts +503 -0
  53. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.config.ts +40 -0
  54. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.doc.ts +269 -0
  55. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.draftFile.ts +18 -0
  56. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.notepadsFile.ts +18 -0
  57. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.plan.ts +155 -0
  58. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.planFile.ts +361 -0
  59. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.proofFile.ts +18 -0
  60. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.rulesFile.ts +11 -0
  61. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.runContinuationFile.ts +14 -0
  62. package/src/pware.oc.opencode/constants/index.ts +10 -0
  63. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.eventName.ts +15 -0
  64. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.fileTouch.ts +18 -0
  65. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.questionKind.ts +45 -0
  66. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.sessionStatus.ts +58 -0
  67. package/src/pware.oc.opencode/index.ts +8 -0
  68. package/src/pware.oc.opencode/pware.oc.opencode.events.ts +182 -0
  69. package/src/pware.oc.opencode/pware.oc.opencode.files.ts +399 -0
  70. package/src/pware.oc.opencode/resolver/index.ts +170 -0
  71. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.file.ts +162 -0
  72. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.question.ts +185 -0
  73. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.session.ts +211 -0
  74. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.todo.ts +26 -0
  75. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.tool.ts +236 -0
  76. package/src/pware.oc.perf/index.ts +14 -0
  77. package/src/pware.oc.perf/pware.oc.perf.asciichart.d.ts +15 -0
  78. package/src/pware.oc.perf/pware.oc.perf.charts.ts +284 -0
  79. package/src/pware.oc.perf/pware.oc.perf.reader.ts +927 -0
  80. package/src/pware.oc.perf/pware.oc.perf.realtime.ts +88 -0
  81. package/src/pware.oc.perf/pware.oc.perf.realtimeBlock.ts +118 -0
  82. package/src/pware.oc.perf/pware.oc.perf.realtimeCpuRam.ts +79 -0
  83. package/src/pware.oc.perf/pware.oc.perf.realtimeSampler.ts +71 -0
  84. package/src/pware.oc.perf/pware.oc.perf.realtimeTimeline.ts +214 -0
  85. package/src/pware.oc.perf/pware.oc.perf.self.ts +179 -0
  86. package/src/pware.oc.perf/pware.oc.perf.view.tsx +462 -0
  87. package/src/pware.oc.runtime/index.ts +13 -0
  88. package/src/pware.oc.runtime/pware.oc.runtime.monitor.ts +142 -0
  89. package/src/pware.oc.runtime/pware.oc.runtime.mywork-enrich.ts +79 -0
  90. package/src/pware.oc.runtime/pware.oc.runtime.mywork.ts +253 -0
  91. package/src/pware.oc.runtime/pware.oc.runtime.questions.ts +95 -0
  92. package/src/pware.oc.runtime/pware.oc.runtime.snapshotClient.ts +129 -0
  93. package/src/pware.oc.runtime/pware.oc.runtime.source.ts +105 -0
  94. package/src/pware.oc.runtime/pware.oc.runtime.worker.ts +43 -0
  95. package/src/pware.oc.runtime/resolver/index.ts +224 -0
  96. package/src/pware.oc.runtime/resolver/pware.oc.runtime.resolver.delegate.ts +146 -0
  97. package/src/pware.oc.ui/index.ts +12 -0
  98. package/src/pware.oc.ui/pware.oc.ui.chrome.tsx +339 -0
  99. package/src/pware.oc.ui/pware.oc.ui.glyphs.tsx +144 -0
  100. package/src/pware.oc.ui/pware.oc.ui.host.tsx +111 -0
  101. package/src/pware.oc.ui/pware.oc.ui.live.tsx +82 -0
  102. package/src/pware.oc.ui/pware.oc.ui.menudialogs.tsx +843 -0
  103. package/src/pware.oc.ui/pware.oc.ui.sections.tsx +526 -0
  104. package/src/pware.oc.ui/pware.oc.ui.sidebar.tsx +1629 -0
  105. package/src/pware.oc.ui.tsx +98 -0
@@ -0,0 +1,269 @@
1
+ /**
2
+ * pware.oc.core.omo.resolver.doc
3
+ *
4
+ * OMO document index: the plan, drafts, notepads and evidence under `.omo/`.
5
+ * Names, ages and sizes only — a document is read on click, by preview.ts.
6
+ * Scanned lazily behind a short TTL, never on the poll path. Hosts the single
7
+ * per-root omo scan cache shared with the approval classifier (`approval.ts`):
8
+ * each root keeps one record whose per-kind stat rows are filled on first
9
+ * request, so `.omo/` is walked once per TTL no matter how many consumers ask.
10
+ * One listing per kind (`listOmoFiles`); the per-kind `*File` resolvers wrap it
11
+ * with a `list` method so each document kind can be listed and
12
+ * session-filtered on its own.
13
+ */
14
+ import fs from "node:fs"
15
+ import path from "node:path"
16
+ import { createStampCache } from "../../pware.oc.core/pware.oc.core.cache.js"
17
+ import { profile } from "../../pware.oc.core/pware.oc.core.debug.js"
18
+ import { canonicalizePath } from "../../pware.oc.core/pware.oc.core.paths.js"
19
+ import { canPreviewPath } from "../../pware.oc.core/pware.oc.core.preview.js"
20
+ import type { SqlDb } from "../../pware.oc.core/pware.oc.core.sqlite.js"
21
+ import {
22
+ DOC_KIND_DRAFT,
23
+ DOC_KIND_NOTEPAD,
24
+ DOC_KIND_PLAN,
25
+ DOC_KIND_PROOF,
26
+ type DocKind,
27
+ } from "../constants/pware.oc.omo.constants.docKind.js"
28
+ import { PLAN_STATUS_DONE, WORK_STATE_COMPLETED, type WorkState } from "../constants/pware.oc.omo.constants.planStatus.js"
29
+ import { findOmoWatchDirs, planWorkStateByPlanName } from "./pware.oc.omo.resolver.boulder.js"
30
+ import { approvalName, parsePlanStatus } from "./pware.oc.omo.resolver.plan.js"
31
+ import { omoFileIndex, omoWriterSession } from "./pware.oc.omo.resolver.planFile.js"
32
+
33
+ export type { DocKind }
34
+
35
+ export type DocView = {
36
+ kind: DocKind
37
+ /** Row label: basename, or `change/file` under evidence. */
38
+ name: string
39
+ /** Project-relative path. The panel must never show a root. */
40
+ rel: string
41
+ updatedAt: number | null
42
+ sizeBytes: number
43
+ previewable: boolean
44
+ }
45
+
46
+ /** The `.omo/` (and `.sisyphus/`) subdirectory + walk depth for each kind. */
47
+ const DOC_KIND_DIRS: Record<DocKind, { dir: string; depth: number }> = {
48
+ [DOC_KIND_PLAN]: { dir: "plans", depth: 1 },
49
+ [DOC_KIND_DRAFT]: { dir: "drafts", depth: 1 },
50
+ [DOC_KIND_NOTEPAD]: { dir: "notepads", depth: 1 },
51
+ // Evidence is one folder per change, files inside.
52
+ [DOC_KIND_PROOF]: { dir: "evidence", depth: 2 },
53
+ }
54
+
55
+ export const DOC_KIND_LABEL: Record<DocKind, string> = {
56
+ [DOC_KIND_PLAN]: "plan",
57
+ [DOC_KIND_DRAFT]: "drafts",
58
+ [DOC_KIND_NOTEPAD]: "notepads",
59
+ [DOC_KIND_PROOF]: "proof",
60
+ }
61
+
62
+ const MAX_PER_KIND = 20
63
+ const MAX_WALK = 400
64
+ const TTL_MS = 2_000
65
+
66
+ /** Options for `listOmoFiles`: session/status filters, ordering and a row cap. */
67
+ export type ListOmoFilesOptions = {
68
+ /** Only files last written by this session. Needs `db` to resolve writers. */
69
+ sessionId?: string | null
70
+ /** Readonly DB handle for the session filter. Ignored when `sessionId` is null. */
71
+ db?: SqlDb | null
72
+ /** Plan/draft frontmatter status, reconciled against boulder. Null = all. */
73
+ status?: string | null
74
+ /** Most files of this kind to return. Defaults to 20. */
75
+ limit?: number
76
+ /** Ordering. Defaults to newest-first (`updatedAt` desc, then name). */
77
+ sort?: "newest" | "name"
78
+ }
79
+
80
+ function statOf(abs: string): { updatedAt: number | null; sizeBytes: number } | null {
81
+ try {
82
+ const st = fs.statSync(abs)
83
+ if (!st.isFile()) return null
84
+ return { updatedAt: Math.round(st.mtimeMs), sizeBytes: st.size }
85
+ } catch {
86
+ return null
87
+ }
88
+ }
89
+
90
+ /** Files under `base`, at most `depth` levels deep. Symlinks are not followed. */
91
+ function walk(base: string, depth: number, budget: { left: number }): string[] {
92
+ if (depth <= 0 || budget.left <= 0) return []
93
+ let entries: fs.Dirent[]
94
+ try {
95
+ entries = fs.readdirSync(base, { withFileTypes: true })
96
+ } catch {
97
+ return []
98
+ }
99
+ const out: string[] = []
100
+ for (const e of entries) {
101
+ if (budget.left <= 0) break
102
+ if (e.name.startsWith(".")) continue
103
+ const abs = path.join(base, e.name)
104
+ if (e.isDirectory()) {
105
+ out.push(...walk(abs, depth - 1, budget))
106
+ } else if (e.isFile()) {
107
+ budget.left -= 1
108
+ out.push(abs)
109
+ }
110
+ }
111
+ return out
112
+ }
113
+
114
+ function newestFirst(a: DocView, b: DocView): number {
115
+ const d = (b.updatedAt ?? 0) - (a.updatedAt ?? 0)
116
+ return d !== 0 ? d : a.name.localeCompare(b.name)
117
+ }
118
+
119
+ /** Every file of one kind under the project's `.omo/` and `.sisyphus/` dirs. */
120
+ function scanKind(kind: DocKind, projectRoot: string): DocView[] {
121
+ const root = canonicalizePath(projectRoot)
122
+ const src = DOC_KIND_DIRS[kind]
123
+ const seen = new Set<string>()
124
+ const out: DocView[] = []
125
+ for (const omoDir of findOmoWatchDirs(root)) {
126
+ const base = path.join(omoDir, src.dir)
127
+ for (const abs of walk(base, src.depth, { left: MAX_WALK })) {
128
+ const name = path.relative(base, abs).replace(/\\/g, "/")
129
+ const rel = path.relative(root, abs).replace(/\\/g, "/")
130
+ if (!rel || rel.startsWith("..") || seen.has(rel)) continue
131
+ const st = statOf(abs)
132
+ if (!st) continue
133
+ seen.add(rel)
134
+ out.push({
135
+ kind,
136
+ name,
137
+ rel,
138
+ updatedAt: st.updatedAt,
139
+ sizeBytes: st.sizeBytes,
140
+ previewable: canPreviewPath(abs),
141
+ })
142
+ }
143
+ }
144
+ out.sort(newestFirst)
145
+ return out
146
+ }
147
+
148
+ /** One cached per-root omo scan: stat-only rows per doc kind, filled on request. */
149
+ export type OmoDocScan = {
150
+ rows: Partial<Record<DocKind, DocView[]>>
151
+ }
152
+
153
+ const omoScanCache = createStampCache<OmoDocScan>({ ttlMs: TTL_MS })
154
+
155
+ /** The shared per-root omo scan record, or null without a project root. */
156
+ export function omoScanRecord(projectRoot: string | null | undefined): OmoDocScan | null {
157
+ if (!projectRoot) return null
158
+ const root = canonicalizePath(projectRoot)
159
+ return omoScanCache.get(root, () => ({ rows: {} }))
160
+ }
161
+
162
+ /**
163
+ * Stat-only rows of one doc kind from the shared per-root omo scan — the single
164
+ * stamp cache behind both `listOmoFiles` and the approval classifier
165
+ * (`approval.ts`). Each kind is scanned lazily on first request and cached with
166
+ * the root record until the TTL expires. Draft rows are the one place where a
167
+ * draft superseded by its plan is dropped: a draft whose slug already exists as
168
+ * a plan file is hidden everywhere (Session-tab draft list, My-work action
169
+ * groups, the Draft docs archive), because the plan is the live artifact.
170
+ */
171
+ export function omoKindRows(kind: DocKind, projectRoot: string | null | undefined): DocView[] {
172
+ if (!projectRoot) return []
173
+ const scan = omoScanRecord(projectRoot)
174
+ if (!scan) return []
175
+ const cached = scan.rows[kind]
176
+ if (cached) return cached
177
+ const rows = profile("omo.docs", () => {
178
+ try {
179
+ return scanKind(kind, projectRoot)
180
+ } catch {
181
+ return []
182
+ }
183
+ })
184
+ scan.rows[kind] = kind === DOC_KIND_DRAFT ? dropSupersededDrafts(rows, projectRoot) : rows
185
+ return scan.rows[kind]
186
+ }
187
+
188
+ /**
189
+ * The slugs that already have a plan file. A draft sharing one of these is
190
+ * superseded — the plan is the live artifact, so the draft must not surface in
191
+ * any list (Session drafts, My-work action groups or the Draft docs archive).
192
+ */
193
+ function plannedSlugs(projectRoot: string): ReadonlySet<string> {
194
+ const slugs = new Set<string>()
195
+ for (const row of omoKindRows(DOC_KIND_PLAN, projectRoot)) {
196
+ if (!row.rel.toLowerCase().endsWith(".md")) continue
197
+ const slug = approvalName(row.rel)
198
+ if (slug) slugs.add(slug)
199
+ }
200
+ return slugs
201
+ }
202
+
203
+ /** Drafts whose basename already exists as a plan (same slug) are superseded. */
204
+ function dropSupersededDrafts(rows: readonly DocView[], projectRoot: string): DocView[] {
205
+ const planned = plannedSlugs(projectRoot)
206
+ if (planned.size === 0) return rows as DocView[]
207
+ return rows.filter((d) => !planned.has(approvalName(d.rel)))
208
+ }
209
+
210
+ /** Drop the shared omo scan cache so the next read hits the filesystem. */
211
+ export function resetDocsCache(): void {
212
+ omoScanCache.reset()
213
+ }
214
+
215
+ /**
216
+ * The effective plan/draft status: the frontmatter `status:`, overridden to
217
+ * `done` when boulder shows the plan's work completed — the LLM may not have
218
+ * updated the file. Falls back to the frontmatter alone when boulder is
219
+ * absent. Null when the file has no status or cannot be read.
220
+ */
221
+ function planStatusOf(
222
+ projectRoot: string,
223
+ rel: string,
224
+ workStates: Map<string, WorkState>,
225
+ ): string | null {
226
+ let text = ""
227
+ try {
228
+ text = fs.readFileSync(path.join(projectRoot, rel), "utf8")
229
+ } catch {
230
+ return null
231
+ }
232
+ const status = parsePlanStatus(text)
233
+ if (workStates.get(approvalName(rel)) === WORK_STATE_COMPLETED) return PLAN_STATUS_DONE
234
+ if (!status) return null
235
+ return status
236
+ }
237
+
238
+ /**
239
+ * Files of one document kind, newest-first. `sessionId` filters to the files
240
+ * that session last wrote (resolved against the OpenCode DB via `db`); `status`
241
+ * filters plan/draft files to their effective status; `limit` and `sort` cap
242
+ * and reorder the result. Soft-fails to `[]` on a missing root or a
243
+ * filesystem error.
244
+ */
245
+ export function listOmoFiles(
246
+ kind: DocKind,
247
+ projectRoot: string | null | undefined,
248
+ opts: ListOmoFilesOptions = {},
249
+ ): DocView[] {
250
+ if (!projectRoot) return []
251
+ const root = canonicalizePath(projectRoot)
252
+ const all = omoKindRows(kind, root)
253
+ let docs = all
254
+ const sessionId = opts.sessionId
255
+ const db = opts.db
256
+ if (sessionId && db && docs.length > 0) {
257
+ // One index build per listing — the writer lookup per file is a map read.
258
+ const writers = omoFileIndex(db, null, null, kind)
259
+ docs = docs.filter((d) => omoWriterSession(writers, d.rel) === sessionId)
260
+ }
261
+ if (opts.status && (kind === DOC_KIND_PLAN || kind === DOC_KIND_DRAFT)) {
262
+ const want = opts.status.toLowerCase()
263
+ const workStates = planWorkStateByPlanName(root)
264
+ docs = docs.filter((d) => (planStatusOf(root, d.rel, workStates) ?? "").toLowerCase() === want)
265
+ }
266
+ if (opts.sort === "name") docs = [...docs].sort((a, b) => a.name.localeCompare(b.name))
267
+ const limit = opts.limit ?? MAX_PER_KIND
268
+ return docs.slice(0, limit)
269
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * pware.oc.omo.resolver.draftFile
3
+ *
4
+ * Draft-file → writer-session index. Thin re-export of the shared omo file
5
+ * engine (`planFile.ts`), pinned to the `.omo/drafts/` document kind.
6
+ */
7
+ import { DOC_KIND_DRAFT } from "../constants/pware.oc.omo.constants.docKind.js"
8
+ import {
9
+ makeOmoFileList,
10
+ makeOmoFileResolver,
11
+ } from "./pware.oc.omo.resolver.planFile.js"
12
+
13
+ /** Bidirectional draft-file index for one project (see `omoFileIndex`). */
14
+ export const { sessionIndex: draftSessionIndex, sessionForFile: sessionForDraftFile } =
15
+ makeOmoFileResolver("draft")
16
+
17
+ /** Draft files under `.omo/drafts/`, optionally filtered by writer session. */
18
+ export const DraftFile = makeOmoFileList(DOC_KIND_DRAFT)
@@ -0,0 +1,18 @@
1
+ /**
2
+ * pware.oc.omo.resolver.notepadsFile
3
+ *
4
+ * Notepad-file → writer-session index. Thin re-export of the shared omo file
5
+ * engine (`planFile.ts`), pinned to the `.omo/notepads/` document kind.
6
+ */
7
+ import { DOC_KIND_NOTEPAD } from "../constants/pware.oc.omo.constants.docKind.js"
8
+ import {
9
+ makeOmoFileList,
10
+ makeOmoFileResolver,
11
+ } from "./pware.oc.omo.resolver.planFile.js"
12
+
13
+ /** Bidirectional notepad-file index for one project (see `omoFileIndex`). */
14
+ export const { sessionIndex: notepadsSessionIndex, sessionForFile: sessionForNotepadFile } =
15
+ makeOmoFileResolver("notepad")
16
+
17
+ /** Notepad files under `.omo/notepads/`, optionally filtered by writer session. */
18
+ export const NotepadFile = makeOmoFileList(DOC_KIND_NOTEPAD)
@@ -0,0 +1,155 @@
1
+ /**
2
+ * pware.oc.core.omo.resolver.plan
3
+ *
4
+ * Plan.md parsing: status / slug / pending-action from YAML frontmatter or the
5
+ * `## State` section, plus the approval row label. Pure text — no fs, no scan.
6
+ */
7
+ import path from "node:path"
8
+ import type { WorkState } from "../constants/pware.oc.omo.constants.planStatus.js"
9
+
10
+ export type ApprovalItem = {
11
+ /** Project-relative path. The panel must never show a root. */
12
+ rel: string
13
+ name: string
14
+ /** Frontmatter `status:`; null when the draft has none (a bare working note). */
15
+ status: string | null
16
+ /** What happens after approval — e.g. `write .omo/plans/<slug>.md`. */
17
+ pendingAction: string | null
18
+ updatedAt: number | null
19
+ /** ulw-plan review lifecycle from the draft frontmatter; null = no review requested. */
20
+ review: ReviewState | null
21
+ /** Boulder work completion state, for the My-work reconciliation. */
22
+ workState: WorkState
23
+ }
24
+
25
+ /** One ulw-plan review lane (momus / independent). */
26
+ export type ReviewLane = {
27
+ /** Lane lifecycle: pending | launching | in_flight | approved | changes_requested | inconclusive. */
28
+ status: string | null
29
+ /** Lane verdict, when the lane reported one (pass / fail / approved / blocked / …). */
30
+ result: string | null
31
+ }
32
+
33
+ /**
34
+ * The ulw-plan review state persisted in the draft/plan frontmatter: one live
35
+ * round keyed by `review_round_id` + `plan_sha256` (any plan change bumps the
36
+ * hash and starts a fresh round), with a `momus` and an `independent` lane.
37
+ */
38
+ export type ReviewState = {
39
+ required: boolean
40
+ /** `review_round_id` — the live round; a changed `plan_sha256` forces a new one. */
41
+ roundId: string | null
42
+ /** `round_status` — `active` while a round is live. */
43
+ roundStatus: string | null
44
+ /** `plan_sha256` — the reviewed plan's hash, null until a round is initialized. */
45
+ planSha256: string | null
46
+ lanes: { momus: ReviewLane; independent: ReviewLane }
47
+ }
48
+
49
+ /** Row label for an approval item — basename without the `.md` extension. */
50
+ export function approvalName(rel: string): string {
51
+ return path.basename(rel).replace(/\.md$/i, "")
52
+ }
53
+
54
+ function frontmatter(text: string): string | null {
55
+ const m = text.match(/^---\s*\r?\n([\s\S]*?)\r?\n---/)
56
+ return m ? (m[1] ?? "") : null
57
+ }
58
+
59
+ function stateSection(text: string): string | null {
60
+ const m = text.match(/##\s+State\s*\r?\n([\s\S]*?)(?=\r?\n##\s|$)/)
61
+ return m ? (m[1] ?? "") : null
62
+ }
63
+
64
+ export function parsePlanStatus(text: string): string | null {
65
+ const fm = frontmatter(text)
66
+ if (fm) {
67
+ const m = fm.match(/^status:\s*["']?([^"'\r\n]+)["']?/m)
68
+ if (m?.[1]) return m[1].trim()
69
+ }
70
+ const state = stateSection(text)
71
+ if (state) {
72
+ const m = state.match(/status:\s*[`'"]?\s*([^\s`'"\r\n]+)/m)
73
+ if (m?.[1]) return m[1].trim()
74
+ }
75
+ return null
76
+ }
77
+
78
+ function parsePlanSlug(text: string): string | null {
79
+ const fm = frontmatter(text)
80
+ if (fm) {
81
+ const m = fm.match(/^slug:\s*["']?([^"'\r\n]+)["']?/m)
82
+ if (m?.[1]) return m[1].trim()
83
+ }
84
+ const state = stateSection(text)
85
+ if (state) {
86
+ const m = state.match(/slug:\s*[`'"]?\s*([^\s`'"\r\n]+)/m)
87
+ if (m?.[1]) return m[1].trim()
88
+ }
89
+ return null
90
+ }
91
+
92
+ export function parsePlanPendingAction(text: string): string | null {
93
+ const fm = frontmatter(text)
94
+ if (fm) {
95
+ const m = fm.match(/^pending-action:\s*["']?([^"'\r\n]+)["']?/m)
96
+ if (m?.[1]) return m[1].trim()
97
+ }
98
+ const slug = parsePlanSlug(text)
99
+ return slug ? `.omo/plans/${slug}.md` : null
100
+ }
101
+
102
+ function scalar(fm: string, key: string): string | null {
103
+ const v = fm.match(new RegExp(`^${key}:\\s*(\\S+)`, "m"))?.[1]
104
+ if (!v || v === "null") return null
105
+ return v.trim()
106
+ }
107
+
108
+ function stripQuotes(v: string): string {
109
+ return v.replace(/^["']|["']$/g, "")
110
+ }
111
+
112
+ /**
113
+ * One review lane (`momus:` / `independent:`) from the frontmatter lines:
114
+ * the lane's own `status` and `result`, stopping at the next top-level key.
115
+ * Line-based so the last frontmatter line needs no trailing newline.
116
+ */
117
+ function laneState(fm: string, lane: "momus" | "independent"): ReviewLane | null {
118
+ const lines = fm.split(/\r?\n/)
119
+ const idx = lines.findIndex((l) => l.trim() === `${lane}:`)
120
+ if (idx < 0) return null
121
+ const laneIndent = lines[idx]!.match(/^\s*/)?.[0].length ?? 0
122
+ const out: ReviewLane = { status: null, result: null }
123
+ for (const line of lines.slice(idx + 1)) {
124
+ if ((line.match(/^\s*/)?.[0].length ?? 0) <= laneIndent) break
125
+ const m = line.match(/^\s*(\w[\w-]*):\s*(\S+)?/)
126
+ if (!m) continue
127
+ const val = m[2] && m[2] !== "null" ? stripQuotes(m[2]) : null
128
+ if (m[1] === "status") out.status = val
129
+ else if (m[1] === "result") out.result = val
130
+ }
131
+ return out
132
+ }
133
+
134
+ /**
135
+ * ulw-plan review lifecycle from the frontmatter `review:` block and its
136
+ * siblings (`review_required`, `review_round_id`, `round_status`,
137
+ * `plan_sha256`). Null when the draft carries no review fields at all.
138
+ */
139
+ export function parseReviewBlock(text: string): ReviewState | null {
140
+ const fm = frontmatter(text)
141
+ if (!fm) return null
142
+ const required = /^review_required:\s*true/m.test(fm)
143
+ const hasBlock = /^review:\s*$/m.test(fm)
144
+ if (!required && !hasBlock) return null
145
+ return {
146
+ required,
147
+ roundId: scalar(fm, "review_round_id"),
148
+ roundStatus: scalar(fm, "round_status"),
149
+ planSha256: scalar(fm, "plan_sha256"),
150
+ lanes: {
151
+ momus: laneState(fm, "momus") ?? { status: null, result: null },
152
+ independent: laneState(fm, "independent") ?? { status: null, result: null },
153
+ },
154
+ }
155
+ }