TL;DR: Each markdown file's frontmatter has a status field with values draft → ready → live → archived. A 30-line scanner reads all files and renders a kanban view by status column. No SaaS subscriptions, no syncing issues, files stay in git.
The kanban states
Every paste-ready content file uses one of:
| Status | Meaning |
|---|---|
draft |
Started writing, not ready to publish |
ready |
Self-verified, ready to publish |
live |
Published (Substack/Gumroad) |
published |
Published (dev.to — the platform uses different terms) |
archived |
Done, kept for reference |
Other fields:
-
priority:P0(today) /P1(this week) /P2(this month) -
category:content/product/roadmap/kit -
publish_target_date: ISO 8601 date
The scanner
import yaml
from pathlib import Path
from collections import defaultdict
def kanban_view(root: Path):
"""Render kanban view across all markdown files with frontmatter."""
columns = defaultdict(list)
for md_file in root.rglob("*.md"):
content = md_file.read_text(encoding='utf-8-sig')
if not content.startswith("---"):
continue
try:
_, yaml_block, _ = content.split("---", 2)
meta = yaml.safe_load(yaml_block) or {}
except (ValueError, yaml.YAMLError):
continue
if "status" not in meta:
continue
meta["_file"] = str(md_file.relative_to(root))
columns[meta["status"]].append(meta)
return columns
def render_kanban(columns):
print(f"\n{'DRAFT':12} {'READY':12} {'LIVE':12} {'ARCHIVED':12}")
print("-" * 64)
max_rows = max(len(items) for items in columns.values())
for i in range(max_rows):
row = []
for status in ['draft', 'ready', 'live', 'archived']:
items = columns.get(status, [])
if i < len(items):
row.append(items[i].get('id', '?')[:12])
else:
row.append("")
print(" | ".join(f"{c:12}" for c in row))
if __name__ == "__main__":
cols = kanban_view(Path("reports"))
render_kanban(cols)
for status, items in cols.items():
print(f"\n{status} ({len(items)}):")
for item in items[:5]:
print(f" {item.get('id', '?')} [{item.get('priority', '?')}]")
Output:
DRAFT READY LIVE ARCHIVED
----------------------------------------------------------------
devto-50 substack-21
devto-51 substack-22
devto-52 substack-23
...
draft (0):
ready (24):
devto-30 [P1]
devto-31 [P0]
...
live (12):
substack-21 [P0]
substack-22 [P0]
...
Status transitions
You move a file between columns by editing its frontmatter:
sed -i 's/^status: draft$/status: ready/' file.md
Or via your dashboard's "ship" button:
@app.route('/ship/<path>')
def ship(path):
md = Path(f"reports/{path}.md").read_text()
md = md.replace("status: ready", "status: live")
Path(f"reports/{path}.md").write_text(md)
return "ok"
Why this beats Trello/Notion
1. Files stay in git
Every status change is a commit. git log is your audit trail. No "Trello deleted my card."
2. No SaaS lock-in
Trello / Notion can rate-limit, change their API, raise prices. Your filesystem won't.
3. CDP automation reads the same source
Your CDP publish script can read status: ready files only. Once published, status flips to live. Self-documenting.
4. Search is grep
"Find all P0 priority articles" = grep -l "priority: P0" reports/. No SaaS query language.
5. Cross-references are real
Article #50 references article #45. In Notion, those are 2 different "pages" with backlinks. In files, it's just a markdown link.
Bonus: 1-line stats
def stats(columns):
total = sum(len(items) for items in columns.values())
return {
"total": total,
"ready": len(columns.get("ready", [])),
"live": len(columns.get("live", [])),
"draft": len(columns.get("draft", [])),
"by_priority": {
"P0": sum(1 for items in columns.values() for i in items if i.get("priority") == "P0"),
"P1": sum(1 for items in columns.values() for i in items if i.get("priority") == "P1"),
}
}
Output:
{"total": 60, "ready": 24, "live": 36, "draft": 0, "by_priority": {"P0": 5, "P1": 28}}
What this lets you do
- See at a glance: "I have 24 articles ready to ship"
- Filter by priority: "What's P0 today?"
- Track throughput: "I shipped 12 articles this month"
- Identify backlog: "0 drafts means I'm not writing enough"
All from cat reports/*.md | grep status and friends.
What I'd skip
- Don't add sub-statuses (
ready-for-review,in-review). Keep it 4 columns max. - Don't track effort estimates (
eta_min: 30). Self-deception territory. - Don't add assignee. You're an indie. There's only you.
What I'd add:
-
tagsfor category grouping (already in my frontmatter spec) -
last_modified(auto-updated on save) -
dependencies(article #50 depends on #45 being LIVE first)
Source
Full kanban scanner + dashboard integration:
AutoApp Dashboard ($39) includes:
-
kanban_scan.py(this article) -
dashboard/templates/kanban.html(Flask kanban view) - 60+ paste-ready content files with status fields as examples
If you have 50+ markdown files and you're using Trello to track them, you're paying SaaS for what grep can do. 30 lines of Python solves it.
Top comments (0)