note-state 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 resystem0
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,177 @@
1
+ Metadata-Version: 2.4
2
+ Name: note-state
3
+ Version: 0.1.0
4
+ Summary: A portable state-tracking layer for markdown note vaults (Obsidian, Dendron, Foam, and plain markdown folders).
5
+ Author-email: superxgen <superxgen@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/resystem0/note-state
8
+ Project-URL: Issues, https://github.com/resystem0/note-state/issues
9
+ Keywords: notes,markdown,obsidian,pkm,zettelkasten,frontmatter,state-machine
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: End Users/Desktop
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
17
+ Classifier: Topic :: Utilities
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: PyYAML>=6.0
22
+ Dynamic: license-file
23
+
24
+ # note-state
25
+
26
+ A portable "patch" for turning a pile of notes into a *pipeline*: every note
27
+ carries an explicit `state` telling you how far it's traveled from raw
28
+ capture to integrated, trustworthy knowledge — the same way a ticket in an
29
+ issue tracker carries a status, or a part on an assembly line carries a
30
+ stage.
31
+
32
+ Works with any note application that stores notes as markdown files with
33
+ **YAML frontmatter** on local disk — Obsidian, Dendron, Foam, Logseq, plain
34
+ markdown vaults, even a folder of `.md` files with no app at all.
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pip install note-state
40
+ ```
41
+
42
+ ## The pipeline
43
+
44
+ ```
45
+ drafting → review → formatting → referencing → integrating → published
46
+ ↘ ↘ ↘ ↘
47
+ needs_revision (loops back to whichever
48
+ stage flagged the problem)
49
+
50
+ any state ──────────────────────────────→ archived
51
+ ```
52
+
53
+ | State | What happens here | Exit condition |
54
+ |---|---|---|
55
+ | **Drafting** | Raw capture. Get the idea down with as little friction as possible. | The idea has enough substance to be judged. |
56
+ | **Review** | Read it critically: accurate, internally consistent, complete? | Content is verified, or it's sent back to Drafting. |
57
+ | **Formatting** | Give it shape: headings, atomic scope, a real title, clean prose, tags. | It reads well and is scoped correctly. |
58
+ | **Referencing** | Attach provenance: citations, source links, backlinks. | Every non-obvious claim is traceable to a source. |
59
+ | **Integrating** | Weave it into the graph: link from a Map of Content, cross-link related notes. | It's reachable from at least one other place in the vault. |
60
+ | **Published** | Stable and reusable — safe to cite, export, or share. | Terminal, but can be pulled back into Integrating. |
61
+ | **Needs Revision** *(utility state)* | Something's wrong. Flagged from any stage. | Always exits back to the stage that flagged it. |
62
+ | **Archived** *(utility state)* | Retired or superseded. | Terminal. |
63
+
64
+ This isn't sacred — it's just the bundled default. Drop your own
65
+ `states.yaml` next to your notes (or in any parent directory) to define a
66
+ different pipeline; `note-state` doesn't hardcode the states, it just
67
+ enforces whatever pipeline you describe.
68
+
69
+ ## The metadata schema
70
+
71
+ Every tracked note gets two frontmatter fields: the current state, and a
72
+ timestamped log of how it got there.
73
+
74
+ ```yaml
75
+ ---
76
+ title: "Maps of Content connect clusters of related notes"
77
+ state: referencing
78
+ state_history:
79
+ - state: drafting
80
+ date: 2026-07-01
81
+ - state: review
82
+ date: 2026-07-05
83
+ - state: formatting
84
+ date: 2026-07-08
85
+ - state: referencing
86
+ date: 2026-07-12
87
+ note: "added three sources"
88
+ tags: [pkm, moc]
89
+ ---
90
+
91
+ Note body goes here as normal — nothing about the state system touches
92
+ the content itself.
93
+ ```
94
+
95
+ `state_history` is what makes the system useful beyond a label: it's an
96
+ audit trail you can query later ("which notes have been stuck in Review for
97
+ three weeks?", "how long does formatting usually take?").
98
+
99
+ ## Usage
100
+
101
+ ```bash
102
+ # Stamp every note in a folder with the initial state (only touches
103
+ # notes that don't already have one — safe to run repeatedly)
104
+ note-state init ./my-vault
105
+
106
+ # Move a note forward
107
+ note-state set my-vault/atomic-notes.md review
108
+
109
+ # Try to skip a stage — this is rejected unless you pass --force
110
+ note-state set my-vault/atomic-notes.md published
111
+
112
+ # See what's where
113
+ note-state list ./my-vault
114
+ note-state list ./my-vault --state review
115
+
116
+ # Find bottlenecks — counts per state + oldest untouched notes
117
+ note-state report ./my-vault
118
+
119
+ # Print the pipeline as a Mermaid diagram (paste into anything that renders it)
120
+ note-state graph
121
+ ```
122
+
123
+ ## Customizing the pipeline
124
+
125
+ Two files define the state machine:
126
+
127
+ - **`states.yaml`** — states + allowed transitions. `note-state` looks for
128
+ this file (or `.note-states.yaml`) in the current directory, then any
129
+ parent directory, and falls back to the bundled default pipeline above if
130
+ none is found. Copy the bundled default as a starting point and edit
131
+ freely — rename stages, add a `translating` or `peer-review` state,
132
+ rewire transitions.
133
+ - Pass `--config path/to/states.yaml` to point at a specific file instead
134
+ of relying on directory discovery.
135
+
136
+ ## Wiring it into a specific app
137
+
138
+ - **Obsidian, Dendron, Foam, plain markdown folders** — these already read
139
+ and preserve YAML frontmatter, so `note-state` works with zero
140
+ adaptation: run it straight against your vault folder. In Obsidian, the
141
+ [Dataview](https://github.com/blacksmithgu/obsidian-dataview) plugin can
142
+ turn `state` into a live board, e.g.:
143
+
144
+ ```
145
+ TABLE state, state_history[-1].date AS "last moved"
146
+ FROM "Notes"
147
+ SORT state
148
+ ```
149
+
150
+ - **Logseq** — pages are markdown files too, but Logseq's own metadata
151
+ lives as a `key:: value` line on the first block rather than a `---`
152
+ frontmatter block. Keep frontmatter *and* Logseq properties side by side
153
+ (Logseq ignores the frontmatter block, `note-state` ignores the `::`
154
+ line), or adapt the parser in `note_state/cli.py` to read
155
+ `state:: drafting` instead.
156
+
157
+ - **Notion** (or any database-backed app without local files) — recreate
158
+ `states.yaml` as a **Status** property with matching options, and use a
159
+ Board view grouped by it. You lose `note-state`'s enforcement of
160
+ transition rules unless you script it against the Notion API, but the
161
+ state vocabulary and pipeline shape carry over directly.
162
+
163
+ ## Design notes
164
+
165
+ - **Backward moves are allowed on purpose.** Review can send a note back to
166
+ Drafting; Formatting can send it back to Review. Knowledge work isn't
167
+ strictly linear, and a state system that can't represent "this needs
168
+ another pass" will just get ignored.
169
+ - **`needs_revision` is a trapdoor, not a stage.** It exists so a problem
170
+ found at any point can be flagged without losing the note's place in the
171
+ pipeline — it always returns to wherever it came from.
172
+ - **`--force` exists deliberately.** Rules are useful defaults, not a cage;
173
+ sometimes you really do want to fast-track or bulk-correct a note.
174
+
175
+ ## License
176
+
177
+ MIT
@@ -0,0 +1,154 @@
1
+ # note-state
2
+
3
+ A portable "patch" for turning a pile of notes into a *pipeline*: every note
4
+ carries an explicit `state` telling you how far it's traveled from raw
5
+ capture to integrated, trustworthy knowledge — the same way a ticket in an
6
+ issue tracker carries a status, or a part on an assembly line carries a
7
+ stage.
8
+
9
+ Works with any note application that stores notes as markdown files with
10
+ **YAML frontmatter** on local disk — Obsidian, Dendron, Foam, Logseq, plain
11
+ markdown vaults, even a folder of `.md` files with no app at all.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pip install note-state
17
+ ```
18
+
19
+ ## The pipeline
20
+
21
+ ```
22
+ drafting → review → formatting → referencing → integrating → published
23
+ ↘ ↘ ↘ ↘
24
+ needs_revision (loops back to whichever
25
+ stage flagged the problem)
26
+
27
+ any state ──────────────────────────────→ archived
28
+ ```
29
+
30
+ | State | What happens here | Exit condition |
31
+ |---|---|---|
32
+ | **Drafting** | Raw capture. Get the idea down with as little friction as possible. | The idea has enough substance to be judged. |
33
+ | **Review** | Read it critically: accurate, internally consistent, complete? | Content is verified, or it's sent back to Drafting. |
34
+ | **Formatting** | Give it shape: headings, atomic scope, a real title, clean prose, tags. | It reads well and is scoped correctly. |
35
+ | **Referencing** | Attach provenance: citations, source links, backlinks. | Every non-obvious claim is traceable to a source. |
36
+ | **Integrating** | Weave it into the graph: link from a Map of Content, cross-link related notes. | It's reachable from at least one other place in the vault. |
37
+ | **Published** | Stable and reusable — safe to cite, export, or share. | Terminal, but can be pulled back into Integrating. |
38
+ | **Needs Revision** *(utility state)* | Something's wrong. Flagged from any stage. | Always exits back to the stage that flagged it. |
39
+ | **Archived** *(utility state)* | Retired or superseded. | Terminal. |
40
+
41
+ This isn't sacred — it's just the bundled default. Drop your own
42
+ `states.yaml` next to your notes (or in any parent directory) to define a
43
+ different pipeline; `note-state` doesn't hardcode the states, it just
44
+ enforces whatever pipeline you describe.
45
+
46
+ ## The metadata schema
47
+
48
+ Every tracked note gets two frontmatter fields: the current state, and a
49
+ timestamped log of how it got there.
50
+
51
+ ```yaml
52
+ ---
53
+ title: "Maps of Content connect clusters of related notes"
54
+ state: referencing
55
+ state_history:
56
+ - state: drafting
57
+ date: 2026-07-01
58
+ - state: review
59
+ date: 2026-07-05
60
+ - state: formatting
61
+ date: 2026-07-08
62
+ - state: referencing
63
+ date: 2026-07-12
64
+ note: "added three sources"
65
+ tags: [pkm, moc]
66
+ ---
67
+
68
+ Note body goes here as normal — nothing about the state system touches
69
+ the content itself.
70
+ ```
71
+
72
+ `state_history` is what makes the system useful beyond a label: it's an
73
+ audit trail you can query later ("which notes have been stuck in Review for
74
+ three weeks?", "how long does formatting usually take?").
75
+
76
+ ## Usage
77
+
78
+ ```bash
79
+ # Stamp every note in a folder with the initial state (only touches
80
+ # notes that don't already have one — safe to run repeatedly)
81
+ note-state init ./my-vault
82
+
83
+ # Move a note forward
84
+ note-state set my-vault/atomic-notes.md review
85
+
86
+ # Try to skip a stage — this is rejected unless you pass --force
87
+ note-state set my-vault/atomic-notes.md published
88
+
89
+ # See what's where
90
+ note-state list ./my-vault
91
+ note-state list ./my-vault --state review
92
+
93
+ # Find bottlenecks — counts per state + oldest untouched notes
94
+ note-state report ./my-vault
95
+
96
+ # Print the pipeline as a Mermaid diagram (paste into anything that renders it)
97
+ note-state graph
98
+ ```
99
+
100
+ ## Customizing the pipeline
101
+
102
+ Two files define the state machine:
103
+
104
+ - **`states.yaml`** — states + allowed transitions. `note-state` looks for
105
+ this file (or `.note-states.yaml`) in the current directory, then any
106
+ parent directory, and falls back to the bundled default pipeline above if
107
+ none is found. Copy the bundled default as a starting point and edit
108
+ freely — rename stages, add a `translating` or `peer-review` state,
109
+ rewire transitions.
110
+ - Pass `--config path/to/states.yaml` to point at a specific file instead
111
+ of relying on directory discovery.
112
+
113
+ ## Wiring it into a specific app
114
+
115
+ - **Obsidian, Dendron, Foam, plain markdown folders** — these already read
116
+ and preserve YAML frontmatter, so `note-state` works with zero
117
+ adaptation: run it straight against your vault folder. In Obsidian, the
118
+ [Dataview](https://github.com/blacksmithgu/obsidian-dataview) plugin can
119
+ turn `state` into a live board, e.g.:
120
+
121
+ ```
122
+ TABLE state, state_history[-1].date AS "last moved"
123
+ FROM "Notes"
124
+ SORT state
125
+ ```
126
+
127
+ - **Logseq** — pages are markdown files too, but Logseq's own metadata
128
+ lives as a `key:: value` line on the first block rather than a `---`
129
+ frontmatter block. Keep frontmatter *and* Logseq properties side by side
130
+ (Logseq ignores the frontmatter block, `note-state` ignores the `::`
131
+ line), or adapt the parser in `note_state/cli.py` to read
132
+ `state:: drafting` instead.
133
+
134
+ - **Notion** (or any database-backed app without local files) — recreate
135
+ `states.yaml` as a **Status** property with matching options, and use a
136
+ Board view grouped by it. You lose `note-state`'s enforcement of
137
+ transition rules unless you script it against the Notion API, but the
138
+ state vocabulary and pipeline shape carry over directly.
139
+
140
+ ## Design notes
141
+
142
+ - **Backward moves are allowed on purpose.** Review can send a note back to
143
+ Drafting; Formatting can send it back to Review. Knowledge work isn't
144
+ strictly linear, and a state system that can't represent "this needs
145
+ another pass" will just get ignored.
146
+ - **`needs_revision` is a trapdoor, not a stage.** It exists so a problem
147
+ found at any point can be flagged without losing the note's place in the
148
+ pipeline — it always returns to wherever it came from.
149
+ - **`--force` exists deliberately.** Rules are useful defaults, not a cage;
150
+ sometimes you really do want to fast-track or bulk-correct a note.
151
+
152
+ ## License
153
+
154
+ MIT
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "note-state"
7
+ version = "0.1.0"
8
+ description = "A portable state-tracking layer for markdown note vaults (Obsidian, Dendron, Foam, and plain markdown folders)."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "superxgen", email = "superxgen@gmail.com" }]
12
+ requires-python = ">=3.9"
13
+ dependencies = [
14
+ "PyYAML>=6.0",
15
+ ]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Environment :: Console",
19
+ "Intended Audience :: End Users/Desktop",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3 :: Only",
23
+ "Topic :: Text Processing :: Markup :: Markdown",
24
+ "Topic :: Utilities",
25
+ ]
26
+ keywords = ["notes", "markdown", "obsidian", "pkm", "zettelkasten", "frontmatter", "state-machine"]
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/resystem0/note-state"
30
+ Issues = "https://github.com/resystem0/note-state/issues"
31
+
32
+ [project.scripts]
33
+ note-state = "note_state.cli:main"
34
+
35
+ [tool.setuptools.packages.find]
36
+ where = ["src"]
37
+
38
+ [tool.setuptools.package-data]
39
+ note_state = ["default_states.yaml"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """note-state — a portable state-tracking layer for markdown note vaults."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,251 @@
1
+ """
2
+ note_state.cli — a portable state-tracking layer for markdown-based note apps.
3
+
4
+ Works with any note application that stores notes as markdown files with
5
+ YAML frontmatter on local disk: Obsidian, Dendron, Foam, plain markdown
6
+ vaults, and similar. (See the project README for notes on Logseq/Notion,
7
+ which store notes a little differently.)
8
+
9
+ It does one job: read/write a `state` field (plus a `state_history` log)
10
+ in each note's frontmatter, and enforce the transition rules defined in
11
+ states.yaml so a note can't silently skip stages of the pipeline.
12
+
13
+ Commands:
14
+ note-state init <file_or_dir>
15
+ note-state set <file> <state> [--force] [--note "reason"]
16
+ note-state list [dir] [--state STATE]
17
+ note-state report [dir]
18
+ note-state graph
19
+ """
20
+
21
+ import argparse
22
+ import datetime
23
+ import importlib.resources
24
+ import os
25
+ import pathlib
26
+ import sys
27
+
28
+ import yaml
29
+
30
+ from . import __version__
31
+
32
+ DEFAULT_CONFIG_NAMES = ["states.yaml", ".note-states.yaml"]
33
+ FRONTMATTER_DELIM = "---"
34
+
35
+
36
+ # ---------------------------------------------------------------- config --
37
+
38
+ def find_local_config():
39
+ """Look for states.yaml in the current directory or any parent."""
40
+ d = pathlib.Path.cwd()
41
+ while True:
42
+ for name in DEFAULT_CONFIG_NAMES:
43
+ p = d / name
44
+ if p.exists():
45
+ return str(p)
46
+ if d.parent == d:
47
+ return None
48
+ d = d.parent
49
+
50
+
51
+ def load_config(path=None):
52
+ if path:
53
+ with open(path, "r", encoding="utf-8") as f:
54
+ cfg = yaml.safe_load(f)
55
+ else:
56
+ local = find_local_config()
57
+ if local:
58
+ with open(local, "r", encoding="utf-8") as f:
59
+ cfg = yaml.safe_load(f)
60
+ else:
61
+ # Fall back to the bundled default pipeline.
62
+ data = importlib.resources.files("note_state").joinpath("default_states.yaml")
63
+ cfg = yaml.safe_load(data.read_text(encoding="utf-8"))
64
+
65
+ if "states" not in cfg or "transitions" not in cfg or "initial_state" not in cfg:
66
+ sys.exit("Config is missing required keys: states, transitions, initial_state")
67
+ return cfg
68
+
69
+
70
+ # ------------------------------------------------------------- frontmatter --
71
+
72
+ def read_note(path):
73
+ """Return (meta_dict, body_text) for a markdown file, tolerating no frontmatter."""
74
+ text = pathlib.Path(path).read_text(encoding="utf-8")
75
+ if text.startswith(FRONTMATTER_DELIM):
76
+ parts = text.split(FRONTMATTER_DELIM, 2)
77
+ if len(parts) == 3:
78
+ meta = yaml.safe_load(parts[1]) or {}
79
+ body = parts[2].lstrip("\n")
80
+ return meta, body
81
+ return {}, text
82
+
83
+
84
+ def write_note(path, meta, body):
85
+ yaml_str = yaml.safe_dump(
86
+ meta, sort_keys=False, allow_unicode=True, default_flow_style=False
87
+ )
88
+ content = f"{FRONTMATTER_DELIM}\n{yaml_str}{FRONTMATTER_DELIM}\n\n{body}"
89
+ pathlib.Path(path).write_text(content, encoding="utf-8")
90
+
91
+
92
+ def iter_md_files(root):
93
+ if os.path.isfile(root):
94
+ yield root
95
+ return
96
+ for dirpath, _, filenames in os.walk(root):
97
+ for fn in filenames:
98
+ if fn.endswith(".md"):
99
+ yield os.path.join(dirpath, fn)
100
+
101
+
102
+ def today_iso():
103
+ return datetime.date.today().isoformat()
104
+
105
+
106
+ # -------------------------------------------------------------- commands --
107
+
108
+ def cmd_init(args, cfg):
109
+ initial = cfg["initial_state"]
110
+ touched = 0
111
+ for path in iter_md_files(args.target):
112
+ meta, body = read_note(path)
113
+ if "state" in meta:
114
+ print(f"skip {path} (already: {meta['state']})")
115
+ continue
116
+ meta["state"] = initial
117
+ meta["state_history"] = [{"state": initial, "date": today_iso()}]
118
+ write_note(path, meta, body)
119
+ print(f"init {path} -> {initial}")
120
+ touched += 1
121
+ print(f"\n{touched} note(s) initialized.")
122
+
123
+
124
+ def cmd_set(args, cfg):
125
+ path = args.file
126
+ meta, body = read_note(path)
127
+ current = meta.get("state", cfg["initial_state"])
128
+ target = args.state
129
+
130
+ if target not in cfg["states"]:
131
+ valid = ", ".join(cfg["states"].keys())
132
+ sys.exit(f"Unknown state '{target}'. Valid states: {valid}")
133
+
134
+ allowed = cfg["transitions"].get(current, [])
135
+ if target not in allowed and not args.force:
136
+ allowed_str = ", ".join(allowed) if allowed else "(none — terminal state)"
137
+ sys.exit(
138
+ f"Can't move '{path}' from '{current}' to '{target}'.\n"
139
+ f"Allowed next states from '{current}': {allowed_str}\n"
140
+ f"Use --force to override anyway."
141
+ )
142
+
143
+ meta["state"] = target
144
+ history = meta.setdefault("state_history", [])
145
+ entry = {"state": target, "date": today_iso()}
146
+ if args.note:
147
+ entry["note"] = args.note
148
+ history.append(entry)
149
+ write_note(path, meta, body)
150
+ print(f"{path}: {current} -> {target}")
151
+
152
+
153
+ def cmd_list(args, cfg):
154
+ rows = []
155
+ for path in iter_md_files(args.target):
156
+ meta, _ = read_note(path)
157
+ state = meta.get("state", "(none)")
158
+ if args.state and state != args.state:
159
+ continue
160
+ history = meta.get("state_history", [])
161
+ last_date = history[-1]["date"] if history else "?"
162
+ rows.append((state, last_date, path))
163
+
164
+ if not rows:
165
+ print("No notes found.")
166
+ return
167
+
168
+ rows.sort(key=lambda r: (r[0], r[2]))
169
+ width = max(len(r[0]) for r in rows)
170
+ for state, last_date, path in rows:
171
+ print(f"{state.ljust(width)} {last_date} {path}")
172
+
173
+
174
+ def cmd_report(args, cfg):
175
+ counts = {}
176
+ aged = []
177
+ today = datetime.date.today()
178
+
179
+ for path in iter_md_files(args.target):
180
+ meta, _ = read_note(path)
181
+ state = meta.get("state", "(none)")
182
+ counts[state] = counts.get(state, 0) + 1
183
+ history = meta.get("state_history", [])
184
+ if history:
185
+ try:
186
+ last = datetime.date.fromisoformat(history[-1]["date"])
187
+ aged.append(((today - last).days, state, path))
188
+ except ValueError:
189
+ pass
190
+
191
+ print("Notes per state:")
192
+ for state, n in sorted(counts.items(), key=lambda kv: -kv[1]):
193
+ print(f" {state.ljust(16)} {n}")
194
+
195
+ if aged:
196
+ aged.sort(reverse=True)
197
+ print("\nOldest since last transition (possible bottlenecks):")
198
+ for age, state, path in aged[:10]:
199
+ print(f" {age:>4}d {state.ljust(16)} {path}")
200
+
201
+
202
+ def cmd_graph(args, cfg):
203
+ print("stateDiagram-v2")
204
+ for src, dests in cfg["transitions"].items():
205
+ if not dests:
206
+ continue
207
+ for dest in dests:
208
+ print(f" {src} --> {dest}")
209
+
210
+
211
+ # ------------------------------------------------------------------ main --
212
+
213
+ def main():
214
+ parser = argparse.ArgumentParser(
215
+ prog="note-state",
216
+ description="Track note states through a knowledge-processing pipeline.",
217
+ )
218
+ parser.add_argument("--version", action="version", version=f"note-state {__version__}")
219
+ parser.add_argument("--config", help="path to states.yaml", default=None)
220
+ sub = parser.add_subparsers(dest="command", required=True)
221
+
222
+ p_init = sub.add_parser("init", help="stamp an initial state onto notes that don't have one")
223
+ p_init.add_argument("target", help="file or directory")
224
+ p_init.set_defaults(func=cmd_init)
225
+
226
+ p_set = sub.add_parser("set", help="transition a note to a new state")
227
+ p_set.add_argument("file")
228
+ p_set.add_argument("state")
229
+ p_set.add_argument("--force", action="store_true", help="allow a transition not listed in states.yaml")
230
+ p_set.add_argument("--note", default=None, help="optional short reason, stored in state_history")
231
+ p_set.set_defaults(func=cmd_set)
232
+
233
+ p_list = sub.add_parser("list", help="list notes and their current state")
234
+ p_list.add_argument("target", nargs="?", default=".")
235
+ p_list.add_argument("--state", default=None, help="filter to a single state")
236
+ p_list.set_defaults(func=cmd_list)
237
+
238
+ p_report = sub.add_parser("report", help="counts per state + oldest unmoved notes")
239
+ p_report.add_argument("target", nargs="?", default=".")
240
+ p_report.set_defaults(func=cmd_report)
241
+
242
+ p_graph = sub.add_parser("graph", help="print the state machine as a Mermaid diagram")
243
+ p_graph.set_defaults(func=cmd_graph)
244
+
245
+ args = parser.parse_args()
246
+ cfg = load_config(args.config)
247
+ args.func(args, cfg)
248
+
249
+
250
+ if __name__ == "__main__":
251
+ main()
@@ -0,0 +1,62 @@
1
+ # Bundled default state machine — used automatically when no states.yaml
2
+ # is found in the current directory or any parent. Copy this file into
3
+ # your vault as `states.yaml` (or `.note-states.yaml`) and edit freely;
4
+ # a local copy always takes priority over this bundled default.
5
+
6
+ initial_state: drafting
7
+
8
+ states:
9
+ drafting:
10
+ label: "Drafting"
11
+ description: >
12
+ Raw capture. Get the idea down with as little friction as possible —
13
+ fragments, bullet points, half-formed sentences are all fine here.
14
+ review:
15
+ label: "Review"
16
+ description: >
17
+ Check the idea for accuracy, internal consistency, and missing gaps.
18
+ Decide whether it deserves to keep moving or needs rework.
19
+ formatting:
20
+ label: "Formatting"
21
+ description: >
22
+ Give the note a shape: headings, atomic scope (one idea per note),
23
+ consistent title, clean prose, tags.
24
+ referencing:
25
+ label: "Referencing"
26
+ description: >
27
+ Attach provenance: citations, source links, backlinks to the notes
28
+ this one draws on or was extracted from.
29
+ integrating:
30
+ label: "Integrating"
31
+ description: >
32
+ Weave the note into the graph: link it from relevant Maps of
33
+ Content / index notes, cross-link related permanent notes, make it
34
+ discoverable through search and tags rather than sitting orphaned.
35
+ published:
36
+ label: "Published"
37
+ description: >
38
+ Stable and reusable. Safe to cite, export, or share outside the
39
+ vault. The "done" state of the main pipeline.
40
+ needs_revision:
41
+ label: "Needs Revision"
42
+ description: >
43
+ Flagged during any stage as having a problem (factual error,
44
+ unclear scope, broken links). Always exits back to the stage that
45
+ flagged it, once fixed.
46
+ archived:
47
+ label: "Archived"
48
+ description: >
49
+ Retired or superseded by another note. No longer part of the
50
+ active pipeline, but kept for the record.
51
+
52
+ # Map of state -> list of states it's allowed to move to.
53
+ # cmd `set` enforces this unless you pass --force.
54
+ transitions:
55
+ drafting: [review, archived]
56
+ review: [formatting, drafting, needs_revision, archived]
57
+ formatting: [referencing, review, needs_revision, archived]
58
+ referencing: [integrating, formatting, needs_revision, archived]
59
+ integrating: [published, referencing, needs_revision, archived]
60
+ published: [integrating, archived]
61
+ needs_revision: [drafting, review, formatting, referencing, integrating]
62
+ archived: []
@@ -0,0 +1,177 @@
1
+ Metadata-Version: 2.4
2
+ Name: note-state
3
+ Version: 0.1.0
4
+ Summary: A portable state-tracking layer for markdown note vaults (Obsidian, Dendron, Foam, and plain markdown folders).
5
+ Author-email: superxgen <superxgen@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/resystem0/note-state
8
+ Project-URL: Issues, https://github.com/resystem0/note-state/issues
9
+ Keywords: notes,markdown,obsidian,pkm,zettelkasten,frontmatter,state-machine
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: End Users/Desktop
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
17
+ Classifier: Topic :: Utilities
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: PyYAML>=6.0
22
+ Dynamic: license-file
23
+
24
+ # note-state
25
+
26
+ A portable "patch" for turning a pile of notes into a *pipeline*: every note
27
+ carries an explicit `state` telling you how far it's traveled from raw
28
+ capture to integrated, trustworthy knowledge — the same way a ticket in an
29
+ issue tracker carries a status, or a part on an assembly line carries a
30
+ stage.
31
+
32
+ Works with any note application that stores notes as markdown files with
33
+ **YAML frontmatter** on local disk — Obsidian, Dendron, Foam, Logseq, plain
34
+ markdown vaults, even a folder of `.md` files with no app at all.
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pip install note-state
40
+ ```
41
+
42
+ ## The pipeline
43
+
44
+ ```
45
+ drafting → review → formatting → referencing → integrating → published
46
+ ↘ ↘ ↘ ↘
47
+ needs_revision (loops back to whichever
48
+ stage flagged the problem)
49
+
50
+ any state ──────────────────────────────→ archived
51
+ ```
52
+
53
+ | State | What happens here | Exit condition |
54
+ |---|---|---|
55
+ | **Drafting** | Raw capture. Get the idea down with as little friction as possible. | The idea has enough substance to be judged. |
56
+ | **Review** | Read it critically: accurate, internally consistent, complete? | Content is verified, or it's sent back to Drafting. |
57
+ | **Formatting** | Give it shape: headings, atomic scope, a real title, clean prose, tags. | It reads well and is scoped correctly. |
58
+ | **Referencing** | Attach provenance: citations, source links, backlinks. | Every non-obvious claim is traceable to a source. |
59
+ | **Integrating** | Weave it into the graph: link from a Map of Content, cross-link related notes. | It's reachable from at least one other place in the vault. |
60
+ | **Published** | Stable and reusable — safe to cite, export, or share. | Terminal, but can be pulled back into Integrating. |
61
+ | **Needs Revision** *(utility state)* | Something's wrong. Flagged from any stage. | Always exits back to the stage that flagged it. |
62
+ | **Archived** *(utility state)* | Retired or superseded. | Terminal. |
63
+
64
+ This isn't sacred — it's just the bundled default. Drop your own
65
+ `states.yaml` next to your notes (or in any parent directory) to define a
66
+ different pipeline; `note-state` doesn't hardcode the states, it just
67
+ enforces whatever pipeline you describe.
68
+
69
+ ## The metadata schema
70
+
71
+ Every tracked note gets two frontmatter fields: the current state, and a
72
+ timestamped log of how it got there.
73
+
74
+ ```yaml
75
+ ---
76
+ title: "Maps of Content connect clusters of related notes"
77
+ state: referencing
78
+ state_history:
79
+ - state: drafting
80
+ date: 2026-07-01
81
+ - state: review
82
+ date: 2026-07-05
83
+ - state: formatting
84
+ date: 2026-07-08
85
+ - state: referencing
86
+ date: 2026-07-12
87
+ note: "added three sources"
88
+ tags: [pkm, moc]
89
+ ---
90
+
91
+ Note body goes here as normal — nothing about the state system touches
92
+ the content itself.
93
+ ```
94
+
95
+ `state_history` is what makes the system useful beyond a label: it's an
96
+ audit trail you can query later ("which notes have been stuck in Review for
97
+ three weeks?", "how long does formatting usually take?").
98
+
99
+ ## Usage
100
+
101
+ ```bash
102
+ # Stamp every note in a folder with the initial state (only touches
103
+ # notes that don't already have one — safe to run repeatedly)
104
+ note-state init ./my-vault
105
+
106
+ # Move a note forward
107
+ note-state set my-vault/atomic-notes.md review
108
+
109
+ # Try to skip a stage — this is rejected unless you pass --force
110
+ note-state set my-vault/atomic-notes.md published
111
+
112
+ # See what's where
113
+ note-state list ./my-vault
114
+ note-state list ./my-vault --state review
115
+
116
+ # Find bottlenecks — counts per state + oldest untouched notes
117
+ note-state report ./my-vault
118
+
119
+ # Print the pipeline as a Mermaid diagram (paste into anything that renders it)
120
+ note-state graph
121
+ ```
122
+
123
+ ## Customizing the pipeline
124
+
125
+ Two files define the state machine:
126
+
127
+ - **`states.yaml`** — states + allowed transitions. `note-state` looks for
128
+ this file (or `.note-states.yaml`) in the current directory, then any
129
+ parent directory, and falls back to the bundled default pipeline above if
130
+ none is found. Copy the bundled default as a starting point and edit
131
+ freely — rename stages, add a `translating` or `peer-review` state,
132
+ rewire transitions.
133
+ - Pass `--config path/to/states.yaml` to point at a specific file instead
134
+ of relying on directory discovery.
135
+
136
+ ## Wiring it into a specific app
137
+
138
+ - **Obsidian, Dendron, Foam, plain markdown folders** — these already read
139
+ and preserve YAML frontmatter, so `note-state` works with zero
140
+ adaptation: run it straight against your vault folder. In Obsidian, the
141
+ [Dataview](https://github.com/blacksmithgu/obsidian-dataview) plugin can
142
+ turn `state` into a live board, e.g.:
143
+
144
+ ```
145
+ TABLE state, state_history[-1].date AS "last moved"
146
+ FROM "Notes"
147
+ SORT state
148
+ ```
149
+
150
+ - **Logseq** — pages are markdown files too, but Logseq's own metadata
151
+ lives as a `key:: value` line on the first block rather than a `---`
152
+ frontmatter block. Keep frontmatter *and* Logseq properties side by side
153
+ (Logseq ignores the frontmatter block, `note-state` ignores the `::`
154
+ line), or adapt the parser in `note_state/cli.py` to read
155
+ `state:: drafting` instead.
156
+
157
+ - **Notion** (or any database-backed app without local files) — recreate
158
+ `states.yaml` as a **Status** property with matching options, and use a
159
+ Board view grouped by it. You lose `note-state`'s enforcement of
160
+ transition rules unless you script it against the Notion API, but the
161
+ state vocabulary and pipeline shape carry over directly.
162
+
163
+ ## Design notes
164
+
165
+ - **Backward moves are allowed on purpose.** Review can send a note back to
166
+ Drafting; Formatting can send it back to Review. Knowledge work isn't
167
+ strictly linear, and a state system that can't represent "this needs
168
+ another pass" will just get ignored.
169
+ - **`needs_revision` is a trapdoor, not a stage.** It exists so a problem
170
+ found at any point can be flagged without losing the note's place in the
171
+ pipeline — it always returns to wherever it came from.
172
+ - **`--force` exists deliberately.** Rules are useful defaults, not a cage;
173
+ sometimes you really do want to fast-track or bulk-correct a note.
174
+
175
+ ## License
176
+
177
+ MIT
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/note_state/__init__.py
5
+ src/note_state/cli.py
6
+ src/note_state/default_states.yaml
7
+ src/note_state.egg-info/PKG-INFO
8
+ src/note_state.egg-info/SOURCES.txt
9
+ src/note_state.egg-info/dependency_links.txt
10
+ src/note_state.egg-info/entry_points.txt
11
+ src/note_state.egg-info/requires.txt
12
+ src/note_state.egg-info/top_level.txt
13
+ tests/test_cli.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ note-state = note_state.cli:main
@@ -0,0 +1 @@
1
+ PyYAML>=6.0
@@ -0,0 +1 @@
1
+ note_state
@@ -0,0 +1,148 @@
1
+ import subprocess
2
+ import sys
3
+ import textwrap
4
+
5
+ import pytest
6
+ import yaml
7
+
8
+ from note_state import cli
9
+
10
+
11
+ def run(*args, cwd):
12
+ return subprocess.run(
13
+ [sys.executable, "-m", "note_state.cli", *args],
14
+ cwd=cwd,
15
+ capture_output=True,
16
+ text=True,
17
+ )
18
+
19
+
20
+ @pytest.fixture
21
+ def vault(tmp_path):
22
+ note = tmp_path / "idea.md"
23
+ note.write_text("# Idea\n\nSome content.\n")
24
+ return tmp_path, note
25
+
26
+
27
+ def read_meta(path):
28
+ meta, _ = cli.read_note(path)
29
+ return meta
30
+
31
+
32
+ def test_init_stamps_initial_state(vault):
33
+ vault_dir, note = vault
34
+ result = run("init", str(vault_dir), cwd=vault_dir)
35
+ assert result.returncode == 0
36
+ meta = read_meta(note)
37
+ assert meta["state"] == "drafting"
38
+ assert meta["state_history"] == [{"state": "drafting", "date": cli.today_iso()}]
39
+
40
+
41
+ def test_init_is_idempotent(vault):
42
+ vault_dir, note = vault
43
+ run("init", str(vault_dir), cwd=vault_dir)
44
+ result = run("init", str(vault_dir), cwd=vault_dir)
45
+ assert "skip" in result.stdout
46
+ meta = read_meta(note)
47
+ assert len(meta["state_history"]) == 1
48
+
49
+
50
+ def test_set_allowed_transition(vault):
51
+ vault_dir, note = vault
52
+ run("init", str(vault_dir), cwd=vault_dir)
53
+ result = run("set", str(note), "review", cwd=vault_dir)
54
+ assert result.returncode == 0
55
+ meta = read_meta(note)
56
+ assert meta["state"] == "review"
57
+ assert len(meta["state_history"]) == 2
58
+
59
+
60
+ def test_set_rejects_illegal_skip_without_force(vault):
61
+ vault_dir, note = vault
62
+ run("init", str(vault_dir), cwd=vault_dir)
63
+ result = run("set", str(note), "published", cwd=vault_dir)
64
+ assert result.returncode != 0
65
+ assert "Can't move" in result.stderr
66
+ meta = read_meta(note)
67
+ assert meta["state"] == "drafting"
68
+
69
+
70
+ def test_set_allows_illegal_skip_with_force(vault):
71
+ vault_dir, note = vault
72
+ run("init", str(vault_dir), cwd=vault_dir)
73
+ result = run("set", str(note), "published", "--force", cwd=vault_dir)
74
+ assert result.returncode == 0
75
+ meta = read_meta(note)
76
+ assert meta["state"] == "published"
77
+
78
+
79
+ def test_set_rejects_unknown_state(vault):
80
+ vault_dir, note = vault
81
+ run("init", str(vault_dir), cwd=vault_dir)
82
+ result = run("set", str(note), "teleported", cwd=vault_dir)
83
+ assert result.returncode != 0
84
+ assert "Unknown state" in result.stderr
85
+
86
+
87
+ def test_set_records_optional_note(vault):
88
+ vault_dir, note = vault
89
+ run("init", str(vault_dir), cwd=vault_dir)
90
+ run("set", str(note), "review", "--note", "looks solid", cwd=vault_dir)
91
+ meta = read_meta(note)
92
+ assert meta["state_history"][-1]["note"] == "looks solid"
93
+
94
+
95
+ def test_list_filters_by_state(vault):
96
+ vault_dir, note = vault
97
+ other = vault_dir / "other.md"
98
+ other.write_text("# Other\n")
99
+ run("init", str(vault_dir), cwd=vault_dir)
100
+ run("set", str(note), "review", cwd=vault_dir)
101
+ result = run("list", str(vault_dir), "--state", "review", cwd=vault_dir)
102
+ assert "idea.md" in result.stdout
103
+ assert "other.md" not in result.stdout
104
+
105
+
106
+ def test_report_counts_per_state(vault):
107
+ vault_dir, note = vault
108
+ run("init", str(vault_dir), cwd=vault_dir)
109
+ result = run("report", str(vault_dir), cwd=vault_dir)
110
+ assert "drafting" in result.stdout
111
+ assert "1" in result.stdout
112
+
113
+
114
+ def test_graph_outputs_mermaid(vault):
115
+ vault_dir, _ = vault
116
+ result = run("graph", cwd=vault_dir)
117
+ assert result.stdout.startswith("stateDiagram-v2")
118
+ assert "drafting --> review" in result.stdout
119
+
120
+
121
+ def test_falls_back_to_bundled_default_config_outside_any_vault(tmp_path):
122
+ # No states.yaml anywhere under tmp_path -> bundled default is used.
123
+ note = tmp_path / "idea.md"
124
+ note.write_text("# Idea\n")
125
+ result = run("init", str(tmp_path), cwd=tmp_path)
126
+ assert result.returncode == 0
127
+ meta = read_meta(note)
128
+ assert meta["state"] == "drafting"
129
+
130
+
131
+ def test_local_states_yaml_overrides_bundled_default(tmp_path):
132
+ custom = textwrap.dedent(
133
+ """
134
+ initial_state: idea
135
+ states:
136
+ idea:
137
+ label: "Idea"
138
+ transitions:
139
+ idea: []
140
+ """
141
+ )
142
+ (tmp_path / "states.yaml").write_text(custom)
143
+ note = tmp_path / "note.md"
144
+ note.write_text("# Note\n")
145
+ result = run("init", str(tmp_path), cwd=tmp_path)
146
+ assert result.returncode == 0
147
+ meta = read_meta(note)
148
+ assert meta["state"] == "idea"