doctrina-cli 0.15.1 → 0.17.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 (93) hide show
  1. package/README.md +27 -72
  2. package/package.json +3 -3
  3. package/src/commands/adapter.js +27 -3
  4. package/src/commands/analyze.js +16 -212
  5. package/src/commands/change.js +144 -267
  6. package/src/commands/ci.js +182 -0
  7. package/src/commands/clarify.js +65 -21
  8. package/src/commands/close.js +349 -45
  9. package/src/commands/completion.js +8 -2
  10. package/src/commands/context.js +284 -111
  11. package/src/commands/contract.js +95 -75
  12. package/src/commands/coverage.js +58 -273
  13. package/src/commands/decision.js +66 -7
  14. package/src/commands/doctor.js +293 -130
  15. package/src/commands/handoff.js +10 -57
  16. package/src/commands/hooks.js +17 -3
  17. package/src/commands/index-rebuild.js +55 -44
  18. package/src/commands/init.js +88 -16
  19. package/src/commands/intake.js +85 -99
  20. package/src/commands/intent.js +14 -1
  21. package/src/commands/metrics.js +65 -88
  22. package/src/commands/next.js +105 -147
  23. package/src/commands/prime.js +23 -91
  24. package/src/commands/report.js +40 -109
  25. package/src/commands/review.js +68 -36
  26. package/src/commands/search.js +18 -4
  27. package/src/commands/show.js +24 -9
  28. package/src/commands/skill.js +23 -39
  29. package/src/commands/spec.js +38 -11
  30. package/src/commands/status.js +69 -171
  31. package/src/commands/templates.js +35 -148
  32. package/src/commands/trace.js +36 -71
  33. package/src/commands/triage.js +4 -82
  34. package/src/commands/upgrade.js +31 -6
  35. package/src/commands/validate.js +44 -877
  36. package/src/commands/verify.js +60 -138
  37. package/src/commands/why.js +2 -2
  38. package/src/commands/work.js +243 -267
  39. package/src/index.js +139 -12
  40. package/src/lib/actions.js +490 -0
  41. package/src/lib/adapters.js +0 -11
  42. package/src/lib/adr-guard.js +19 -0
  43. package/src/lib/agent-changelog.js +133 -0
  44. package/src/lib/analysis.js +264 -0
  45. package/src/lib/args.js +14 -0
  46. package/src/lib/change-ops.js +133 -0
  47. package/src/lib/commands.js +135 -10
  48. package/src/lib/config.js +206 -0
  49. package/src/lib/constitution-model.js +102 -0
  50. package/src/lib/coverage-model.js +431 -0
  51. package/src/lib/criteria.js +23 -0
  52. package/src/lib/doc-model.js +370 -3
  53. package/src/lib/docs-impact.js +270 -27
  54. package/src/lib/ears.js +24 -1
  55. package/src/lib/flag-catalog.js +0 -16
  56. package/src/lib/fs-ops.js +58 -1
  57. package/src/lib/gates.js +231 -29
  58. package/src/lib/git.js +132 -0
  59. package/src/lib/index-json.js +42 -24
  60. package/src/lib/intake-model.js +155 -0
  61. package/src/lib/json-out.js +100 -2
  62. package/src/lib/ledger.js +192 -0
  63. package/src/lib/lexicon.js +228 -0
  64. package/src/lib/metrics-model.js +189 -0
  65. package/src/lib/names.js +42 -0
  66. package/src/lib/playbook.js +97 -0
  67. package/src/lib/project.js +105 -0
  68. package/src/lib/reproducibility.js +137 -0
  69. package/src/lib/runtime.js +191 -5
  70. package/src/lib/scan.js +328 -25
  71. package/src/lib/signoff.js +147 -0
  72. package/src/lib/snapshot.js +231 -0
  73. package/src/lib/spec-ops.js +72 -8
  74. package/src/lib/templates-model.js +289 -0
  75. package/src/lib/trace-model.js +95 -0
  76. package/src/lib/triage-model.js +121 -0
  77. package/src/lib/usage.js +6 -4
  78. package/src/lib/validation-model.js +1381 -0
  79. package/src/lib/version.js +22 -0
  80. package/src/lib/views.js +413 -0
  81. package/src/lib/work-model.js +102 -0
  82. package/templates/AGENTS.md.template +21 -28
  83. package/templates/README.md +35 -0
  84. package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +6 -3
  85. package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +4 -3
  86. package/templates/change/proposal.md.template +16 -0
  87. package/templates/change/tasks.md.template +3 -8
  88. package/templates/contract.md.template +2 -2
  89. package/templates/hooks/pre-commit.sample +16 -2
  90. package/templates/playbooks/bootstrap.md.template +47 -0
  91. package/templates/playbooks/chore.md.template +24 -0
  92. package/templates/playbooks/work.md.template +83 -0
  93. package/src/commands/constitution.js +0 -145
