Philosophy · Event Ledger · Markers · Write-back · Full Loop · CBQL Read Path · Reports Page · Pub/Sub · Codeblock Types · Build Order · Checks · Open Questions
Most project management software makes you work for it — Gantt charts, requirement hierarchies, status meetings encoded as checkboxes. This model works the other way: you do the work in plain text, and the system reads it.
The project note is a diary. You write dated entries, record decisions, log time, note blockers. The system extracts structure from that prose — not the other way around. When you need a report, it assembles one from what's already there. When you invoice, it writes one line back into the diary as a receipt. Nothing is locked, nothing is mandatory, nothing is irreversible. Plain text throughout.
The goal is project management that feels like a pair of jeans — unobtrusive, comfortable, adapts to how you actually work.
A project note is an event log. Two kinds of events accumulate in it over time:
Typed codeblocks — structured data records embedded in dated sections. Time entries, expenses, decisions, tasks, risks — whatever the project warrants. Each block is timestamped by the dated section heading it lives under.
Markers — plain-text state transitions (> INVOICED: #001, > MILESTONE: Alpha). They appear at the exact moment the event occurred. Position in the file is the timestamp.
Both kinds of events are written by two kinds of authors:
| Author | Examples |
|---|---|
| Human | Typed decisions, observations, hand-written > MILESTONE: |
| System | > INVOICED: written by the Invoice button, future webhook markers |
CBQL makes no distinction between the two. A marker written by a button click and one typed by hand are the same event in the log.
The reports page (name-reports.md) is a projection — a materialized view assembled by reading from the project note. It doesn't store data; it displays it. Change the source, the projection updates.
Markers are plain-text lines recording state transitions. They live in the project note body at the exact point in the timeline where the event occurred.
> MARKER: REF - optional details
>— standard markdown blockquote prefix (no new syntax)MARKER— ALL CAPS; determines color and meaning: REF— short reference (invoice number, milestone name, version, commit hash, etc.)- details— optional free text
| Marker | Color | Written by | Meaning |
|---|---|---|---|
APPROVED |
blue | human / action | Scope locked, work authorized |
MILESTONE |
green | human / action / CI | Phase or deliverable complete |
INVOICED |
red | Invoice button | Billing boundary — period closed |
DELIVERED |
purple | human / action | Work handed to client/user |
PAUSED |
amber | human / action | Work suspended |
RESUMED |
teal | human / action | Work restarted after pause |
CLOSED |
grey | human / action | Project terminal, filed |
TODAY |
gold | human (once, at setup) | Insertion cursor — see below |
Any ALL-CAPS word followed by : is a valid marker — the vocabulary above is the standard set, not an exhaustive list. Unknown marker types render in a neutral default color.
> TODAY: is a positional marker, not a state transition. It is a fixed stake planted once at project setup, between the living log and the planned future. It never moves.
The + Today button inserts a new ## YYYY-MM-DD heading immediately above > TODAY:, opens the editor, and positions the cursor on the blank line just below the new heading — ready to write. No hunting.
## 2026-07-02 ← new heading inserted here by + Today
← cursor lands here
> TODAY: ← fixed stake — stays put forever
> MILESTONE: internal-rc
- [ ] register nb-web.ca
> MILESTONE: self-hosted
The natural multi-day flow — the log grows upward toward the marker, the marker never moves:
## 2026-07-01
notes, timedot ← day 1 done; CBQL counts this
## 2026-07-02
notes, timedot ← day 2 done; CBQL counts this
## 2026-07-03
← working here
> TODAY: ← boundary: above = done, below = future
milestones
CBQL current = everything before > TODAY: — all logged work is automatically in scope. Markers planned in the future (milestones) are automatically excluded.
Rules:
- Place
> TODAY:once when setting up the project - The marker stays put — it is a cursor, not a timestamp
+ Todayinserts above> TODAY:if it exists; falls back to before the first> MILESTONE:; falls back to end-of-file+ Todayis idempotent within a day — today's heading already present means only the editor opens (cursor still positioned at the heading)
TODAY renders in gold (#d4ac0d) — distinct from state-change markers, visually marking the active zone boundary.
A bare > TODAY: (no ref) auto-fills with the current date and time on render — useful as a live "last opened" indicator.
Markers divide the work log into phases — the space between two consecutive markers. The timeframe selector on the reports page navigates these phases. The project note accumulates phases over its lifetime; the reports page can show any one of them or all of them.
Shipped 2026-07-01: marker coloring generalized from INVOICED-only to any > ALLCAPS: pattern. nbweb-specialty.js regex + styles.css attribute selectors. INVOICED keeps its existing red color; all other markers now colored by type.
This is the inverse of CBQL. CBQL reads from the project note to build projections. Write-back writes a marker back to the project note as the receipt of an action.
- User triggers an action (button click, scheduled task, external webhook)
- Action executes (invoice generated, email sent, deploy triggered, payment received)
- On success:
writeMarker(source, type, ref, details)appends one line to the project note in today's dated section - The marker is now part of the immutable event log
Actions are ephemeral. Markers are the record.
Because the marker is just a line of text in a plain file, reversing an action is: delete the line. No database rollback, no status flags, no undo queue. The project note is the only durable state. Delete the marker, the action never happened as far as the system is concerned.
This is a feature. It makes experimentation safe. You can generate an invoice, look at it, decide it's not ready, and delete both the PDF and the marker line with zero consequence.
Shipped 2026-07-03. POST /api/project/write-marker — generic endpoint available to all plugins.
Body: { selector, marker, ref, position }
selector — note to write into (e.g. "Takeout:nb_web.md")
marker — ALLCAPS name; validated against ^[A-Z]{2,20}$
ref — optional reason/label appended after the colon
position — 'before_today' (default) | 'today_section' | 'end'
Position modes:
| Mode | Behaviour | Fallback |
|---|---|---|
before_today |
Inserts line immediately before > TODAY: |
Appends to end |
today_section |
Appends to today's ## YYYY-MM-DD section; creates it if absent |
Falls back to before_today insertion |
end |
Appends at end of file | — |
PAUSED/CLOSED use before_today (they belong in the living log, adjacent to the cursor). DELIVERED uses today_section (milestone closure is a dated event).
Plugin action buttons call this on success. The marker type is the plugin's to choose — hledger owns INVOICED, specialty owns PAUSED/CLOSED, codeblocks owns DELIVERED.
Markers don't have to originate from a button in the UI. Any system that can write to a file can append a marker:
- CI/CD pipeline:
> MILESTONE: v2.1-deployed 2026-07-01on successful deploy - Payment processor webhook:
> PAID: INV-001 2026-07-01on payment received - Scheduled script:
> MILESTONE: monthly-backup 2026-07-01
The project note becomes a single source of truth for everything that happened, regardless of whether a human or a system recorded it.
Human types codeblocks + markers
↓
System writes markers (Invoice btn, webhooks, CI)
↓
Project note accumulates events (plain text, git-versioned)
↓
Reports page fetches source on load
↓
Reports bar scans for markers → builds timeframe dropdown
↓
User selects timeframe → bar broadcasts context
↓
CBQL blocks re-render through selected timeframe lens
↓
Hand-written narrative interprets the generated data
One format (plain text markdown), one file (the project note), complete audit trail. The reports page is a window onto the event log, not a separate data store.
CBQL is the declarative read layer. Any codeblock type gains a query mode when source: is present: fetch matching events from source, filter by type and timeframe, render the result. Without source:, the block renders its inline content normally — backwards compatible.
```csv
source: name.md
filter: type=expense
timeframe: current
```
Parameters are YAML-style key/value pairs inside the fenced block. The renderer strips them before processing; they are never rendered as-is.
| Parameter | Description |
|---|---|
source: |
Where to read events from. Single note selector, or query expression (see CBQL — Codeblock Query Language) |
filter: |
Which block types to include. Matches the fenced block info string (decision, expense, timedot, etc.) |
timeframe: |
Which phase of the event stream. See below |
| Value | Meaning |
|---|---|
current |
From the last marker of any kind to now |
all |
Entire project history |
[marker-ref] |
The phase ending at that marker (INVOICED: #002) |
current is the default when timeframe: is omitted and source: is present. No markers found → current = all, silently.
Phase semantics: INVOICED: #002 means "everything between the previous marker and the #002 line" — the period that invoice covers. current means "everything since the last marker of any kind."
Explicit date ranges also valid:
timeframe: 2026-Q2
timeframe: 2026-01-01..2026-03-31
timeframe: and filter: can be late-bound from the note's own FM:
---
type: reports
source: name.md
period: current
---```table
source: ${source}
timeframe: ${period}
filter: milestone
```
The renderer resolves ${key} against the containing note's FM before executing the query. Declare period: once in FM; every CBQL block on the page inherits it. Changing period: regenerates all blocks.
- Parse block content for CBQL parameters — strip before rendering
- Resolve
${variables}from containing note's FM - If
source:present: fetch source note(s) raw markdown via/api/note - Scan fenced blocks for info string matching
filter: - Locate marker lines; restrict to
timeframe:window - Pass collected records to the block's normal renderer as inline content
- Render output
No matches → render empty state, not an error. Source unavailable → same.
name-reports.md (type: reports, source: name.md in FM) is the projection surface.
The reports specialty bar is the context provider. CBQL blocks are consumers.
On load: the bar fetches source, scans for > MARKER: lines, builds the timeframe dropdown. CBQL blocks fetch source independently and hold their full dataset.
On timeframe change: the bar broadcasts a document-level timeframe-changed event. Every CBQL block on the page re-renders its held data through the new lens. No re-fetch — instant.
source: in the note's FM is shared by both bar and blocks — one declaration, one data pipe.
Current ← always first
────────────────────────────
INVOICED: #003 2026-05-01
MILESTONE: Alpha 2026-03-20
INVOICED: #002 2026-02-14
INVOICED: #001 2026-01-10
────────────────────────────
All ← always last
Markers in reverse chronological order. Each entry = the phase ending there. Degenerate form: one marker → Current / [marker] / All — equivalent to the existing 3-state toggle.
A reports page assembles three kinds of content:
| Content | Source | Mechanism |
|---|---|---|
| Phase timeline | Markers in project note | timeline CBQL block |
| Time totals | timedot blocks in project note | hl CBQL block |
| Task snapshot | tw: blocks or tw filter | tw: CBQL block |
| Deliverables list | Define section in project note | {{inline: name.md#Deliverables}} |
| Financials | hledger journal files | Existing hl blocks (unchanged) |
| Executive summary | Hand-written | Narrative prose |
| Decisions log | Hand-written | Narrative prose |
The ratio of generated to hand-written shifts by project type. A billing report is mostly generated; a retrospective is mostly narrative.
Two mechanisms bring related content into a note. They serve different purposes:
| Mechanism | Syntax | Role |
|---|---|---|
| Wikilink | [[notebook:filename.md|label]] |
Reference — navigate to, cite, acknowledge |
| Inline query | {{inline: selector}} |
Participate — content is read, aggregated, computed |
Use [[wikilinks]] for:
- Plans, spec docs, design docs — things a reader should navigate to
- Related projects — cross-references without data dependency
- External references — links that provide context but don't affect numbers
Use {{inline:}} for:
- Timesheets that roll up into the current note's totals (e.g.
{{inline: accts:jim-timesheets.md}}) - Deliverable lists from a sub-contractor that feed the
checklistCBQL block - Any content that must be included in a computation, not just browsed
A reports page typically has both: wikilinks in the header/resources section (navigation), inline queries in the body (aggregation). The pattern is "link to the document, inline the data."
Existing hl blocks querying hledger directly: unaffected. They have no source: param and no timeframe: param — they render exactly as before. The timeframe dropdown appears on the bar regardless but doesn't affect blocks that aren't listening. Opt-in only.
source: can be a query expression rather than a single note:
source: notebook:hansen ← all notes in notebook
source: filter:client=Hansen ← FM query across all notes
source: type:project ← all project-typed notes
A subscriber aggregating blocks from multiple sources gets cross-project views without explicit links:
- Client dashboard: all
milestoneblocks from every project taggedclient: Hansen - Portfolio risk register: all
riskblocks withstatus: openacross all active projects - Business expense roll-up: all
expenseblocks across all work notebooks
Publishers (project notes) declare nothing — typed codeblocks are naturally discoverable. The subscriber is the only active participant.
Index requirement: scanning codeblock content across many notes needs a maintained index (notes are already indexed by FM; codeblock content index is additive). Build before volume demands it, not after.
Longer term — this is the event sourcing / materialized view pattern at portfolio scale. Out of scope until single-project CBQL is solid.
Which existing block types are natural CBQL participants:
| Type | Role | Readiness |
|---|---|---|
timedot |
Producer: time entries scattered in project log | Ready — needs timeframe wiring |
hl |
Consumer: aggregates timedot, shows totals | Ready — needs marker-based timeframe |
tw |
Both: tasks added in project, completion snapshot in reports | Ready — needs source/filter |
checklist |
Consumer: surfaces unchecked - [ ] deliverables from source project |
New type needed |
query |
Consumer: already aggregates FM across notes; extend to codeblock content | Close |
timeline |
Consumer: renders markers from source as visual phase history | New type needed |
Build order within types: timedot/hl pair first (already in real use, lowest risk). tw snapshot second. timeline third (highest value for reports page opener). checklist is low-effort once the CBQL read path exists. query extension is the pub/sub on-ramp.
Three systems handle work items in nb-web. They don't conflict — each occupies a different granularity and namespace:
| Layer | Syntax | File | System | Interactivity |
|---|---|---|---|---|
| Deliverables | - [ ] framing done |
type: project body |
Markdown GFM | Static; read by checklist CBQL block |
| Todos | # [ ] Note title |
.todo.md |
nb native | Interactive toggles via /api/todo |
| Tasks | — | ~/.task DB |
Taskwarrior | tw codeblock; full TW integration future |
The discriminator between deliverables and nb todos is the leading character: - [ ] vs # [ ]. The .todo.md extension is the file-level signal; the # [ ] H1 is the open/closed state. Inside a .todo.md, sub-tasks use - [ ] under a ## Tasks heading, wired to toggles in nb-web.
- [ ] in a project body is not an nb todo and not a TW task. It is a deliverable checkpoint — hand-managed, static, safe to aggregate via the checklist CBQL block. The tw codeblock reads Taskwarrior directly; markdown checkboxes are invisible to it and there is no conflict.
The * [ ] discriminator (used in taskwiki to namespace TW tasks in vim buffers) is not used here — our layers are already separated by file type and leading character.
| Step | What | Status |
|---|---|---|
| 1 | Marker coloring — generic > ALLCAPS: regex, color by type |
✅ Shipped 2026-07-01 |
| 2 | INVOICED write-back via Invoice button |
✅ Existing (hledger plugin) |
| 3 | project + project-reports global templates; check: wiring |
✅ Shipped 2026-07-01 |
| 4 | Create-on-demand UX — pair chip pre-flight, popup, source: patch | ✅ Shipped 2026-07-01 |
| 5 | POST /api/project/write-marker — generic 3-mode write-back endpoint |
✅ Shipped 2026-07-03 |
| 6 | Timeframe dropdown on type: reports specialty bar |
✅ Shipped 2026-07-01 |
| 6a | TODAY marker + + Today smart insertion (never crosses MILESTONE) |
✅ Shipped 2026-07-01 |
| 7 | CBQL read path — timedot/hl with marker-based timeframe |
✅ Shipped 2026-07-01 |
| 8 | timeline block type — renders markers from source |
✅ Shipped 2026-07-01 |
| 9 | tw CBQL source/filter support |
✅ Shipped 2026-07-01 |
| 10 | checklist block type — surfaces - [ ] deliverables from source |
✅ Shipped 2026-07-01 |
| 10a | group: milestones mode — deliverables grouped by event-log > MILESTONE: markers |
✅ Shipped 2026-07-01 |
| 11 | PAUSED/CLOSED buttons on reports specialty bar; DELIVERED on timeline milestone rows | ✅ Shipped 2026-07-03 |
| 12 | timedot blocks in project note as additional hledger source for hl CBQL blocks |
✅ Shipped 2026-07-03 — see Project Diary — Journal Sync and CBQL Implementation |
| 13 | Pub/sub multi-source source: queries |
📋 Long term |
Steps 3 and 4 are independent and can be built in parallel. Step 5 requires 4. Step 6 requires 4. Steps 3 and 8 are a natural pair.
PAUSED / CLOSED buttons live on the reports specialty bar (nbweb-specialty.js), visible when status !== 'closed'. Prompts for reason → POSTs to /api/project/write-marker with position: 'before_today'. On CLOSED: buttons disappear from the bar.
DELIVERED button appears on each MILESTONE row in the timeline block (nbweb-codeblocks.js). Prompts for a note → writes > DELIVERED: milestone-ref via position: 'today_section'.
Both use data-source-sel threaded down from the block's container el.dataset.sourceSel (set in _loadTimelineCBQLBlock), so the write target is always the source project note, not the reports page.
Action buttons warn (not block) if open deliverables exist under that milestone — not yet implemented; deferred to a future polish pass.
See Project Diary — Journal Sync and CBQL Implementation for the full implementation reference: file layout, sync mechanism, CBQL date range extraction, caveats, and unfinished business.
The project note is the canonical session diary — timedot blocks live inline under dated headings, not in a separate timelog file. The goal: an hl CBQL block in the reports note should aggregate those timedot blocks directly via source:, without requiring a separate .journal import.
PoC contract (to be defined):
# In the hl: block on the reports page
source: nb_web.md
filter: timedot
timeframe: currentThe hl renderer would: (1) fetch the source note raw, (2) extract all fenced timedot blocks in the current timeframe window, (3) concatenate them into a temporary timedot string, (4) pipe that to hledger with -f timeclock:- (or timedot format flag), (5) render the balance/register output.
Open questions for Step 12:
- hledger timedot input mode: does
hledger -f timedot:-accept piped concatenated blocks? - Timeframe slicing: does the marker-boundary logic run before or after fetching blocks?
- Interaction with existing
hljournal config in.Takeout.md— additional source should be additive, not replace the primary journal.
Checks are the validation layer that makes CBQL trustworthy. CBQL assumes the project note is well-formed; checks guarantee it is. They also serve as the format documentation — a user who types > invoiced: (lowercase) gets a check failure explaining the correct form. The check IS the spec, enforced at review time.
Failure modes fall into five categories:
Things the marker regex silently ignores rather than errors on:
> invoiced:— lowercase; not matched by> [A-Z]{2,}:regex; invisible in timeline and dropdown> INVOICED #001— missing:after marker name; won't parse## 2026-7-1— non-padded date heading; breaks chronological sorting and phase boundary detection
- Date headings out of order (pasted section, typo'd year)
> RESUMED:with no preceding> PAUSED:> CLOSED:before> DELIVERED:— logically impossible ordering
status: activein FM but> CLOSED:marker in bodystatus: activebut> PAUSED:present with no subsequent> RESUMED:
source:in reports FM points to a note that doesn't exist (renamed, moved)- Reports note exists but no project pair, or vice versa
- CBQL
timeframe: INVOICED: #005but source only has markers up to#003— silent empty result
- Project note has no define sections (scope, deliverables, etc.) — blank page nudge
- Reports note missing
source:FM — CBQL blocks have nowhere to read from
All 9 scripts live in ~/.nb/.checks/ and are wired into docs:sysadmin.md via project- and report- prefix selectors. Each is silent on pass, silent on non-matching note types.
| Script | What it catches |
|---|---|
project-date-sequence.sh |
## YYYY-MM-DD headings out of chronological order or non-zero-padded |
project-marker-format.sh |
Marker lines missing colon or REF — invisible to timeframe dropdown |
project-marker-sequence.sh |
RESUMED without prior PAUSED; CLOSED not last; orphaned transitions |
project-status-marker-sync.sh |
FM status: disagrees with most recent marker in body |
project-reports-pair.sh |
type: project note missing its -reports.md partner |
report-source-exists.sh |
source: FM points to a note that doesn't exist (renamed/moved) |
report-source-is-project.sh |
source: FM resolves to a non-project note |
report-timeframe-refs.sh |
CBQL timeframe: MARKER: ref but that ref doesn't exist in source |
report-project-pair.sh |
Reports note has no corresponding project partner |
Naming follows existing .checks/ prefix conventions.
filter:syntax — fenced block info string exact match only, or glob? (filter: timedotvsfilter: time*)${variable}resolution scope — note FM only, or also walk up to notebook dotfile config?- CBQL block caching — hold full dataset in memory after first fetch, or re-fetch when source note is saved?
timelineblock: render markers only, or also surfaced dated section headings as minor events?write_marker()position — Resolved: 3 explicit modes (before_today/today_section/end). Callers choose; no implicit logic needed.- Multi-marker phases:
current= since last marker of any type, or since last marker of a specific type? Resolved:current= everything before> TODAY:(the insertion cursor IS the phase boundary). Fallback: since lastINVOICED/CLOSEDmarker. Final fallback: full body. MILESTONE markers are goals, not phase boundaries. - External write-back authentication — webhook endpoint needs a token; scope for later
#wip