@opum-ai/lore 0.1.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.
Files changed (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +306 -0
  3. package/bin/lore.cjs +109 -0
  4. package/package.json +67 -0
  5. package/src/adapters/backlog.ts +1084 -0
  6. package/src/adapters/git.ts +221 -0
  7. package/src/cli.ts +667 -0
  8. package/src/commands/agent.ts +301 -0
  9. package/src/commands/agents.ts +302 -0
  10. package/src/commands/args.ts +209 -0
  11. package/src/commands/changed.ts +70 -0
  12. package/src/commands/check.ts +1031 -0
  13. package/src/commands/codex-bridge.ts +49 -0
  14. package/src/commands/concurrency.ts +48 -0
  15. package/src/commands/context.ts +292 -0
  16. package/src/commands/discover.ts +89 -0
  17. package/src/commands/explorer.ts +253 -0
  18. package/src/commands/export.ts +93 -0
  19. package/src/commands/fswrite.ts +928 -0
  20. package/src/commands/graph.ts +291 -0
  21. package/src/commands/help.ts +151 -0
  22. package/src/commands/impact.ts +59 -0
  23. package/src/commands/init.ts +583 -0
  24. package/src/commands/instructions.ts +91 -0
  25. package/src/commands/link.ts +929 -0
  26. package/src/commands/new.ts +476 -0
  27. package/src/commands/orphans.ts +457 -0
  28. package/src/commands/path.ts +67 -0
  29. package/src/commands/provenance.ts +68 -0
  30. package/src/commands/query.ts +312 -0
  31. package/src/commands/reconcile-shared.ts +280 -0
  32. package/src/commands/rename.ts +585 -0
  33. package/src/commands/replace.ts +320 -0
  34. package/src/commands/scaffold.ts +346 -0
  35. package/src/commands/schema.ts +293 -0
  36. package/src/commands/snapshot.ts +130 -0
  37. package/src/commands/supersede.ts +400 -0
  38. package/src/commands/sync.ts +371 -0
  39. package/src/commands/tasks.ts +271 -0
  40. package/src/commands/traversal.ts +151 -0
  41. package/src/commands/validate.ts +226 -0
  42. package/src/config.ts +598 -0
  43. package/src/core/agent-bridge.ts +287 -0
  44. package/src/core/agent-context.ts +498 -0
  45. package/src/core/agent-profile.ts +447 -0
  46. package/src/core/bundle.ts +893 -0
  47. package/src/core/check.ts +853 -0
  48. package/src/core/codex-bridge.ts +100 -0
  49. package/src/core/concept.ts +597 -0
  50. package/src/core/consumer-scaffold.ts +433 -0
  51. package/src/core/context.ts +271 -0
  52. package/src/core/explorer-contract.ts +441 -0
  53. package/src/core/explorer-qualification.ts +58 -0
  54. package/src/core/explorer.ts +518 -0
  55. package/src/core/finding.ts +31 -0
  56. package/src/core/graph.ts +201 -0
  57. package/src/core/indexes.ts +436 -0
  58. package/src/core/instructions.ts +209 -0
  59. package/src/core/ladybug-driver.ts +1795 -0
  60. package/src/core/ladybug-lifecycle.ts +1178 -0
  61. package/src/core/ladybug-native.ts +95 -0
  62. package/src/core/ladybug-source.ts +667 -0
  63. package/src/core/links.ts +681 -0
  64. package/src/core/log.ts +253 -0
  65. package/src/core/managed-block.ts +540 -0
  66. package/src/core/manifest.ts +718 -0
  67. package/src/core/order.ts +13 -0
  68. package/src/core/profile.ts +1007 -0
  69. package/src/core/projection.ts +195 -0
  70. package/src/core/query.ts +542 -0
  71. package/src/core/reconcile.ts +236 -0
  72. package/src/core/replace.ts +419 -0
  73. package/src/core/retrieval.ts +213 -0
  74. package/src/core/rewrite.ts +940 -0
  75. package/src/core/scaffold.ts +255 -0
  76. package/src/core/schema.ts +366 -0
  77. package/src/core/snapshot-runtime.ts +52 -0
  78. package/src/core/snapshot-store.ts +287 -0
  79. package/src/core/snapshot.ts +711 -0
  80. package/src/core/template.ts +429 -0
  81. package/src/core/traversal.ts +487 -0
  82. package/src/core/validate.ts +517 -0
  83. package/src/core/workspace-contract.ts +473 -0
  84. package/src/core/workspace-projection.ts +365 -0
  85. package/src/core/workspace-retrieval.ts +196 -0
  86. package/src/core/workspace-source.ts +174 -0
  87. package/src/errors.ts +697 -0
  88. package/src/meta.ts +7 -0
  89. package/src/output.ts +589 -0
  90. package/src/scripts/upstream-backlog-watch.ts +288 -0
  91. package/src/state.ts +390 -0
@@ -0,0 +1,540 @@
1
+ /**
2
+ * managed-block.ts — regenerate the `<!-- lore:tasks:begin -->…<!-- lore:tasks:end -->` managed
3
+ * region of a `Story`/`Spec` doc from live Backlog.md data (LORE-22, [ADR-0008]).
4
+ *
5
+ * A `Story` owns a set of Backlog tasks via its `tasks:` frontmatter list. So a reader sees live
6
+ * task status without leaving the doc, lore maintains a **managed region** — a small GFM table
7
+ * between two HTML-comment sentinels — that `lore sync` rewrites and `lore check` verifies without
8
+ * writing. This module is the shared pure engine behind both; the command layer (LORE-24+) reads the
9
+ * file bytes, calls the LORE-21 adapter's `viewTask(id)` once per linked task id to build the
10
+ * {@link ManagedTaskRow rows}, invokes {@link regenerateTaskBlock}, and writes (or diffs) the result.
11
+ *
12
+ * ### Why a surgical string splice, not parse→stringify (the ADR-0008 amendment)
13
+ *
14
+ * [ADR-0008] §Decision describes building the block as mdast and re-serializing the document with a
15
+ * frozen `remark-stringify`/`remark-gfm` config. lore, however, deliberately ships **no markdown
16
+ * serializer** — the only markdown dependency is `mdast-util-from-markdown` (a parser); there is no
17
+ * `remark-stringify`/`mdast-util-to-markdown` (ADR-0001 packaging constraint). Re-emitting the whole
18
+ * document through a stringifier would also reflow the author's untouched prose (list markers,
19
+ * emphasis, wrapping), which ADR-0008 §7 itself forbids. So this engine follows the settled lore
20
+ * pattern ({@link rewriteInbound}, {@link generateIndexes}): **parse only to locate**, then splice a
21
+ * frozen-format table STRING over the byte range strictly between the two marker nodes, copying every
22
+ * other byte — frontmatter, editor modeline, and prose — verbatim. (ADR-0008's mdast prescription is
23
+ * thus superseded on the serializer mechanism only; every guarantee it makes — structural location,
24
+ * marker validation, byte-identity, bounded blast radius — is preserved here.)
25
+ *
26
+ * ### Structural location (what mdast buys over {@link generateIndexes}'s literal splice)
27
+ *
28
+ * Unlike {@link locateManagedBlock}'s `indexOf` scan, the markers are found **structurally**: the
29
+ * document is parsed with `fromMarkdown` and only the direct children of the root that are `html`
30
+ * nodes matching the canonical sentinel are candidates (ADR-0008 §1). A sentinel that appears inside
31
+ * a fenced code block, a blockquote, or any other container is part of *that* node — never a
32
+ * top-level `html` node — so it is never mistaken for a real marker. This is exactly the ambiguity
33
+ * `generateIndexes`'s string splice documents as its one limitation.
34
+ *
35
+ * ### Guarantees
36
+ *
37
+ * - **Byte-identical on no change (AC#1).** Location is structural, row order is the caller's
38
+ * (deterministic) order, links come from the canonical `filePathRelative`, and the table is a
39
+ * frozen string — so regenerating an already-current block reproduces the exact same bytes, and a
40
+ * no-op `lore sync` touches zero bytes (the drift gate stays trustworthy). Splicing is a fixpoint.
41
+ * - **Correct, portable links (AC#2).** Each row link is computed from the task's `filePathRelative`
42
+ * (the JSON's canonical repo-relative path) via {@link normalizeLink} — never reconstructed from
43
+ * the display id, which is upper-cased while the filename is lower-cased and carries the title with
44
+ * spaces (ADR-0008 §5). A linked id whose file is absent on the current branch is tolerated: its
45
+ * row renders the id as plain text rather than a broken link, and never errors.
46
+ * - **Boundary safety.** Only the bytes between the markers change; the markers themselves and every
47
+ * byte before `begin`/after `end` pass through untouched. Malformed markers are a hard
48
+ * {@link LoreError} (`validation`, exit 6) — lore refuses to guess and never writes a partial block.
49
+ *
50
+ * Per the core contract (lore-design §2.1) this module is pure: a string (plus rows) in, a string or
51
+ * a typed {@link LoreError} out — no filesystem, no spawn, no clock, no `process.exit`. Input is
52
+ * expected to be LF-normalized (as every lore read path normalizes it — concept.ts `normalizeInput`).
53
+ *
54
+ * ### The generic sibling ({@link upsertManagedBlock})
55
+ *
56
+ * {@link regenerateTaskBlock} owns one fixed region and *requires* an author-placed marker pair.
57
+ * {@link upsertManagedBlock} is the **insert-or-update** engine for lore-owned blocks that lore must
58
+ * be able to add to a file that has never carried them — `lore agents`'s `CLAUDE.md` nudge. It shares
59
+ * this module's structural, whitespace-tolerant location and fail-loud malformed-marker validation,
60
+ * differing only in that a total absence of markers is an insert, not an error.
61
+ *
62
+ * [ADR-0008]: ../../docs/adr/0008-managed-block-remark-ast.md
63
+ */
64
+
65
+ import type { Nodes, Root } from "mdast";
66
+ import { fromMarkdown } from "mdast-util-from-markdown";
67
+ import { LoreError, singleLine } from "../errors";
68
+ import { normalizeLink } from "./links";
69
+
70
+ /**
71
+ * The HTML-comment sentinels bounding the managed task region (lore-design §6.2). HTML comments are
72
+ * invisible in every target renderer (GitHub, Obsidian, MkDocs, Docusaurus), and the exact
73
+ * `lore:tasks:*` tokens never occur in authored prose. Exported so `lore sync`/`lore check` and any
74
+ * scaffolder emit and match the one canonical spelling. The parallel `lore:index:*` block that
75
+ * {@link generateIndexes} owns uses the same shape.
76
+ */
77
+ export const TASK_BLOCK_BEGIN = "<!-- lore:tasks:begin -->";
78
+ export const TASK_BLOCK_END = "<!-- lore:tasks:end -->";
79
+
80
+ /**
81
+ * Match a top-level `html` node's value against a marker sentinel, tolerant of whitespace both inside
82
+ * the comment (`<!--lore:tasks:begin-->` and the canonical spaced form both match) and *around* it —
83
+ * the node value is trimmed before matching, because `mdast-util-from-markdown` keeps a marker line's
84
+ * leading indent (1–3 spaces) and trailing spaces in the `html` node's `value`, and an invisible
85
+ * trailing space must not make a visibly-correct marker read as "missing". The node must still be
86
+ * *exactly* the marker comment; a node carrying other text (or both markers on one line) matches
87
+ * neither, and is surfaced by {@link findMarkers}'s validation rather than silently paired.
88
+ */
89
+ const BEGIN_MARKER = /^<!--\s*lore:tasks:begin\s*-->$/;
90
+ const END_MARKER = /^<!--\s*lore:tasks:end\s*-->$/;
91
+
92
+ /** The frozen table header row (single-space cell padding, ADR-0008 §Decision item 4). */
93
+ const TABLE_HEADER = "| Task | Title | Status |";
94
+ /** The frozen GFM delimiter row (compact, three dashes per column). */
95
+ const TABLE_DELIMITER = "|---|---|---|";
96
+ /** The frozen paragraph emitted in place of the table when a doc links no tasks (ADR-0008 §3). */
97
+ const NO_TASKS_PARAGRAPH = "_No linked tasks._";
98
+
99
+ /**
100
+ * One task's row data, already resolved by the caller from a `backlog task view <id> --json` read
101
+ * (the LORE-21 adapter's {@link BacklogTaskDetail}). The engine renders these verbatim in the given
102
+ * order — it does not sort, fetch, or resolve; the caller supplies the ADR-0008 §4 order (the doc's
103
+ * `tasks:` frontmatter list, with any out-of-band tasks appended in `task-N` numeric order).
104
+ */
105
+ export interface ManagedTaskRow {
106
+ /** The display-cased identity (`"LORE-42"`), shown as the row's link text verbatim (never a filename source). */
107
+ readonly id: string;
108
+ /** The task title, taken verbatim from the JSON (cell-normalized on render, never reflowed). */
109
+ readonly title: string;
110
+ /** The raw configured status string (`"Done"`, `"In Progress"`), verbatim from the JSON. */
111
+ readonly status: string;
112
+ /**
113
+ * The task file's **repo-relative** path (`filePathRelative`, e.g. `backlog/tasks/lore-42 - x.md`),
114
+ * or `null` when the task is not yet written to disk / absent on the current branch. A non-null
115
+ * value is the link target (ADR-0008 §5); `null` is tolerated — the row renders the id as plain text.
116
+ */
117
+ readonly file: string | null;
118
+ }
119
+
120
+ /** Options for {@link regenerateTaskBlock}. */
121
+ export interface RegenerateTaskBlockOptions {
122
+ /**
123
+ * The **repo-relative** path of the doc being regenerated (`docs/stories/bulk-archive.md`). It
124
+ * anchors each row link's relative computation against the task's repo-relative `filePathRelative`,
125
+ * so a `docs/`-rooted story links a `backlog/`-rooted task as `../../backlog/tasks/…` (both operands
126
+ * must share the repo-relative coordinate space — {@link normalizeLink}'s precondition).
127
+ */
128
+ readonly docPath: string;
129
+ }
130
+
131
+ /** A located marker: the mdast node and its `[start, end)` source byte offsets. */
132
+ interface Marker {
133
+ readonly start: number;
134
+ readonly end: number;
135
+ }
136
+
137
+ /**
138
+ * A top-level `html` node whose (trimmed) value contains marker-like text but does not *exactly*
139
+ * match either the begin or end sentinel — most commonly a begin/end pair that CommonMark's HTML-block
140
+ * rules collapse onto a single line with no separating newline (`<!-- label:begin --><!-- label:end
141
+ * -->`), which `mdast-util-from-markdown` parses as ONE `html` node whose value equals neither anchored
142
+ * pattern (LORE-156). Surfaced distinctly from "no markers at all" so this detected-but-malformed case
143
+ * is never read as a genuinely absent block.
144
+ */
145
+ interface MalformedMarkerNode {
146
+ readonly span: Marker;
147
+ readonly value: string;
148
+ }
149
+
150
+ /**
151
+ * Strip the `^`/`$` anchors from an exact marker-matching `RegExp` (as built for {@link BEGIN_MARKER}/
152
+ * {@link END_MARKER} or the per-label patterns in {@link locateLabeledMarkers}), returning a pattern
153
+ * that matches the same marker text anywhere within a string rather than requiring the whole (trimmed)
154
+ * string to be exactly the marker. Used by {@link collectMarkerSpans} to recognize marker text that is
155
+ * present but not a clean, standalone sentinel (LORE-156).
156
+ */
157
+ function loosenMarkerPattern(anchored: RegExp): RegExp {
158
+ return new RegExp(anchored.source.replace(/^\^/, "").replace(/\$$/, ""));
159
+ }
160
+
161
+ /**
162
+ * Regenerate the managed task region of `content` from `rows`, returning the new full file bytes.
163
+ *
164
+ * The document is parsed only to locate the two top-level `html` marker nodes; the frozen table is
165
+ * built as a string and spliced over the bytes strictly between them, so frontmatter, editor
166
+ * modeline, and every line of prose outside the markers are preserved byte-for-byte. Regenerating an
167
+ * already-current block reproduces identical bytes (AC#1), so the command layer can treat "no byte
168
+ * difference" as a genuine no-op.
169
+ *
170
+ * @param content the doc's full raw bytes (LF-normalized), including frontmatter and the markers.
171
+ * @param rows the linked tasks, in the caller's ADR-0008 §4 render order (may be empty).
172
+ * @param options {@link RegenerateTaskBlockOptions.docPath} — the doc's repo-relative path for links.
173
+ * @returns the new full file bytes with the region replaced.
174
+ * @throws LoreError `validation` (exit 6) when the markers are missing, duplicated, unbalanced, or
175
+ * crossed — lore refuses to guess and never writes a partial or corrupted block (ADR-0008 §2).
176
+ */
177
+ export function regenerateTaskBlock(
178
+ content: string,
179
+ rows: readonly ManagedTaskRow[],
180
+ options: RegenerateTaskBlockOptions,
181
+ ): string {
182
+ const { begin, end } = findMarkers(content);
183
+ const table = buildTable(rows, options.docPath);
184
+ // Replace only the bytes between the markers with `\n{table}\n`; the begin node ends just after its
185
+ // `-->` (its trailing newline is not part of the node), so this reproduces
186
+ // `<!-- …begin -->\n{table}\n<!-- …end -->` — a fixpoint over already-generated bytes.
187
+ return `${content.slice(0, begin.end)}\n${table}\n${content.slice(end.start)}`;
188
+ }
189
+
190
+ /**
191
+ * Scan `content` for top-level `html` marker nodes, returning the source spans of those matching
192
+ * `beginMarker` / `endMarker`, plus any top-level `html` nodes that carry marker-like text without
193
+ * being a clean, standalone sentinel (`malformed`). The document is parsed with `fromMarkdown` and
194
+ * only the root's **direct** `html` children are candidates, so a sentinel nested in a code fence or
195
+ * blockquote is never a marker; the node value is trimmed before matching because mdast keeps a marker
196
+ * line's leading indent and trailing spaces in the `html` node's `value`. The one shared primitive
197
+ * behind both {@link findMarkers} (the fixed `lore:tasks` region) and {@link locateLabeledMarkers} (any
198
+ * labeled block), so the two never drift on how a marker is located — each applies its own validation
199
+ * to the spans this returns.
200
+ *
201
+ * `malformed` exists for LORE-156: when a begin and end marker sit on one line with no separating
202
+ * newline (`<!-- label:begin --><!-- label:end -->`), CommonMark's HTML-block rules make
203
+ * `fromMarkdown` collapse them into a single `html` node whose trimmed value equals neither anchored
204
+ * pattern — so without this check the node is silently skipped and the pair reads as "0 begins, 0
205
+ * ends", indistinguishable from a genuinely marker-free document. A node lands in `malformed` when its
206
+ * trimmed value doesn't exactly match `beginMarker`/`endMarker` but does contain one of them as a
207
+ * substring (a non-anchored, "loose" version of the same pattern) — callers surface this distinctly
208
+ * from a true absence so a detected-but-malformed pair is never mistaken for "no block yet".
209
+ */
210
+ function collectMarkerSpans(
211
+ content: string,
212
+ beginMarker: RegExp,
213
+ endMarker: RegExp,
214
+ ): { begins: Marker[]; ends: Marker[]; malformed: MalformedMarkerNode[] } {
215
+ const tree: Root = fromMarkdown(content);
216
+ const begins: Marker[] = [];
217
+ const ends: Marker[] = [];
218
+ const malformed: MalformedMarkerNode[] = [];
219
+ const looseBegin = loosenMarkerPattern(beginMarker);
220
+ const looseEnd = loosenMarkerPattern(endMarker);
221
+ for (const node of tree.children) {
222
+ if (node.type !== "html") {
223
+ continue;
224
+ }
225
+ const span = offsetsOf(node);
226
+ if (span === null) {
227
+ continue; // defensive: a parsed html node always carries offsets
228
+ }
229
+ const value = node.value.trim();
230
+ if (beginMarker.test(value)) {
231
+ begins.push(span);
232
+ } else if (endMarker.test(value)) {
233
+ ends.push(span);
234
+ } else if (looseBegin.test(value) || looseEnd.test(value)) {
235
+ malformed.push({ span, value });
236
+ }
237
+ }
238
+ return { begins, ends, malformed };
239
+ }
240
+
241
+ /**
242
+ * Locate the single balanced pair of top-level marker nodes in `content`, validating ADR-0008 §2.
243
+ * The document is parsed with `fromMarkdown` and only the root's **direct** `html` children are
244
+ * candidates, so a sentinel nested in a code fence or blockquote is never a marker.
245
+ *
246
+ * @throws LoreError `validation` when there is not exactly one begin and one end marker at top level,
247
+ * or the end precedes the begin (missing / duplicated / unbalanced / crossed).
248
+ */
249
+ function findMarkers(content: string): { begin: Marker; end: Marker } {
250
+ const { begins, ends, malformed } = collectMarkerSpans(content, BEGIN_MARKER, END_MARKER);
251
+
252
+ if (malformed.length > 0) {
253
+ throw markerError(
254
+ `found marker text that is not a clean, standalone \`${TASK_BLOCK_BEGIN}\`/\`${TASK_BLOCK_END}\` sentinel on its own line (commonly a begin/end pair placed on the same line with no separating newline): \`${malformed[0]?.value ?? ""}\``,
255
+ { begins: begins.length, ends: ends.length, malformed: malformed.length },
256
+ );
257
+ }
258
+ if (begins.length === 0 || ends.length === 0) {
259
+ throw markerError(
260
+ `the managed task region is missing (need one \`${TASK_BLOCK_BEGIN}\` and one \`${TASK_BLOCK_END}\` at the document top level)`,
261
+ { begins: begins.length, ends: ends.length },
262
+ );
263
+ }
264
+ if (begins.length > 1 || ends.length > 1) {
265
+ throw markerError(
266
+ `the managed task region is duplicated (found ${begins.length} begin and ${ends.length} end markers; expected exactly one of each)`,
267
+ { begins: begins.length, ends: ends.length },
268
+ );
269
+ }
270
+ const begin = begins[0];
271
+ const end = ends[0];
272
+ if (begin === undefined || end === undefined) {
273
+ throw markerError("the managed task region is missing", { begins: begins.length, ends: ends.length }); // unreachable given the counts above; narrows the element access without a non-null assertion
274
+ }
275
+ if (end.start < begin.end) {
276
+ throw markerError("the managed task markers are crossed (the end marker precedes the begin marker)", {
277
+ begin: begin.start,
278
+ end: end.start,
279
+ });
280
+ }
281
+ return { begin, end };
282
+ }
283
+
284
+ /**
285
+ * Build the fail-loud "malformed managed-block markers" error for the fixed `lore:tasks` region
286
+ * (ADR-0008 §2 → `validation`, exit 6). A thin specialization of {@link labeledMarkerError} so the
287
+ * tasks-block and generic-block diagnostics share one wording/shape and cannot drift.
288
+ */
289
+ function markerError(reason: string, input: Record<string, unknown>): LoreError {
290
+ return labeledMarkerError("lore:tasks", reason, input);
291
+ }
292
+
293
+ /**
294
+ * The `lore:tasks` region's full `[start, end)` byte span (markers included), located
295
+ * **structurally** — the same {@link collectMarkerSpans} mdast scan {@link findMarkers} uses — or
296
+ * `null` when the document carries no `lore:tasks` markers at all (most docs; only a linked
297
+ * `Story`/`Spec` do). Exported for `core/replace.ts`'s managed-region registry, which must protect
298
+ * exactly the span {@link regenerateTaskBlock} would rewrite.
299
+ *
300
+ * Deliberately **not** `indexes.ts`'s `locateManagedBlock` literal `indexOf` scan: that scan is safe
301
+ * for `lore:index` only because the marker text never occurs outside a real index block in practice,
302
+ * but `lore:tasks:begin`/`:end` are routinely *cited* in this project's own prose and fenced code
303
+ * examples documenting the format — a literal scan misfires on those (a false "duplicated"/"unmatched"
304
+ * validation error, or worse, silently treating a prose citation as a real block) (LORE-73). The
305
+ * structural, top-level-`html`-node-only location this module already uses for `lore sync`/`lore
306
+ * check` has no such ambiguity: a sentinel inside a code fence or blockquote is never a marker.
307
+ *
308
+ * @throws LoreError `validation` when markers are present but malformed (duplicated, unmatched, or
309
+ * crossed) — the same fail-loud contract {@link findMarkers} enforces. Total absence is `null`, not
310
+ * an error: a file with no `lore:tasks` block has nothing to protect.
311
+ */
312
+ export function locateTaskBlock(content: string): { start: number; end: number } | null {
313
+ const { begins, ends, malformed } = collectMarkerSpans(content, BEGIN_MARKER, END_MARKER);
314
+ if (begins.length === 0 && ends.length === 0 && malformed.length === 0) {
315
+ return null;
316
+ }
317
+ const { begin, end } = findMarkers(content);
318
+ return { start: begin.start, end: end.end };
319
+ }
320
+
321
+ /**
322
+ * Build the frozen table string (no leading/trailing newline) for `rows`, or the "no linked tasks"
323
+ * paragraph when there are none. The format is fixed byte-for-byte so identical input yields identical
324
+ * output: a header row, a compact GFM delimiter, and one `| [id](link) | title | status |` row each.
325
+ */
326
+ function buildTable(rows: readonly ManagedTaskRow[], docPath: string): string {
327
+ if (rows.length === 0) {
328
+ return NO_TASKS_PARAGRAPH;
329
+ }
330
+ const lines = [TABLE_HEADER, TABLE_DELIMITER];
331
+ for (const row of rows) {
332
+ lines.push(renderRow(row, docPath));
333
+ }
334
+ return lines.join("\n");
335
+ }
336
+
337
+ /**
338
+ * Render one task as a table row. The id becomes the link text; its target is the canonical relative
339
+ * link to the task file ({@link normalizeLink} over the repo-relative `docPath` and `file`). When the
340
+ * file is absent — `null`, or the empty string a not-yet-written task can carry — the id is rendered
341
+ * as plain text: the task still appears, marked, rather than linking to a broken `..md` target or
342
+ * erroring (ADR-0008 §5 tolerance).
343
+ */
344
+ function renderRow(row: ManagedTaskRow, docPath: string): string {
345
+ const label = escapeLinkText(row.id);
346
+ const taskCell = row.file === null || row.file === "" ? label : `[${label}](${normalizeLink(docPath, row.file)})`;
347
+ return `| ${taskCell} | ${cell(row.title)} | ${cell(row.status)} |`;
348
+ }
349
+
350
+ /**
351
+ * Normalize one JSON value into safe GFM **table-cell** text. Four deterministic defenses keep the
352
+ * table well-formed and byte-stable regardless of the source string:
353
+ *
354
+ * - **single-line** ({@link singleLine}) — a title carrying a newline would otherwise split the row;
355
+ * - **escape `\`** — done *first*, before the `|` escape below. A pre-existing literal backslash
356
+ * immediately before a pipe (e.g. a title `x\|y`) would otherwise combine with the pipe-escape's
357
+ * inserted backslash into CommonMark's `\\` (an escaped backslash) followed by a live, cell-splitting
358
+ * `|` — silently adding a column. Doubling backslashes first, before any escaping backslashes are
359
+ * introduced, means the later steps' own backslashes are never mistaken for source content and never
360
+ * re-escaped.
361
+ * - **escape `|`** — an unescaped pipe would open a spurious extra column;
362
+ * - **neutralize the comment sentinels** (`<!--`/`-->` → entities) — a value literally containing an
363
+ * end marker cannot then be mistaken for the region boundary (defense-in-depth beyond the structural
364
+ * location; matches {@link generateIndexes}'s `linkText`). The entities render identically.
365
+ */
366
+ function cell(text: string): string {
367
+ return singleLine(text)
368
+ .replace(/\\/g, "\\\\")
369
+ .replace(/\|/g, "\\|")
370
+ .replace(/<!--/g, "&lt;!--")
371
+ .replace(/-->/g, "--&gt;");
372
+ }
373
+
374
+ /** Cell text for the id used as link **text**: additionally escape `[`/`]` so they cannot break the `[text](…)` syntax. */
375
+ function escapeLinkText(id: string): string {
376
+ return cell(id).replace(/[[\]]/g, (c) => `\\${c}`);
377
+ }
378
+
379
+ /** A node's `[start, end)` source byte offsets, or `null` when position info is absent (defensive). */
380
+ function offsetsOf(node: Nodes): Marker | null {
381
+ const position = node.position;
382
+ if (position?.start.offset === undefined || position.end.offset === undefined) {
383
+ return null;
384
+ }
385
+ return { start: position.start.offset, end: position.end.offset };
386
+ }
387
+
388
+ /**
389
+ * Insert or refresh a generic lore-managed block delimited by `<!-- {label}:begin -->` …
390
+ * `<!-- {label}:end -->`, returning the new full file bytes.
391
+ *
392
+ * This is the **insert-or-update** sibling of {@link regenerateTaskBlock}. That engine owns the
393
+ * fixed `lore:tasks` region and deliberately *requires* an author-placed marker pair (a missing
394
+ * pair is a hard error), because a Story's task block only ever regenerates in a doc that already
395
+ * declares it. `lore agents`'s `CLAUDE.md` nudge is the opposite shape: lore must be able to add
396
+ * the block to a file that has never seen it. So this function tolerates a total absence of markers
397
+ * (it appends the block) while keeping every other guarantee of {@link regenerateTaskBlock} —
398
+ * structural, whitespace-tolerant location and fail-loud validation of a malformed pair.
399
+ *
400
+ * `label` names the region (`"lore:agents"`); `body` is the pre-rendered inner content, with no
401
+ * surrounding newlines (the engine adds exactly one on each side).
402
+ *
403
+ * - **No markers present** → append the block after the file's existing content, which is preserved
404
+ * **byte-for-byte** (including an unrelated managed block like Backlog.md's, and any trailing
405
+ * whitespace); only the separation needed to guarantee a blank line before the block is added. An
406
+ * empty or whitespace-only file yields the block alone. If the existing content ends inside an
407
+ * unterminated code fence or `<!--` comment — which would swallow the appended markers so they are
408
+ * not at the top level — this is a fail-loud `validation` error rather than a silent, duplicating
409
+ * append (a later run, finding no top-level markers, would otherwise append a *second* block).
410
+ * - **Exactly one balanced pair** → splice `\n{body}\n` between the markers, copying every other
411
+ * byte. The insert and update forms converge on the same canonical bytes, so regenerating an
412
+ * already-current block reproduces byte-identical output (idempotent) — the command layer can
413
+ * treat "no byte difference" as a genuine no-op. The result is re-located the same way the insert
414
+ * form is: a `body` that itself contains marker-like text, or disrupts top-level parsing (e.g. an
415
+ * unterminated code fence), is a fail-loud `validation` error rather than silently corrupted content.
416
+ * - **Malformed** (a lone begin/end, duplicated markers, or a crossed pair) → a `validation`
417
+ * {@link LoreError} (exit 6); lore refuses to guess and never writes a partial block.
418
+ *
419
+ * Input is expected LF-normalized (the caller normalizes on read, as every lore read path does).
420
+ */
421
+ export function upsertManagedBlock(content: string, options: { label: string; body: string }): string {
422
+ const { label, body } = options;
423
+ const block = `<!-- ${label}:begin -->\n${body}\n<!-- ${label}:end -->`;
424
+ const located = locateLabeledMarkers(content, label);
425
+ if (located !== null) {
426
+ // Update: replace only the bytes strictly between the markers with `\n{body}\n` — a fixpoint over
427
+ // already-current bytes (the begin node ends just after its `-->`, matching the insert form below).
428
+ const updated = `${content.slice(0, located.begin.end)}\n${body}\n${content.slice(located.end.start)}`;
429
+ // The splice must still parse as a single clean top-level marker pair. If `body` itself contains
430
+ // marker-like text, or opens an unterminated code fence/comment that swallows a marker, the
431
+ // structure breaks — re-locate in the result and fail loud instead of silently returning corrupted
432
+ // content (mirrors the insert branch's post-condition check below).
433
+ if (locateLabeledMarkers(updated, label) === null) {
434
+ throw labeledMarkerError(
435
+ label,
436
+ "the updated body disrupts the document's top-level marker structure, so the block can no longer be located",
437
+ { label },
438
+ );
439
+ }
440
+ return updated;
441
+ }
442
+ // Insert: append the block, preserving `content` verbatim (only the blank-line separation is added).
443
+ const inserted = appendBlock(content, block);
444
+ // The append must land at the document top level. If `content` ends inside an unterminated code
445
+ // fence or `<!--` comment, the appended markers are parsed *inside* that construct and are not
446
+ // top-level nodes — a re-run would find none and append again, multiplying the block. Detect that
447
+ // by re-locating in the result and fail loud instead of silently duplicating.
448
+ if (locateLabeledMarkers(inserted, label) === null) {
449
+ throw labeledMarkerError(
450
+ label,
451
+ "the document ends inside an unterminated code fence or `<!--` comment, so the block cannot be appended at the top level",
452
+ { label },
453
+ );
454
+ }
455
+ return inserted;
456
+ }
457
+
458
+ /**
459
+ * Append `block` after `content`, preserving `content` byte-for-byte and adding only the separation
460
+ * needed to guarantee a blank line before the block. An empty or whitespace-only `content` yields the
461
+ * block alone (no leading blank lines on an otherwise-empty file).
462
+ */
463
+ function appendBlock(content: string, block: string): string {
464
+ if (!/\S/.test(content)) {
465
+ return `${block}\n`;
466
+ }
467
+ const separator = content.endsWith("\n\n") ? "" : content.endsWith("\n") ? "\n" : "\n\n";
468
+ return `${content}${separator}${block}\n`;
469
+ }
470
+
471
+ /** Escape a literal for safe embedding in a `RegExp`. The label is lore-internal, but the matcher stays robust. */
472
+ function escapeRegExp(literal: string): string {
473
+ return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
474
+ }
475
+
476
+ /**
477
+ * Locate the balanced `<!-- {label}:begin -->` / `<!-- {label}:end -->` marker pair in `content`, or
478
+ * `null` when the file carries neither marker (the insert case {@link upsertManagedBlock} needs).
479
+ * Shares {@link collectMarkerSpans} with {@link findMarkers} (so marker *location* never drifts
480
+ * between the two); the one behavioral difference is that a *total* absence returns `null` instead of
481
+ * throwing — a marker pair present yet malformed (a lone begin/end, duplicated, crossed, or collapsed
482
+ * onto one line with no separating newline — LORE-156) is still a fail-loud `validation` error, never
483
+ * read as "no block yet" (which would make {@link upsertManagedBlock} append a second, duplicate block
484
+ * alongside the untouched malformed pair).
485
+ *
486
+ * @throws LoreError `validation` when markers are present but malformed.
487
+ */
488
+ function locateLabeledMarkers(content: string, label: string): { begin: Marker; end: Marker } | null {
489
+ const beginMarker = new RegExp(`^<!--\\s*${escapeRegExp(label)}:begin\\s*-->$`);
490
+ const endMarker = new RegExp(`^<!--\\s*${escapeRegExp(label)}:end\\s*-->$`);
491
+ const { begins, ends, malformed } = collectMarkerSpans(content, beginMarker, endMarker);
492
+
493
+ if (malformed.length > 0) {
494
+ // Marker text is present but not a clean, standalone sentinel — most commonly a begin/end pair
495
+ // mdast collapsed onto one line (LORE-156). This must never be read as "no block yet": returning
496
+ // null here would make the caller append a fresh block after the untouched malformed pair,
497
+ // silently duplicating it.
498
+ throw labeledMarkerError(
499
+ label,
500
+ `found marker text that is not a clean, standalone \`<!-- ${label}:begin -->\`/\`<!-- ${label}:end -->\` sentinel on its own line (commonly a begin/end pair placed on the same line with no separating newline): \`${malformed[0]?.value ?? ""}\``,
501
+ { malformed: malformed.length },
502
+ );
503
+ }
504
+ if (begins.length === 0 && ends.length === 0) {
505
+ return null; // no block yet — the caller inserts one
506
+ }
507
+ if (begins.length !== 1 || ends.length !== 1) {
508
+ throw labeledMarkerError(
509
+ label,
510
+ `expected exactly one \`<!-- ${label}:begin -->\` and one \`<!-- ${label}:end -->\`, found ${begins.length} begin and ${ends.length} end`,
511
+ { begins: begins.length, ends: ends.length },
512
+ );
513
+ }
514
+ const begin = begins[0];
515
+ const end = ends[0];
516
+ if (begin === undefined || end === undefined) {
517
+ return null; // unreachable given the counts above; narrows the element access without a non-null assertion
518
+ }
519
+ if (end.start < begin.end) {
520
+ throw labeledMarkerError(label, "the markers are crossed (the end marker precedes the begin marker)", {
521
+ begin: begin.start,
522
+ end: end.start,
523
+ });
524
+ }
525
+ return { begin, end };
526
+ }
527
+
528
+ /**
529
+ * Build the fail-loud "malformed managed-block markers" error for a labeled block (`validation`,
530
+ * exit 6). The one builder behind both the generic and `lore:tasks`-specific ({@link markerError})
531
+ * diagnostics, so their wording and shape stay in lockstep.
532
+ */
533
+ function labeledMarkerError(label: string, reason: string, input: Record<string, unknown>): LoreError {
534
+ return new LoreError(
535
+ "validation",
536
+ `cannot regenerate the \`${label}\` block: ${reason}`,
537
+ `place exactly one \`<!-- ${label}:begin -->\` and one \`<!-- ${label}:end -->\` on their own lines, in order, at the top level of the doc`,
538
+ input,
539
+ );
540
+ }