@@ -18,6 +18,9 @@
18
18
  // a non-canonical form is REPORTED (so `validate --fix` can repair it)
19
19
  // rather than silently accepted or silently missed.
20
20
  // STRICT ON WRITE. One canonical form per artifact kind, always.
21
+ import path from "node:path";
22
+ import { read } from "./fs-ops.js";
23
+ import { locateTemplatesDir } from "./templates.js";
21
24
 
22
25
  // Artifact kinds and the header form each one canonically uses. Specs and
23
26
  // contracts use bare bold; ADRs, proposals, and the intake use list items.
@@ -90,7 +93,12 @@ function headerPattern(name) {
90
93
  * line: number, conforming: boolean } | null}
91
94
  */
92
95
  export function readHeader(text, name) {
93
- const m = headerPattern(name).exec(String(text));
96
+ // Match against the MASKED text so a header written inside an HTML comment
97
+ // — a template's example of the form, say — is never mistaken for one the
98
+ // author wrote. Masking replaces comment content with spaces and keeps
99
+ // every line terminator, so offsets and line numbers are the document's own
100
+ // and `raw` below is still the real line.
101
+ const m = headerPattern(name).exec(maskComments(String(text)));
94
102
  if (!m) return null;
95
103
  const [raw, indent, dash, value] = m;
96
104
  const style = dash ? HEADER_STYLE.LIST : HEADER_STYLE.BARE;
@@ -130,7 +138,13 @@ export function setHeader(text, name, value, opts = {}) {
130
138
  const useStyle = style ?? existing.style;
131
139
  const prefix = useStyle === HEADER_STYLE.LIST ? "- " : "";
132
140
  const replacement = `${existing.indent}${prefix}**${name}:** ${value}`.replace(/\s+$/, "");
133
- return String(text).replace(headerPattern(name), replacement);
141
+ // Write over the line `readHeader` actually found, by OFFSET. Replacing on
142
+ // a pattern would find the first textual match, which — now that reading
143
+ // skips annotation — may be an example inside a comment sitting above the
144
+ // real header. Masking preserves offsets, so the index is the real one.
145
+ const at = maskComments(String(text)).search(headerPattern(name));
146
+ if (at < 0) return null;
147
+ return String(text).slice(0, at) + String(text).slice(at).replace(headerPattern(name), replacement);
134
148
  }
135
149
 
136
150
  // The PREAMBLE: everything before the first `## ` section. Metadata headers
@@ -147,7 +161,22 @@ export function preamble(text) {
147
161
  // Every header in the document's preamble, in order, with conformance
148
162
  // recorded.
149
163
  export function readAllHeaders(fullText) {
150
- const text = preamble(fullText);
164
+ // AN HTML COMMENT IS ANNOTATION, NOT A HEADER.
165
+ //
166
+ // This module is the one owner of that rule — every other reader in the
167
+ // tree defers to it — and this function did not apply it. The preamble is
168
+ // exactly where a template puts its guidance, so an EXAMPLE header written
169
+ // to show an author the form counted as a header the author wrote.
170
+ //
171
+ // It showed as a disagreement rather than a wrong answer: `readHeader`
172
+ // takes the first match in the whole document, this one collects every
173
+ // match in the preamble, and a commented example made the two return
174
+ // different sets. A round-trip test on the real tree caught it the moment
175
+ // the proposal template gained such an example.
176
+ //
177
+ // Masking preserves line terminators, so the reported line numbers are the
178
+ // document's own either way.
179
+ const text = preamble(maskComments(fullText));
151
180
  const out = [];
152
181
  const re = /^([ \t]*)(-[ \t]+)?\*\*([A-Za-z][A-Za-z .-]*?)[ \t]*(?::\*\*|\*\*[ \t]*:)[ \t]*(.*?)[ \t]*$/gm;
153
182
  let m;
@@ -285,3 +314,341 @@ export function parseArtifact(text, { kind = ARTIFACT_KIND.UNKNOWN } = {}) {
285
314
  nonConforming: nonConformingHeaders(text, kind),
286
315
  };
287
316
  }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // The rest of the on-disk grammar (ADR 0021, change 0043)
320
+ // ---------------------------------------------------------------------------
321
+ //
322
+ // ADR 0021 declares ONE document model that owns how a Doctrina artifact is
323
+ // read off disk. Three parsers lived outside it — skill frontmatter in a
324
+ // command module, and the two delta parsers in another — and `lib/scan.js`
325
+ // imported them FROM `commands/`, inverting the dependency the layering
326
+ // depends on. Change 0037 broke that edge by moving them into lib/; this is
327
+ // the second half: they belong to the module the ADR names, not to two more
328
+ // libraries beside it.
329
+ //
330
+ // Pure text predicates, all of them. Nothing here reads a file.
331
+
332
+ export function parseFrontmatter(text, key) {
333
+ // Match frontmatter blocks bounded by `---` lines at start of file.
334
+ const fmMatch = text.match(/^---\s*\n([\s\S]*?)\n---\s*\n/);
335
+ if (!fmMatch) return null;
336
+ const block = fmMatch[1];
337
+ const lineRe = new RegExp(`^${key}\\s*:\\s*(.+)$`, "m");
338
+ const m = block.match(lineRe);
339
+ return m ? m[1].trim() : null;
340
+ }
341
+
342
+ export function parseOperation(text) {
343
+ const m = text.match(/^\*\*Operation:\*\*\s*([A-Z]+)/m);
344
+ if (!m) return null;
345
+ const op = m[1];
346
+ if (op === "ADDED" || op === "MODIFIED" || op === "REMOVED") return op;
347
+ return null;
348
+ }
349
+
350
+ export function parseCapabilityFromDelta(text, deltaPath) {
351
+ // Prefer the explicit header "# Spec Delta — capability: <name>"
352
+ const m = text.match(/^#\s+Spec Delta\s*[—-]\s*capability:\s*([a-z][a-z0-9-]*)/m);
353
+ if (m) return m[1];
354
+ // Fall back to the parent directory name of the delta file
355
+ const parent = path.basename(path.dirname(deltaPath));
356
+ if (/^[a-z][a-z0-9-]*$/.test(parent)) return parent;
357
+ return null;
358
+ }
359
+
360
+ /**
361
+ * The value a shipped template writes for `name`, or null when the template
362
+ * cannot be located (an unusual install) or does not carry that header.
363
+ *
364
+ * @param {string} templateName e.g. "spec.md.template"
365
+ * @param {string} name
366
+ * @returns {string|null}
367
+ */
368
+ export function templateHeaderValue(templateName, name) {
369
+ try {
370
+ return getHeader(read(path.join(locateTemplatesDir(), templateName)), name);
371
+ } catch {
372
+ return null;
373
+ }
374
+ }
375
+
376
+ /**
377
+ * Is this header value still the scaffold's placeholder?
378
+ *
379
+ * A check written to catch a MISSING header is dead in the normal flow when
380
+ * the scaffold writes a value for it: the escape hatch arrives pre-armed
381
+ * (change 0057). The templates mark a placeholder by wrapping the whole
382
+ * value in angle brackets, so that is the portable rule; when the templates
383
+ * can be located, the template's own value settles it exactly.
384
+ *
385
+ * @param {string|null|undefined} value
386
+ * @param {{ template?: string, name?: string }} [opts]
387
+ * @returns {boolean}
388
+ */
389
+ export function isPlaceholderHeaderValue(value, opts = {}) {
390
+ if (value === null || value === undefined) return true;
391
+ const v = String(value).trim();
392
+ if (v === "") return true;
393
+ // `<...>` wrapping the WHOLE value — the templates' placeholder convention.
394
+ // A real value may still contain angle brackets ("n/a — <why>" does not,
395
+ // but a prose value could), which is why the whole value must be wrapped.
396
+ if (/^<[\s\S]*>$/.test(v)) return true;
397
+ if (opts.template && opts.name) {
398
+ const tpl = templateHeaderValue(opts.template, opts.name);
399
+ if (tpl !== null && tpl.trim() === v) return true;
400
+ }
401
+ return false;
402
+ }
403
+
404
+ /**
405
+ * Every GitHub-style checkbox in `text`, in reading order.
406
+ *
407
+ * One owner for the box grammar (ADR 0021). It had six, and they disagreed:
408
+ * `snapshot.js` required TEXT after the box (`\[[ xX]\]\s+(.*)`), so the three
409
+ * empty placeholders `work` scaffolds were invisible to it and `prime` printed
410
+ * "tasks 0/3" for a change with six open boxes — the three it counted being
411
+ * the closing steps, not the work. `gates.js` required no text and counted
412
+ * six; `change tick` counted eight, adding the proposal's Verification boxes.
413
+ * Three numbers for the same file, side by side in the same session.
414
+ *
415
+ * Each entry carries whether the box is a scaffold PLACEHOLDER — a box with
416
+ * nothing written after it — because that is a real distinction (`analyze`
417
+ * refuses a change that still has them) and it is the one the counters must
418
+ * agree to make explicitly rather than by accident.
419
+ *
420
+ * @param {string} text
421
+ * @param {{ section?: string|null }} [opts] limit to one `## ` section
422
+ * @returns {Array<{ line: number, checked: boolean, text: string, placeholder: boolean, section: string|null }>}
423
+ */
424
+ export function parseChecklist(text, opts = {}) {
425
+ const section = opts.section ?? null;
426
+ const lines = String(text).split(/\r?\n/);
427
+ const out = [];
428
+ let inSection = section === null;
429
+ let heading = null;
430
+ for (let i = 0; i < lines.length; i++) {
431
+ if (/^##\s+/.test(lines[i])) {
432
+ heading = lines[i].replace(/^##\s+/, "").trim();
433
+ if (section !== null) {
434
+ inSection = new RegExp(`^##\\s+${section.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i")
435
+ .test(lines[i]);
436
+ }
437
+ continue;
438
+ }
439
+ if (!inSection) continue;
440
+ // Any Markdown bullet marker opens a box (change 0104): `* [ ]` and
441
+ // `+ [ ]` are task items to every renderer, and a box the grammar
442
+ // cannot see is one `tick` cannot tick and the archive gate cannot
443
+ // count — an unchecked task that closes green.
444
+ const m = /^\s*[-*+]\s*\[([ xX])\]\s*(.*)$/.exec(lines[i]);
445
+ if (!m) continue;
446
+ const body = m[2].trim();
447
+ out.push({ line: i, checked: m[1] !== " ", text: body, placeholder: body === "", section: heading });
448
+ }
449
+ return out;
450
+ }
451
+
452
+ /**
453
+ * How many boxes a checklist holds and how many are done — the pair every
454
+ * progress line prints. Placeholders count: a box nobody wrote is a box
455
+ * nobody finished, and hiding it is what made "0/3" mean six.
456
+ *
457
+ * @param {string} text
458
+ * @param {{ section?: string|null }} [opts]
459
+ * @returns {{ total: number, done: number, open: string[], placeholders: number }}
460
+ */
461
+ // A tasks.md scaffolded before change 0195 ends with "## Closing steps" —
462
+ // apply, archive, update the index. Those are what `doctrina close` DOES,
463
+ // so asking for them ticked before the close ran was asking the agent to
464
+ // claim three things that had not happened yet. The template no longer
465
+ // writes them; a change that still carries them is not held to them.
466
+ const CLOSING_STEPS = /^closing steps$/i;
467
+
468
+ export function checklistProgress(text, opts = {}) {
469
+ const boxes = parseChecklist(text, opts)
470
+ .filter((b) => opts.section != null || !CLOSING_STEPS.test(b.section ?? ""));
471
+ return {
472
+ total: boxes.length,
473
+ done: boxes.filter((b) => b.checked).length,
474
+ open: boxes.filter((b) => !b.checked).map((b) => b.text),
475
+ placeholders: boxes.filter((b) => b.placeholder).length,
476
+ };
477
+ }
478
+
479
+ /**
480
+ * Is this section still the shipped template's — empty, or nothing but the
481
+ * template's own instructional comment and placeholder?
482
+ *
483
+ * The framework writes artifacts from templates and then trusts that someone
484
+ * filled them in. Exactly one gate checked that (`analyze`, over a change
485
+ * proposal); everything else took the mould for a decision (change 0065).
486
+ * This is the same ruler `isPlaceholderHeaderValue` applies to a header,
487
+ * applied to a section body: an HTML comment is annotation (change 0055), so
488
+ * a section that is only annotation is a section nobody wrote.
489
+ *
490
+ * @param {string} text the whole artifact
491
+ * @param {string} name the `## ` section name
492
+ * @param {{ template?: string }} [opts] template file to compare against
493
+ * @returns {boolean}
494
+ */
495
+ export function isUnwrittenSection(text, name, opts = {}) {
496
+ const body = getSection(text, name);
497
+ if (body === null) return true;
498
+ const prose = maskComments(body).trim();
499
+ if (prose === "") return true;
500
+ // A body that is nothing but the template's placeholder bullets or an
501
+ // angle-bracket placeholder is unwritten too.
502
+ const stripped = prose.replace(/^[-*]\s*$/gm, "").trim();
503
+ if (stripped === "") return true;
504
+ if (/^<[\s\S]*>$/.test(stripped)) return true;
505
+ if (opts.template) {
506
+ try {
507
+ const tpl = read(path.join(locateTemplatesDir(), opts.template));
508
+ const tplBody = getSection(tpl, name);
509
+ if (tplBody !== null && maskComments(tplBody).trim() === prose) return true;
510
+ } catch {
511
+ // No templates dir — the checks above already carry the common cases.
512
+ }
513
+ }
514
+ return false;
515
+ }
516
+
517
+ /**
518
+ * The named sections of `text` that nobody has written yet, in order.
519
+ *
520
+ * @param {string} text
521
+ * @param {string[]} names
522
+ * @param {{ template?: string }} [opts]
523
+ * @returns {string[]}
524
+ */
525
+ export function unwrittenSections(text, names, opts = {}) {
526
+ return names.filter((name) => isUnwrittenSection(text, name, opts));
527
+ }
528
+
529
+ // Is the on-disk spec still the untouched `spec new <cap>` scaffold? Precise
530
+ // check: render the shipped capability template for the same capability and
531
+ // compare, ignoring the date-bearing "Last updated" line and whitespace
532
+ // normalisation. When the template cannot be located (unusual installs),
533
+ // fall back to the scaffold's own placeholder fingerprints — text no real
534
+ // spec keeps. Used by `change apply` so an ADDED delta can replace a
535
+ // scaffold (the canonical spec-new → delta flow) without ever clobbering a
536
+ // spec that carries real content.
537
+ export function isUntouchedScaffold(specText, capability) {
538
+ const normalize = (s) =>
539
+ s.replace(/\r\n/g, "\n")
540
+ .split("\n")
541
+ .filter((line) => !/^\*\*Last updated:\*\*/.test(line))
542
+ .join("\n")
543
+ .trim();
544
+ try {
545
+ const tplPath = path.join(locateTemplatesDir(), "spec.md.template");
546
+ const rendered = read(tplPath)
547
+ .replace(/\{\{CAPABILITY\}\}/g, capability)
548
+ .replace(/\{\{DATE\}\}/g, "");
549
+ if (normalize(rendered) === normalize(specText)) return true;
550
+ } catch {
551
+ // fall through to the fingerprint heuristic
552
+ }
553
+ // Fingerprints: the Purpose placeholder comment AND an empty Ubiquitous
554
+ // section survive only in a scaffold nobody edited.
555
+ return (
556
+ specText.includes("<!-- One paragraph: what this capability does and why it exists. -->") &&
557
+ /##\s+Requirements \(EARS\)[\s\S]*?### Ubiquitous\s*\n\s*-\s*\n/.test(specText)
558
+ );
559
+ }
560
+
561
+
562
+ /**
563
+ * The byte ranges every HTML comment occupies in `text`, in file order.
564
+ *
565
+ * A comment is annotation, not content: the scaffolded spec carries the
566
+ * five-line EARS legend inside one, and the guessed delta (change 0044)
567
+ * carries its `RANKED GUESS` note inside another. Every reader of an
568
+ * on-disk artifact has to agree on that, so the ranges are computed HERE
569
+ * and nowhere else (ADR 0021 — one owner for the on-disk grammar).
570
+ *
571
+ * @param {string} text
572
+ * @returns {Array<[number, number]>}
573
+ */
574
+ export function commentRanges(text) {
575
+ /** @type {Array<[number, number]>} */
576
+ const ranges = [];
577
+ for (const m of text.matchAll(/<!--[\s\S]*?-->/g)) {
578
+ ranges.push([m.index ?? 0, (m.index ?? 0) + m[0].length]);
579
+ }
580
+ return ranges;
581
+ }
582
+
583
+ /**
584
+ * Is this offset inside one of `ranges`?
585
+ *
586
+ * @param {Array<[number, number]>} ranges
587
+ * @param {number} offset
588
+ * @returns {boolean}
589
+ */
590
+ export function isInsideComment(ranges, offset) {
591
+ return ranges.some(([a, b]) => offset >= a && offset < b);
592
+ }
593
+
594
+ /**
595
+ * `text` with every HTML comment blanked out — same length, same line
596
+ * count, same offset for every character that survives.
597
+ *
598
+ * This is the skip-by-position rule of `spec-ops.matchOpsBlock` in a form
599
+ * a line- or regex-oriented scanner can use directly: a bullet inside a
600
+ * comment stops looking like a bullet, and a command name inside one stops
601
+ * looking like a reference, WITHOUT any surviving character moving. Masking
602
+ * is for SCANNING only. Never write the masked text back to disk, and never
603
+ * derive an authored value from it — a delta whose `append-criterion` value
604
+ * legitimately contains `<!-- illustrative -->` must land with the marker
605
+ * intact, which is why `applyOps` still works on the raw text.
606
+ *
607
+ * @param {string} text
608
+ * @returns {string}
609
+ */
610
+ export function maskComments(text) {
611
+ let out = text;
612
+ for (const [a, b] of commentRanges(text)) {
613
+ const blanked = text.slice(a, b).replace(/[^\r\n]/g, " ");
614
+ out = out.slice(0, a) + blanked + out.slice(b);
615
+ }
616
+ return out;
617
+ }
618
+
619
+ /**
620
+ * The title a change proposal's H1 states, or null.
621
+ *
622
+ * The H1 the template writes is `# Change <id> — <title>`, and the id itself
623
+ * contains hyphens (`NNNN-slug`) — which is what four separate copies of this
624
+ * regex kept getting wrong in four slightly different ways. The one that read
625
+ * `[^—-]*` for the id stopped at the FIRST hyphen, which in any multi-word id
626
+ * is the id's own, so `prime`, `handoff` and `report` printed the slug glued
627
+ * in front of the title on every change `work` had ever generated. The ones
628
+ * that read `\s*[—-]\s*` accepted a bare hyphen with no spaces around it, so
629
+ * an H1 with no separator at all had its last segment read as the title.
630
+ *
631
+ * The separator is a dash WITH whitespace on both sides; an id's hyphens
632
+ * never have that, which is the whole ambiguity, resolved. Both the em dash
633
+ * and the plain hyphen are accepted, because the character was never the
634
+ * problem — a hand-written H1 using `-` reads exactly as clearly.
635
+ *
636
+ * The `Change <id> —` prefix is optional: an H1 written without it is a title
637
+ * in its own right and is returned whole (ADR 0021 — one owner for the
638
+ * on-disk grammar).
639
+ *
640
+ * @param {string} text the proposal, or just its first line
641
+ * @returns {string|null}
642
+ */
643
+ export function parseChangeTitle(text) {
644
+ const first = String(text ?? "").split(/\r?\n/).find((l) => /^#\s+\S/.test(l));
645
+ if (!first) return null;
646
+ const m = first.match(/^#\s+(?:Change\s+\S+\s+[—-]\s+)?(.+)$/);
647
+ const title = m?.[1]?.trim();
648
+ return title ? title : null;
649
+ }
650
+
651
+ /** A value still wrapped in the template's angle brackets: `<one-sentence …>`. */
652
+ export function isScaffoldValue(value) {
653
+ return /^<[^<>]*>$/.test(String(value ?? "").trim());
654
+ }