title: "The spec editor" description: "A hierarchical, typed specification tree with references, status tracking, AI assistance and markdown generation." nodes: 21 progress: 71% generated: ohm spec editor#

The spec editor#

A hierarchical, typed specification tree with references, status tracking, AI assistance and markdown generation.

Contents#

☑ Overview#

☑ Scope#

note

packages/core/src/specs.ts, apps/ui/src/views/SpecEditor.tsx and the specs/*.spec.json documents. This very document is written in that format — docs/specs/ is generated from data and published as both .spec.json and .spec.md.

☑ Specs are a tree of typed nodes, not a document#

decision

Prose in a wiki drifts from the code. A spec node has a kind, a status, a priority and typed references that point at real things in the project — a prompt, a curve, an experiment, a script line, another node — so the spec can be navigated, filtered and progress-tracked, and generating markdown is a rendering step rather than the source of truth.

☑ Model#

☑ SpecDoc and SpecNode#

data-model

SpecDoc  { version: 1, name, description?, updatedAt?, nodes: SpecNode[] }
SpecNode { id, title, kind, body?, status?, priority?, tags?, refs?,
           prompt?, collapsed?, children: SpecNode[] }

Kinds (15): section, requirement, api, component, data-model, test, flow, state, task, prompt, reference, note, question, decision, risk.

Statuses (7): none, todo, doing, blocked, review, done, cancelled — rendered as ☐ ◔ ⛔ 👁 ☑ ✗ (none is blank).

Priority: low, normal, high, urgent — only non-normal is shown.

prompt is { text?, profile?, temperature?, maxTokens?, lastOutput?, lastRunAt? } and is only meaningful on a prompt node.

☑ References#

data-model

{ kind, target, line?, note?, blocking? } over eleven kinds: prompt, curve, graph, experiment, spec, doc, data, script, link, url, node.

"Blocking" is declarative: it renders as depends on and draws a solid rather than dotted edge in the generated graph. Nothing propagates status or detects cycles — deliberately, for now.

Opening a reference navigates: urls and links open a browser tab, docs/data/scripts open in the text editor, node selects that node, and the rest open the corresponding item editor.

☑ Progress#

data-model

tracked = total − none − cancelled; progress = done / tracked, and 0 when nothing is tracked. So notes and cancelled work do not dilute the number.

☑ The editor#

☑ Keyboard-first tree editing#

requirement

Key Action
↑ / ↓ move selection through the visible rows
→ / ← expand / collapse
Enter new sibling; Ctrl+Enter new child
Tab / Shift+Tab indent (becomes last child of the previous sibling) / outdent
Alt+↑ / Alt+↓ move within siblings
Delete remove

A new node inherits the selected node's kind unless that is section. Every structural action is also a toolbar button whose tooltip names the shortcut.

Drag and drop reorders; holding Alt or Shift drops inside the target. Dropping onto self or a descendant is rejected.

☑ Detail pane#

component

Breadcrumbs, title, kind/status/priority, tags (committed on blur), a markdown body, the reference editor, the inline prompt block for prompt nodes, and History — the item version list with a Restore that loads the snapshot and leaves the document dirty.

  • spec: 08-projects-and-research — version snapshots

☑ AI actions#

component

  • Expand into children — asks for 3–6 items as title — one sentence given the spec name and the ancestor path, parses the reply line by line (stripping list markers, capped at 8), and appends them as todo children whose kind is requirement under a section and the parent's kind otherwise.

  • Draft body — a short specification paragraph, appended to the existing body.

  • Run on a prompt node — generates with that node's profile and parameters and stores lastOutput/lastRunAt, with a separate action to copy the output into the body.

  • spec: 04-models-and-settings

☑ Markdown generation#

flow

specs.markdown(name, write) renders, and with write writes docs/<name>.spec.md and returns the path. The structure is fixed and test-pinned:

  1. YAML front matter — title, description, project, node count, progress percentage, generator and timestamp.
  2. # name and the description.
  3. ## Contents — a table of contents of the top two levels, only when there is more than one entry.
  4. Each node depth-first as #### <a id="…"></a>☑ Title (heading level capped at 6), then a badge line of kind, non-normal priority and #tags, the body, a ```prompt fence, the last model output in a <details>, and one bullet per reference.
  5. ## Links between nodes — a mermaid graph LR of node-to-node references, solid for blocking and dotted otherwise, emitted only when such references exist.
  • node: refs

☑ Script API#

api

specs.list(), specs.get(name), specs.save(name, doc), specs.markdown(name, write) — so a spec can be generated or rewritten by an Ohm script, which is how docs/specs could be kept in sync from inside the app.

  • spec: 02-ohm-language

☑ Templates#

component

Feature spec (Overview → Goals/Non-goals/Open questions; Design → Data model, API, Flow, Risks; Plan → Implement, Tests), Research plan (Question, Sub-questions, Sources, Findings, Report), and Prompt suite (a draft prompt node plus an evaluation test referencing an experiment).

☑ Review findings#

☐ The key handler hijacks the filter select#

risk

The tree's key handler only exempts INPUT elements, and the status filter <select> lives inside the same container — so arrow keys pressed while it is focused move the tree selection and are preventDefaulted, breaking the select's own keyboard behaviour. Exempt SELECT and TEXTAREA too.

  • node: keyboard

risk

Arrow navigation walks the filtered rows while indent, outdent and move operate on the real tree, so with a filter active a move can produce a result the user cannot see. Either disable structural edits while filtering or operate on the filtered view consistently.

☐ Delete is refused when everything is collapsed#

risk low

The guard counts the collapse-filtered rows, so with a single collapsed root it refuses to delete even though children exist; conversely the post-delete selection can point past the end and the pane shows "Select a node."

☐ Done nodes are struck through#

risk

.spec-row.done .spec-title applies text-decoration: line-through. In a to-do list that reads correctly; in a specification it does not — "done" there means "this is how the product works", and striking it through makes an implemented requirement look cancelled or obsolete.

Found by opening this repository's own specs in the editor, where most nodes are done and the whole tree appeared crossed out. Dim the row, or reserve the strikethrough for cancelled.

  • node: spec-model

☐ Markdown generation overwrites silently#

risk

It always writes docs/<name>.spec.md with no collision check, so a hand-written document of that name is replaced without warning.

  • node: markdown

☐ Duplicated logic between core and the editor#

note low

The status-mark table, the node-id generator and the progress computation each exist twice — once in packages/core/src/specs.ts and once in the editor. They agree today; they should be imported from one place.

The drop hint says Shift while the code accepts Alt or Shift.

graph LR
  markdown["Markdown generation"] -.-> refs["References"]
  the_key_handler_hijacks_the_filter_select["The key handler hijacks the filter select"] -.-> keyboard["Keyboard-first tree editing"]
  done_nodes_are_struck_through["Done nodes are struck through"] -.-> spec_model["SpecDoc and SpecNode"]
  markdown_generation_overwrites_silently["Markdown generation overwrites silently"] -.-> markdown["Markdown generation"]