Conversation
Extracts src/utils/markdown-query.ts into src/utils/mdq/ as a publish-ready package: MarkdownDoc + Selection, insert/remove verbs alongside query, a comment selector, frontmatter handling, JS-value matchers, and a planned jq-like CLI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
Frontmatter uses yaml's Document API (comment-preserving) rather than a hand-rolled parser; MarkdownDoc gains append/prepend; leading '.' is accepted in the CLI grammar; documents a fourth migration breakage class where MarkdownDoc === string silently stops a guard from firing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
11 tasks, 68 steps. Ordering keeps the repo green at every boundary: port behind a shim first, migrate the 11 write-return-type breaks second, then add features additively. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
removeRanges collapsed backwards whenever a node had no trailing space
token, which ate a blank line on '# A\n\n## B\n\ntext\n'. marked bakes
separators into heading and document-final paragraph raws, so the rule is
"collapse only at end-of-document".
insertAt used a global \n{3,} collapse that would rewrite blank lines
inside fenced code blocks; scoped it to the seam.
Also: selector error index is 8 not 7, YAML needs new Document({}) when
frontmatter is absent, and the --set CLI test now proves the old value is
gone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
Types moved to end of file and ternaries replaced with early returns, per repo style. All 1416 unit tests pass through the shim. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
Splits MarkdownQuery into MarkdownDoc (whole document) and Selection (matched ranges, extends MarkdownDoc). Every write returns a MarkdownDoc, so several edits compose in one expression. Migrates 17 call sites, not the 11 the plan predicted. The extras were found by type-checking on the type name repo-wide rather than by file path: a test helper fed a MarkdownDoc to marked.lexer, which does not coerce and threw at runtime. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
…tor errors comment matches block comments on their inner body, so anchored patterns like /^test/ work; html matches every html block on raw text. Regex flags are now honored instead of parsed and discarded, unknown selectors throw MdqSelectorError with a position instead of silently matching nothing, and table text-matching reaches cells rather than headers only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
Matchers can be a string (exact), RegExp (own flags) or predicate, so a dynamic value no longer has to be escaped into a selector string. The sugar layer lives on MarkdownDoc and Selection inherits it, rather than a separate abstract base: Selection already extends MarkdownDoc and overrides query(), so the eleven one-liners dispatch correctly with no extra class. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
remove() takes a node plus its adjacent space token and collapses only at end-of-document, because marked bakes separators into heading and document-final paragraph raws but emits them as siblings elsewhere. insertAt normalizes only the seam, so blank lines inside fenced code blocks survive. mdq() now returns MarkdownDoc rather than Selection, so document-level append/prepend are reachable; blocks() selects every top-level block for the one caller that read the whole document as a selection. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
addRow re-renders the table so column pipes stay aligned and preserves alignment markers; addItem copies the list's existing marker and indent and continues an ordered list's numbering. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
Uses YAML.parseDocument rather than parse/stringify, so nested maps, lists and block scalars parse correctly and comments survive a write. Verified against the knowledge and experience formats CLAUDE.md documents: raw marked lexes their --- block as a setext h2 titled 'url: /login...', mdq reports zero headings and typed frontmatter. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
runMdq returns { output, code } rather than writing to stdout or exiting,
so the whole surface is testable; bin/mdq.ts is the only place that
touches the process. Exit codes compose like grep: 0 match, 1 no match,
2 usage or selector error.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
Every example executed against the implementation before committing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
Selection extends MarkdownDoc to share the sugar layer, so it type-checks anywhere a MarkdownDoc does, but stringifies to its matched markdown rather than the whole document. That is the useful behaviour - a selection can be passed straight back as a fragment - but it was previously unpinned by any test and undocumented. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
The duplication review found two places where query.ts kept logic the spec assigns to edit.ts: - replaceEach reimplemented spliceRanges verbatim, so the one write path used by replace() and setEntry() was the only one not delegating. - setEntry inlined the whole entry-rewrite transformation; it now calls rewriteEntries, matching the shape of every other verb. Also: entryKey moves to edit.ts beside rewriteEntries, dedupeRanges becomes internal now that query.ts no longer needs it, two new ternaries are gone, a dead insertAt import is dropped, three hand-rolled /\s+$/ strips become trimEnd(), and isSectionSelector's /^section\d?$/ now matches the [1-6] every other depth check uses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
DenysKuchma
approved these changes
Sep 15, 2026
Collaborator
|
The npm mdq binary keeps a Bun shebang, unlike the other CLIs rewritten to Node. Be better add it to the build-npm.sh rewrite |
bin/mdq.ts is now Node-compatible, not Bun-only. Two real breaks fixed: - readFileSync(0, 'utf8') throws EAGAIN on Node when stdin is a pipe with no data ready, crashing with a stack trace. Bun tolerates it. Stdin is now read asynchronously. - Stdin was read whenever it was not a TTY, before knowing whether a file argument was given, so 'mdq h2 file.md' in a pipeline read input it never needed. runMdq now takes a lazy reader and calls it only when no file is given. Packaging mirrors prima-cli: src/utils/mdq/package.json declares the standalone manifest, scripts/build-mdq-npm.ts bundles both entries with bun build --target=node (marked, yaml and commander stay external) and emits declarations, and .github/workflows/publish-mdq.yml publishes on a release tagged mdq-*. The workflow's smoke test installs the tarball into a clean project and exercises the CLI, stdin, the EAGAIN case, exit codes and a library import on Node - the same sequence was run locally. The mdq bin is removed from explorbot's package.json so the two packages cannot collide on the binary name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
query.ts was 790 lines: 20 module-level helper functions and three error classes sitting above a two-class split, with a 120-line hand-rolled character-scanning parser for the selector grammar. Now 577 lines with the class first and three module functions after it. - MarkdownDoc and Selection collapse back into one MarkdownQuery holding source plus matches, as it was before. The query engine (section scoping, item extraction, text matching, narrowing) moves inside as private methods, below the public API. - The selector parser drops from 120 lines of peek/advance/readUntilAny closures to one sticky regex and a 40-line loop. The grammar is closed, which CLAUDE.md says is a regex, not a parser. - blocks() is gone - it only existed to paper over the two-class split - and with it the toString/text asymmetry: toString() is always the document, text() the selection. - Document-level append/prepend are gone; they had no caller. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
The grammar is bespoke, not a standard, and the README presented it as a given. Names the real alternative (remark/mdast + unist-util-select) and the three things that do not fall out of it, each verified against the stack rather than assumed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
# Conflicts: # CHANGELOG.md # docs/superpowers/plans/2026-09-15-mdq-package.md
edit.ts becomes MarkdownEditor: an object holding the source with remove/replace/insert/frontmatter/setFrontmatter on it, and the pure helpers (blockEnd, splitFrontmatter, table, item, entries) as statics. query.ts delegates every write to it instead of importing ten loose functions. Constants were largely restating each other. TOKEN_TYPES was eight identity pairs plus one real mapping, so only the mapping survives as TOKEN_ALIASES. TEXT_TOKENS is gone - tokenText falls through to the token's own text field, which is what the whitelist was approximating. The three that remain sit at the end of the file beside the types. Seven repeated @deprecated lines become one comment over the alias group. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Extracts
src/utils/markdown-query.tsintosrc/utils/mdq/as a standalone npm package that can query and edit markdown, plus a jq-like CLI runnable withnpx mdq.Design:
docs/superpowers/specs/2026-09-14-mdq-package-design.md· Plan:docs/superpowers/plans/2026-09-15-mdq-package.mdShape
One class, two files.
query.tsholdsMarkdownQuery— the document plus the set of blocks currently selected — with the query engine as private methods below the public API.edit.tsholds pure functions over(source, ranges); it imports types fromquery.tstype-only and never touches a class value, so the split is acyclic. Reads narrow the selection, writes return a fresh query over the edited document.toString()is always the whole document;text()is the current selection.New beyond the move
removeinsertBeforeinsertAfterprependappendaddRowaddItemsetEntry, each returning aMarkdownQueryso edits chain.commentselector — matches block comments on their inner body, socomment(/^test/)reaches the multi-line<!-- test … -->blocks thattest-plan-markdown.ts:122+hand-parses today. Plushtml.yaml's Document API, kept out of the token index, exposed viafrontmatter()/setFrontmatter(); comments survive a write. Without this,markedreads a knowledge file's---block as a setext heading titledurl: /login.string(exact),RegExp(own flags), or predicate, removing the hand-escaping atresearcher/focus.ts:77.sectionheadingtablecomment… each exactlyquery(sel, matcher), plusat/slice/exists.MdqSelectorErrorwith a position instead of silently matching nothing.npx mdq 'section("API") table' --json README.md. Exit codes compose like grep: 0 match, 1 no match, 2 error.Fixes to existing behaviour
/x/was always case-insensitive while"x"and~"x"were case-sensitive. Now/x/iis insensitive and/x/is not. One production call site depended on the old behaviour (researcher.ts:316) and is now explicit.table(~"GET")could never match a cell.readFileSync(0, 'utf8')throwsEAGAINon Node when stdin is a pipe with no data ready — Bun tolerates it, Node dies with a stack trace. Stdin is now read asynchronously, and only when no file argument is given.Migration
All 54 existing call sites keep working through a re-export shim at
src/utils/markdown-query.ts; deprecated aliases (gettoJsonkeyValuesetKeyValuemetabeforeafter) cover every read rename.Writes could not be shielded — they return
MarkdownQuerynow — so 17 call sites were migrated. The design doc predicted eleven; type-checking by type name repo-wide rather than by file path found six more. One deserves attention:research-result.tsdidif (updated === this.text) return;, which a class instance makes always-false — a guard that silently stops firing rather than crashing. Another fed the result tomarked.lexer, which does not coerce and threw at runtime.This repo runs
tscwith--noCheck, so none of that surfaces in a green CI. The gate used here wasbunx tsc -p tsconfig.json --noEmit | grep -E "MarkdownQuery|Markdown", which returns nothing.Whitespace
markedseparators are uneven — aheadingraw is"# A\n\n", a mid-documentparagraphraw is"para"with a siblingspacetoken, and a document-finalparagraphraw is"last\n"with none. The invariant is never zero blank lines between blocks, never more than one, and two rules follow, both derived from real token output:remove()collapses only at end-of-document. The obvious alternative ("no trailing space → trim backwards") turns'# A\n\n## B\n\ntext\n'into'# A\ntext\n'.insertAtnormalizes only the seam. A global\n{3,}collapse would rewrite blank lines inside fenced code blocks, whose raw genuinely contains them.Packaging
Mirrors how
prima-cliships.src/utils/mdq/package.jsonis the standalone manifest;scripts/build-mdq-npm.tsbundles both entries withbun build --target=node(marked, yaml, commander stay external) and emits declarations;.github/workflows/publish-mdq.ymlpublishes on a release taggedmdq-*, gated on that prefix so an explorbot release cannot publish it by accident.Result is 11.5 kB, 6 files, 3 dependencies. The workflow's smoke test installs the tarball into a clean project and exercises the CLI, stdin, the EAGAIN case, exit codes and a library import on Node — the same sequence was run locally. The
mdqbin is removed from explorbot'spackage.jsonso the two packages cannot collide on the binary name.To publish: cut a release tagged
mdq-v0.1.0.On the grammar
The selector grammar is bespoke — it is not a standard and has no upstream parser. The README now says so, names the real alternative (
unist-util-select, CSS selectors over mdast viacss-selector-parser), and states the three things that do not fall out of that stack: mdast is flat so there is nosection;remark-sectionizeadds the nesting but its nodes carry noposition, which splice-based editing needs; and mdast headings have no flat text field while CSS dropped:contains()andunist-util-selectignores the attributeiflag.A spike compiled the terse grammar to that CSS and got 23/23 identical results against this engine, so a migration is viable — at the cost of 5 packages / 2.0 MB becoming 79 / 7.9 MB, and rebuilding
edit.tson derived section ranges. Not done here; recorded so the option is understood.Tests
1547 unit + 138 prima, zero failures. The original 110-test suite moved to
tests/unit/mdq/query.test.tsintact as the regression net; six new suites cover frontmatter, selectors, sugar, edits, structural inserts and the CLI. Every README example was executed before committing.Regression
This touches 17 call sites across researcher, planner and experience-tracker. A regression run looks worthwhile, but the label is the maintainer's call — none was applied.
🤖 Generated with Claude Code
https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD