oifmd 0.1.0.dev0__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,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: oifmd
3
+ Version: 0.1.0.dev0
4
+ Summary: Open Issue Format (OIF) validator and tools
5
+ License: Apache-2.0
6
+ Project-URL: Homepage, https://oif.md
7
+ Project-URL: Source, https://github.com/oifmd/oifmd
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: pyyaml>=6
12
+ Dynamic: license-file
13
+
14
+ # Open Issue Format (OIF)
15
+
16
+ **Issues as files.** A directory of Markdown files that any agent or
17
+ human can run as a board with `ls`, `cat` and `git mv`. No tool required.
18
+
19
+ - **Spec:** [SPEC.md](SPEC.md) · version 0.1 (draft)
20
+ - **Site:** https://oif.md
21
+ - **Package:** `oifmd` (validator and converters)
22
+
23
+ ## Sixty-second tour
24
+
25
+ ```
26
+ board.md
27
+ issues/
28
+ ├── backlog/
29
+ │ ├── column.md
30
+ │ └── add-jira-importer-nkhnsk.md
31
+ ├── doing/
32
+ │ ├── column.md
33
+ │ └── fix-login-redirect-loop-7k2x9m.md
34
+ └── done/
35
+ ├── column.md
36
+ └── write-spec-outline-h87456.md
37
+ ```
38
+
39
+ The directory is the status. The filename is the identity. Moving an
40
+ issue is `git mv issues/doing/fix-login-redirect-loop-7k2x9m.md issues/done/`.
41
+
42
+ `issues/doing/fix-login-redirect-loop-7k2x9m.md`:
43
+
44
+ ```markdown
45
+ ---
46
+ type: issue
47
+ resource: oif:app/7k2x9m
48
+ title: Login form rejects passwords containing "!"
49
+ kind: bug
50
+ priority: high
51
+ assignees: [coder/1.4]
52
+ requested_by: human:sam
53
+ tags: [auth]
54
+ created: 2026-09-13T03:10:00Z
55
+ ---
56
+
57
+ Submitting a correct password with `!` clears the form and shows
58
+ "invalid credentials". Expected: login succeeds.
59
+
60
+ ## Acceptance Criteria
61
+
62
+ - [x] Reproduce with a failing test
63
+ - [ ] Fix without changing the hashing path
64
+
65
+ ```
66
+
67
+ Comments are separate files, one per comment, keyed by the issue's id:
68
+
69
+ `comments/7k2x9m/k3n2wp.md`
70
+
71
+ ```markdown
72
+ ---
73
+ type: comment
74
+ at: 2026-09-13T04:12:00Z
75
+ by: human:sam
76
+ kind: verdict
77
+ result: changes_requested
78
+ ---
79
+
80
+ Keep the strip for whitespace only.
81
+ ```
82
+
83
+ Read an issue and its whole history with one command:
84
+
85
+ ```sh
86
+ cat issues/*/*-7k2x9m.md comments/7k2x9m/*.md
87
+ ```
88
+
89
+ ## Why
90
+
91
+ Every git-native tracker (Backlog.md, git-issues, beaver-backlog, beads)
92
+ converged on Markdown plus frontmatter, one file per issue. None
93
+ published the format separately from the tool. OIF is the format,
94
+ written down, with a validator, so issues outlive whichever tool wrote
95
+ them and any agent can read them cold.
96
+
97
+ Design rules that fall out of being agent-first and git-native:
98
+
99
+ - **State is the directory.** A move is one atomic rename, not a
100
+ read-modify-write that two agents can trample.
101
+ - **Identity is a random six-character id in the filename.** Two
102
+ branches can create issues at once and merge with no counter, no
103
+ scan, no renumbering. Sequential keys like `APP-2753` survive as
104
+ aliases.
105
+ - **One comment is one file**, keyed by the issue's id. Two agents
106
+ commenting at once write two different paths, so nothing conflicts and
107
+ nothing is lost. Appending to a shared file does not survive concurrent
108
+ writers; see the changelog.
109
+ - **Unknown keys are preserved.** Your tracker's extra fields round-trip.
110
+ - **Every column has a `column.md`.** It keeps empty columns in git and
111
+ tells an arriving agent what belongs there and how to leave.
112
+ - **The board declares its own vocabulary.** `board.md` lists the columns
113
+ and, optionally, the `kinds` an issue may have and which kinds may
114
+ contain which. Epic, story and task are one team's words, not the
115
+ format's. Ready `board.md` files for Kanban, Scrum and Shape Up are in
116
+ [`profiles/`](profiles/).
117
+
118
+ An OIF board is a conforming [Open Knowledge Format](https://okf.md)
119
+ bundle: same substrate, same actor convention, `type: issue` on every
120
+ file. The one deliberate divergence, identity by id rather than by path,
121
+ is spelled out in SPEC.md section 9. Trackers are platforms, OIF is the
122
+ file.
123
+
124
+ ## This repository
125
+
126
+ The roadmap for OIF itself lives in [`board/`](board/), in OIF.
127
+ GitHub Issues stays open for conversation; accepted work lands in
128
+ `board/issues/` with the GitHub number kept in `external_ids`.
129
+
130
+ ## Status
131
+
132
+ 0.1 is a draft. Expect 0.x to move.
133
+
134
+ ## License
135
+
136
+ Apache-2.0.
@@ -0,0 +1,123 @@
1
+ # Open Issue Format (OIF)
2
+
3
+ **Issues as files.** A directory of Markdown files that any agent or
4
+ human can run as a board with `ls`, `cat` and `git mv`. No tool required.
5
+
6
+ - **Spec:** [SPEC.md](SPEC.md) · version 0.1 (draft)
7
+ - **Site:** https://oif.md
8
+ - **Package:** `oifmd` (validator and converters)
9
+
10
+ ## Sixty-second tour
11
+
12
+ ```
13
+ board.md
14
+ issues/
15
+ ├── backlog/
16
+ │ ├── column.md
17
+ │ └── add-jira-importer-nkhnsk.md
18
+ ├── doing/
19
+ │ ├── column.md
20
+ │ └── fix-login-redirect-loop-7k2x9m.md
21
+ └── done/
22
+ ├── column.md
23
+ └── write-spec-outline-h87456.md
24
+ ```
25
+
26
+ The directory is the status. The filename is the identity. Moving an
27
+ issue is `git mv issues/doing/fix-login-redirect-loop-7k2x9m.md issues/done/`.
28
+
29
+ `issues/doing/fix-login-redirect-loop-7k2x9m.md`:
30
+
31
+ ```markdown
32
+ ---
33
+ type: issue
34
+ resource: oif:app/7k2x9m
35
+ title: Login form rejects passwords containing "!"
36
+ kind: bug
37
+ priority: high
38
+ assignees: [coder/1.4]
39
+ requested_by: human:sam
40
+ tags: [auth]
41
+ created: 2026-09-13T03:10:00Z
42
+ ---
43
+
44
+ Submitting a correct password with `!` clears the form and shows
45
+ "invalid credentials". Expected: login succeeds.
46
+
47
+ ## Acceptance Criteria
48
+
49
+ - [x] Reproduce with a failing test
50
+ - [ ] Fix without changing the hashing path
51
+
52
+ ```
53
+
54
+ Comments are separate files, one per comment, keyed by the issue's id:
55
+
56
+ `comments/7k2x9m/k3n2wp.md`
57
+
58
+ ```markdown
59
+ ---
60
+ type: comment
61
+ at: 2026-09-13T04:12:00Z
62
+ by: human:sam
63
+ kind: verdict
64
+ result: changes_requested
65
+ ---
66
+
67
+ Keep the strip for whitespace only.
68
+ ```
69
+
70
+ Read an issue and its whole history with one command:
71
+
72
+ ```sh
73
+ cat issues/*/*-7k2x9m.md comments/7k2x9m/*.md
74
+ ```
75
+
76
+ ## Why
77
+
78
+ Every git-native tracker (Backlog.md, git-issues, beaver-backlog, beads)
79
+ converged on Markdown plus frontmatter, one file per issue. None
80
+ published the format separately from the tool. OIF is the format,
81
+ written down, with a validator, so issues outlive whichever tool wrote
82
+ them and any agent can read them cold.
83
+
84
+ Design rules that fall out of being agent-first and git-native:
85
+
86
+ - **State is the directory.** A move is one atomic rename, not a
87
+ read-modify-write that two agents can trample.
88
+ - **Identity is a random six-character id in the filename.** Two
89
+ branches can create issues at once and merge with no counter, no
90
+ scan, no renumbering. Sequential keys like `APP-2753` survive as
91
+ aliases.
92
+ - **One comment is one file**, keyed by the issue's id. Two agents
93
+ commenting at once write two different paths, so nothing conflicts and
94
+ nothing is lost. Appending to a shared file does not survive concurrent
95
+ writers; see the changelog.
96
+ - **Unknown keys are preserved.** Your tracker's extra fields round-trip.
97
+ - **Every column has a `column.md`.** It keeps empty columns in git and
98
+ tells an arriving agent what belongs there and how to leave.
99
+ - **The board declares its own vocabulary.** `board.md` lists the columns
100
+ and, optionally, the `kinds` an issue may have and which kinds may
101
+ contain which. Epic, story and task are one team's words, not the
102
+ format's. Ready `board.md` files for Kanban, Scrum and Shape Up are in
103
+ [`profiles/`](profiles/).
104
+
105
+ An OIF board is a conforming [Open Knowledge Format](https://okf.md)
106
+ bundle: same substrate, same actor convention, `type: issue` on every
107
+ file. The one deliberate divergence, identity by id rather than by path,
108
+ is spelled out in SPEC.md section 9. Trackers are platforms, OIF is the
109
+ file.
110
+
111
+ ## This repository
112
+
113
+ The roadmap for OIF itself lives in [`board/`](board/), in OIF.
114
+ GitHub Issues stays open for conversation; accepted work lands in
115
+ `board/issues/` with the GitHub number kept in `external_ids`.
116
+
117
+ ## Status
118
+
119
+ 0.1 is a draft. Expect 0.x to move.
120
+
121
+ ## License
122
+
123
+ Apache-2.0.
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "oifmd"
7
+ version = "0.1.0.dev0"
8
+ description = "Open Issue Format (OIF) validator and tools"
9
+ readme = "README.md"
10
+ license = { text = "Apache-2.0" }
11
+ requires-python = ">=3.10"
12
+ dependencies = ["pyyaml>=6"]
13
+
14
+ [project.urls]
15
+ Homepage = "https://oif.md"
16
+ Source = "https://github.com/oifmd/oifmd"
17
+
18
+ [project.scripts]
19
+ oifmd = "oifmd.cli:main"
20
+
21
+ [tool.setuptools.packages.find]
22
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ """Open Issue Format (OIF) reference tooling. Spec: https://oif.md"""
2
+ __version__ = "0.1.0.dev0"
@@ -0,0 +1,2 @@
1
+ from .cli import main
2
+ raise SystemExit(main())
@@ -0,0 +1,421 @@
1
+ """oifmd — validate and operate an OIF board with nothing but the filesystem.
2
+
3
+ Usage:
4
+ oifmd validate [BOARD] exit 0 if BOARD conforms to OIF 0.1
5
+ oifmd ls [BOARD] [COLUMN] list issues by column
6
+ oifmd new BOARD COLUMN TITLE create an issue with a fresh id, print its path
7
+ oifmd id print a fresh id
8
+ oifmd index [BOARD] write an OKF-shaped root index.md
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ import secrets
14
+ import sys
15
+ from dataclasses import dataclass, field
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+
19
+ import yaml
20
+
21
+ ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz"
22
+ ID_RE = r"[0-9a-hjkmnp-tv-z]{6}"
23
+ SLUG_RE = r"[a-z0-9]+(?:-[a-z0-9]+)*"
24
+ FILENAME_RE = re.compile(rf"^(?P<slug>{SLUG_RE})-(?P<id>{ID_RE})\.md$")
25
+ COLUMN_RE = re.compile(r"^[a-z0-9]+(?:[_-][a-z0-9]+)*$")
26
+ KEY_RE = re.compile(r"^[a-z][a-z0-9]{1,15}$")
27
+ REF_RE = re.compile(rf"^(?:[a-z][a-z0-9]{{1,15}}-)?(?P<id>{ID_RE})$")
28
+ COMMENT_RE = re.compile(
29
+ r"^### (?P<ts>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2}))"
30
+ r" (?P<actor>\S+)(?P<kv>(?: [a-z][a-z0-9_]*=[A-Za-z0-9_.:/@-]+)*)\s*$"
31
+ )
32
+ RESERVED = ("id", "status", "state", "column")
33
+ RESERVED_FILES = ("column.md", "index.md", "log.md")
34
+ LIST_OF_STR = ("assignees", "tags", "aliases")
35
+ OFFSET_RE = re.compile(r"(?:Z|[+-]\d{2}:?\d{2})$")
36
+ RESOURCE_RE = re.compile(rf"^oif:[a-z][a-z0-9]{{1,15}}/{ID_RE}$")
37
+ LIST_OF_REF = ("depends_on", "related")
38
+
39
+
40
+ def new_id() -> str:
41
+ return "".join(secrets.choice(ALPHABET) for _ in range(6))
42
+
43
+
44
+ def slugify(title: str) -> str:
45
+ s = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
46
+ return s[:60].rstrip("-") or "issue"
47
+
48
+
49
+ @dataclass
50
+ class Finding:
51
+ level: str # "error" | "warn"
52
+ path: str
53
+ message: str
54
+
55
+ def __str__(self) -> str:
56
+ return f"{self.level}: {self.path}: {self.message}"
57
+
58
+
59
+ @dataclass
60
+ class Issue:
61
+ path: Path
62
+ column: str
63
+ slug: str
64
+ id: str
65
+ front: dict = field(default_factory=dict)
66
+ body: str = ""
67
+
68
+
69
+ def split_frontmatter(text: str) -> tuple[dict | None, str, str | None]:
70
+ """Return (frontmatter, body, error)."""
71
+ if not text.startswith("---\n"):
72
+ return None, text, "missing frontmatter"
73
+ end = text.find("\n---\n", 4)
74
+ if end < 0:
75
+ if text.rstrip("\n").endswith("\n---"):
76
+ end = len(text.rstrip("\n")) - 3
77
+ else:
78
+ return None, text, "unterminated frontmatter"
79
+ raw = text[4:end]
80
+ try:
81
+ data = yaml.safe_load(raw)
82
+ except yaml.YAMLError as exc: # pragma: no cover - message passthrough
83
+ return None, text, f"frontmatter is not valid YAML: {exc}"
84
+ if data is None:
85
+ data = {}
86
+ if not isinstance(data, dict):
87
+ return None, text, "frontmatter must be a YAML mapping"
88
+ return data, text[end + 5:], None
89
+
90
+
91
+ def load_board(root: Path) -> tuple[dict | None, list[Finding]]:
92
+ findings: list[Finding] = []
93
+ bm = root / "board.md"
94
+ if not bm.is_file():
95
+ return None, [Finding("error", str(bm), "board.md is required")]
96
+ front, _, err = split_frontmatter(bm.read_text(encoding="utf-8"))
97
+ if err:
98
+ return None, [Finding("error", str(bm), err)]
99
+ if front.get("type") != "board":
100
+ findings.append(Finding("error", str(bm), "board.md must have type: board"))
101
+ if str(front.get("oif", "")) != "0.1":
102
+ findings.append(Finding("error", str(bm), "oif must be \"0.1\""))
103
+ key = front.get("key")
104
+ if key is not None and not (isinstance(key, str) and KEY_RE.match(key)):
105
+ findings.append(Finding("error", str(bm), f"key {key!r} must match {KEY_RE.pattern}"))
106
+ cols = front.get("columns")
107
+ if not isinstance(cols, list) or not cols:
108
+ findings.append(Finding("error", str(bm), "columns must be a non-empty list"))
109
+ return front, findings
110
+ names = []
111
+ for c in cols:
112
+ if not isinstance(c, dict) or "name" not in c:
113
+ findings.append(Finding("error", str(bm), f"column entry {c!r} needs a name"))
114
+ continue
115
+ n = str(c["name"])
116
+ if not COLUMN_RE.match(n):
117
+ findings.append(Finding("error", str(bm), f"column name {n!r} must match {COLUMN_RE.pattern}"))
118
+ if n in names:
119
+ findings.append(Finding("error", str(bm), f"duplicate column {n!r}"))
120
+ names.append(n)
121
+ front["_column_names"] = names
122
+ cmode = front.get("comments", "sidecar")
123
+ if cmode not in ("sidecar", "inline"):
124
+ findings.append(Finding("error", str(bm), "comments must be 'sidecar' or 'inline'"))
125
+ front["_comments_mode"] = cmode if cmode in ("sidecar", "inline") else "sidecar"
126
+ kinds = front.get("kinds")
127
+ if kinds is not None:
128
+ if not isinstance(kinds, list) or not all(isinstance(k, dict) and "name" in k for k in kinds):
129
+ findings.append(Finding("error", str(bm), "kinds must be a list of {name, contains?}"))
130
+ else:
131
+ front["_kinds"] = {str(k["name"]): [str(c) for c in (k.get("contains") or [])] for k in kinds}
132
+ return front, findings
133
+
134
+
135
+ def scan_issues(root: Path, board: dict) -> tuple[list[Issue], list[Finding]]:
136
+ findings: list[Finding] = []
137
+ issues: list[Issue] = []
138
+ idir = root / "issues"
139
+ if not idir.is_dir():
140
+ return issues, [Finding("error", str(idir), "issues/ directory is required")]
141
+ declared = set(board.get("_column_names", []))
142
+ for name in sorted(declared):
143
+ cm = idir / name / "column.md"
144
+ if not (idir / name).is_dir():
145
+ findings.append(Finding("error", str(idir / name), "declared column has no directory (add a column.md)"))
146
+ elif not cm.is_file():
147
+ findings.append(Finding("error", str(cm), "every column directory must contain column.md"))
148
+ else:
149
+ front, _, err = split_frontmatter(cm.read_text(encoding="utf-8"))
150
+ if err:
151
+ findings.append(Finding("error", str(cm), err))
152
+ elif front.get("type") != "column":
153
+ findings.append(Finding("error", str(cm), "column.md must have type: column"))
154
+ elif any(k in front for k in ("wip", "complete", "hidden", "order")):
155
+ findings.append(Finding("error", str(cm), "column config belongs in board.md, not column.md"))
156
+ for col in sorted(p for p in idir.iterdir() if p.is_dir()):
157
+ if col.name not in declared:
158
+ findings.append(Finding("error", str(col), "directory is not a declared column"))
159
+ for f in sorted(col.iterdir()):
160
+ if f.name in RESERVED_FILES:
161
+ if f.name == "index.md" and f.read_text(encoding="utf-8").startswith("---\n"):
162
+ findings.append(Finding("warn", str(f), "index.md inside a column must not carry frontmatter (OKF section 8)"))
163
+ continue
164
+ if f.is_dir():
165
+ findings.append(Finding("error", str(f), "subdirectories inside a column are not allowed"))
166
+ continue
167
+ if f.suffix != ".md":
168
+ findings.append(Finding("warn", str(f), "non-markdown file in a column is ignored"))
169
+ continue
170
+ m = FILENAME_RE.match(f.name)
171
+ if not m:
172
+ findings.append(Finding("error", str(f), "filename must be <slug>-<id>.md with a 6-char Crockford id"))
173
+ continue
174
+ issues.append(Issue(f, col.name, m["slug"], m["id"]))
175
+ return issues, findings
176
+
177
+
178
+ def check_comments_dir(root: Path, ids: set[str]) -> list[Finding]:
179
+ """Validate comments/<issue-id>/<comment-id>.md (spec 4.4)."""
180
+ out: list[Finding] = []
181
+ cdir = root / "comments"
182
+ if not cdir.is_dir():
183
+ return out
184
+ for sub in sorted(p for p in cdir.iterdir()):
185
+ if not sub.is_dir():
186
+ out.append(Finding("error", str(sub), "comments/ holds one directory per issue id"))
187
+ continue
188
+ if not re.fullmatch(ID_RE, sub.name):
189
+ out.append(Finding("error", str(sub), f"{sub.name!r} is not an issue id"))
190
+ continue
191
+ if sub.name not in ids:
192
+ out.append(Finding("error", str(sub), f"no issue with id {sub.name} on this board"))
193
+ continue
194
+ for f in sorted(sub.iterdir()):
195
+ if f.name in ("index.md", "log.md"):
196
+ continue
197
+ if not re.fullmatch(rf"{ID_RE}\.md", f.name):
198
+ out.append(Finding("error", str(f), "comment filename must be <id>.md with a 6-char id"))
199
+ continue
200
+ front, _, err = split_frontmatter(f.read_text(encoding="utf-8"))
201
+ if err:
202
+ out.append(Finding("error", str(f), err)); continue
203
+ if front.get("type") != "comment":
204
+ out.append(Finding("error", str(f), "comment must have type: comment"))
205
+ at = front.get("at")
206
+ if at is None:
207
+ out.append(Finding("error", str(f), "comment requires at"))
208
+ elif isinstance(at, datetime):
209
+ if at.tzinfo is None:
210
+ out.append(Finding("error", str(f), "at must carry Z or a numeric offset"))
211
+ elif not (isinstance(at, str) and OFFSET_RE.search(at)):
212
+ out.append(Finding("error", str(f), "at must be ISO 8601 with Z or a numeric offset"))
213
+ if not isinstance(front.get("by"), str) or not front.get("by"):
214
+ out.append(Finding("error", str(f), "comment requires by (an actor)"))
215
+ return out
216
+
217
+
218
+ def check_issue(issue: Issue, ids: set[str], key: str | None = None) -> list[Finding]:
219
+ p = str(issue.path)
220
+ out: list[Finding] = []
221
+ front, body, err = split_frontmatter(issue.path.read_text(encoding="utf-8"))
222
+ if err:
223
+ return [Finding("error", p, err)]
224
+ issue.front, issue.body = front, body
225
+ if front.get("type") != "issue":
226
+ out.append(Finding("error", p, "frontmatter must have type: issue"))
227
+ res = front.get("resource")
228
+ if res is not None and not (isinstance(res, str) and RESOURCE_RE.match(res)):
229
+ out.append(Finding("error", p, "resource must be oif:<key>/<id>"))
230
+ elif isinstance(res, str) and not res.endswith("/" + issue.id):
231
+ out.append(Finding("error", p, f"resource {res} does not end with this file's id {issue.id}"))
232
+ for k in ("kind", "description", "priority"):
233
+ if k in front and not isinstance(front[k], str):
234
+ out.append(Finding("error", p, f"{k} must be a string"))
235
+ for k in RESERVED:
236
+ if k in front:
237
+ out.append(Finding("error", p, f"reserved key {k!r} must not appear in frontmatter"))
238
+ for k in LIST_OF_STR:
239
+ v = front.get(k)
240
+ if v is not None and not (isinstance(v, list) and all(isinstance(x, str) for x in v)):
241
+ out.append(Finding("error", p, f"{k} must be a list of strings"))
242
+ for k in LIST_OF_REF:
243
+ v = front.get(k)
244
+ if v is None:
245
+ continue
246
+ if not isinstance(v, list):
247
+ out.append(Finding("error", p, f"{k} must be a list of issue references"))
248
+ continue
249
+ for r in v:
250
+ out.extend(_check_ref(p, k, r, ids))
251
+ if front.get("parent") is not None:
252
+ out.extend(_check_ref(p, "parent", front["parent"], ids))
253
+ ext = front.get("external_ids")
254
+ if ext is not None and not (isinstance(ext, dict) and all(isinstance(v, str) for v in ext.values())):
255
+ out.append(Finding("error", p, "external_ids must be a map of string to string"))
256
+ created = front.get("created")
257
+ if created is not None:
258
+ if isinstance(created, datetime):
259
+ if created.tzinfo is None:
260
+ out.append(Finding("error", p, "created must carry Z or a numeric offset"))
261
+ elif not (isinstance(created, str) and OFFSET_RE.search(created)):
262
+ out.append(Finding("error", p, "created must be an ISO 8601 datetime with Z or a numeric offset"))
263
+ if "title" not in front:
264
+ out.append(Finding("warn", p, "title is recommended"))
265
+ if key and "resource" not in front:
266
+ out.append(Finding("warn", p, f"resource oif:{key}/{issue.id} is recommended"))
267
+ out.extend(_check_comments(p, body))
268
+ return out
269
+
270
+
271
+ def _check_ref(p: str, k: str, r, ids: set[str]) -> list[Finding]:
272
+ if not isinstance(r, str) or not REF_RE.match(r):
273
+ return [Finding("error", p, f"{k}: {r!r} is not an issue reference")]
274
+ m = REF_RE.match(r)
275
+ if "-" not in r and m["id"] not in ids:
276
+ return [Finding("error", p, f"{k}: {r} does not resolve within this board")]
277
+ return []
278
+
279
+
280
+ def _check_comments(p: str, body: str) -> list[Finding]:
281
+ out: list[Finding] = []
282
+ lines = body.splitlines()
283
+ h2 = [(i, l) for i, l in enumerate(lines) if l.startswith("## ")]
284
+ idx = [i for i, l in h2 if l.strip() == "## Comments"]
285
+ if not idx:
286
+ return out
287
+ if len(idx) > 1:
288
+ out.append(Finding("error", p, "more than one ## Comments section"))
289
+ start = idx[0]
290
+ if any(i > start for i, _ in h2):
291
+ out.append(Finding("error", p, "## Comments must be the last level-2 section"))
292
+ for i in range(start + 1, len(lines)):
293
+ l = lines[i]
294
+ if l.startswith("### ") and not COMMENT_RE.match(l):
295
+ out.append(Finding("error", p, f"line {i + 1}: comment heading does not match '### <timestamp> <actor> [k=v ...]'"))
296
+ return out
297
+
298
+
299
+ def validate(root: Path) -> list[Finding]:
300
+ board, findings = load_board(root)
301
+ if board is None:
302
+ return findings
303
+ issues, more = scan_issues(root, board)
304
+ findings += more
305
+ seen: dict[str, Path] = {}
306
+ for it in issues:
307
+ if it.id in seen:
308
+ findings.append(Finding("error", str(it.path), f"duplicate id {it.id} (also {seen[it.id]})"))
309
+ seen[it.id] = it.path
310
+ ids = set(seen)
311
+ key = board.get("key") if isinstance(board.get("key"), str) else None
312
+ for it in issues:
313
+ findings += check_issue(it, ids, key)
314
+ findings += check_comments_dir(root, ids)
315
+ if board.get("_comments_mode") == "sidecar":
316
+ for it in issues:
317
+ if "\n## Comments" in it.body or it.body.startswith("## Comments"):
318
+ findings.append(Finding("warn", str(it.path),
319
+ "inline ## Comments on a board declaring comments: sidecar (spec 4.3)"))
320
+ kinds = board.get("_kinds")
321
+ if kinds is not None:
322
+ by_id = {it.id: it for it in issues}
323
+ for it in issues:
324
+ k = it.front.get("kind")
325
+ if k is not None and k not in kinds:
326
+ findings.append(Finding("error", str(it.path), f"kind {k!r} is not declared in board.md kinds"))
327
+ parent = it.front.get("parent")
328
+ if isinstance(parent, str) and k is not None:
329
+ pm = REF_RE.match(parent)
330
+ pit = by_id.get(pm["id"]) if pm else None
331
+ if pit is not None:
332
+ pk = pit.front.get("kind")
333
+ if pk in kinds and k not in kinds[pk]:
334
+ findings.append(Finding("error", str(it.path), f"kind {k!r} may not be a child of {pk!r} (board.md kinds)"))
335
+ return findings
336
+
337
+
338
+ def cmd_validate(root: Path) -> int:
339
+ findings = validate(root)
340
+ for f in findings:
341
+ print(f)
342
+ errors = sum(1 for f in findings if f.level == "error")
343
+ n = sum(1 for f in (root / "issues").rglob("*.md") if f.name not in RESERVED_FILES) if (root / "issues").is_dir() else 0
344
+ c = sum(1 for f in (root / "comments").rglob("*.md") if f.name not in RESERVED_FILES) if (root / "comments").is_dir() else 0
345
+ print(f"{root}: {n} issue file(s), {c} comment file(s), {errors} error(s), {len(findings) - errors} warning(s)")
346
+ return 1 if errors else 0
347
+
348
+
349
+ def cmd_ls(root: Path, column: str | None) -> int:
350
+ board, findings = load_board(root)
351
+ if board is None:
352
+ print(findings[0]); return 1
353
+ issues, _ = scan_issues(root, board)
354
+ for col in board["_column_names"]:
355
+ if column and col != column:
356
+ continue
357
+ rows = [i for i in issues if i.column == col]
358
+ print(f"{col} ({len(rows)})")
359
+ for i in rows:
360
+ front, _, _ = split_frontmatter(i.path.read_text(encoding="utf-8"))
361
+ title = (front or {}).get("title", i.slug)
362
+ print(f" {i.id} {title}")
363
+ return 0
364
+
365
+
366
+ def cmd_new(root: Path, column: str, title: str) -> int:
367
+ board, findings = load_board(root)
368
+ if board is None:
369
+ print(findings[0]); return 1
370
+ if column not in board["_column_names"]:
371
+ print(f"error: {column!r} is not a declared column"); return 1
372
+ d = root / "issues" / column
373
+ d.mkdir(parents=True, exist_ok=True)
374
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
375
+ iid = new_id()
376
+ path = d / f"{slugify(title)}-{iid}.md"
377
+ key = board.get("key")
378
+ res = f"resource: oif:{key}/{iid}\n" if isinstance(key, str) else ""
379
+ path.write_text(f"---\ntype: issue\n{res}title: {yaml.safe_dump(title).strip()}\ncreated: {now}\n---\n\n", encoding="utf-8")
380
+ print(path)
381
+ return 0
382
+
383
+
384
+ def cmd_index(root: Path) -> int:
385
+ """Write an OKF-shaped root index.md linking board.md and every column.md."""
386
+ board, findings = load_board(root)
387
+ if board is None:
388
+ print(findings[0]); return 1
389
+ title = board.get("title") or "Board"
390
+ lines = ['---', 'okf_version: "0.2"', '---', f"# {title}", "", "## Board", "",
391
+ f"- [{title}](board.md) — columns, kinds and charter", "", "## Columns", ""]
392
+ for col in board["_column_names"]:
393
+ cm = root / "issues" / col / "column.md"
394
+ desc = ""
395
+ if cm.is_file():
396
+ front, _, _ = split_frontmatter(cm.read_text(encoding="utf-8"))
397
+ desc = (front or {}).get("description", "")
398
+ lines.append(f"- [{col}](issues/{col}/column.md)" + (f" — {desc}" if desc else ""))
399
+ (root / "index.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
400
+ print(root / "index.md")
401
+ return 0
402
+
403
+
404
+ def main(argv: list[str] | None = None) -> int:
405
+ args = list(sys.argv[1:] if argv is None else argv)
406
+ if not args or args[0] in ("-h", "--help"):
407
+ print(__doc__.strip()); return 0
408
+ cmd, rest = args[0], args[1:]
409
+ if cmd == "id":
410
+ print(new_id()); return 0
411
+ if cmd == "validate":
412
+ return cmd_validate(Path(rest[0] if rest else "."))
413
+ if cmd == "ls":
414
+ return cmd_ls(Path(rest[0] if rest else "."), rest[1] if len(rest) > 1 else None)
415
+ if cmd == "index":
416
+ return cmd_index(Path(rest[0] if rest else "."))
417
+ if cmd == "new":
418
+ if len(rest) < 3:
419
+ print("usage: oifmd new BOARD COLUMN TITLE"); return 2
420
+ return cmd_new(Path(rest[0]), rest[1], " ".join(rest[2:]))
421
+ print(f"unknown command {cmd!r}\n\n{__doc__.strip()}"); return 2
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: oifmd
3
+ Version: 0.1.0.dev0
4
+ Summary: Open Issue Format (OIF) validator and tools
5
+ License: Apache-2.0
6
+ Project-URL: Homepage, https://oif.md
7
+ Project-URL: Source, https://github.com/oifmd/oifmd
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: pyyaml>=6
12
+ Dynamic: license-file
13
+
14
+ # Open Issue Format (OIF)
15
+
16
+ **Issues as files.** A directory of Markdown files that any agent or
17
+ human can run as a board with `ls`, `cat` and `git mv`. No tool required.
18
+
19
+ - **Spec:** [SPEC.md](SPEC.md) · version 0.1 (draft)
20
+ - **Site:** https://oif.md
21
+ - **Package:** `oifmd` (validator and converters)
22
+
23
+ ## Sixty-second tour
24
+
25
+ ```
26
+ board.md
27
+ issues/
28
+ ├── backlog/
29
+ │ ├── column.md
30
+ │ └── add-jira-importer-nkhnsk.md
31
+ ├── doing/
32
+ │ ├── column.md
33
+ │ └── fix-login-redirect-loop-7k2x9m.md
34
+ └── done/
35
+ ├── column.md
36
+ └── write-spec-outline-h87456.md
37
+ ```
38
+
39
+ The directory is the status. The filename is the identity. Moving an
40
+ issue is `git mv issues/doing/fix-login-redirect-loop-7k2x9m.md issues/done/`.
41
+
42
+ `issues/doing/fix-login-redirect-loop-7k2x9m.md`:
43
+
44
+ ```markdown
45
+ ---
46
+ type: issue
47
+ resource: oif:app/7k2x9m
48
+ title: Login form rejects passwords containing "!"
49
+ kind: bug
50
+ priority: high
51
+ assignees: [coder/1.4]
52
+ requested_by: human:sam
53
+ tags: [auth]
54
+ created: 2026-09-13T03:10:00Z
55
+ ---
56
+
57
+ Submitting a correct password with `!` clears the form and shows
58
+ "invalid credentials". Expected: login succeeds.
59
+
60
+ ## Acceptance Criteria
61
+
62
+ - [x] Reproduce with a failing test
63
+ - [ ] Fix without changing the hashing path
64
+
65
+ ```
66
+
67
+ Comments are separate files, one per comment, keyed by the issue's id:
68
+
69
+ `comments/7k2x9m/k3n2wp.md`
70
+
71
+ ```markdown
72
+ ---
73
+ type: comment
74
+ at: 2026-09-13T04:12:00Z
75
+ by: human:sam
76
+ kind: verdict
77
+ result: changes_requested
78
+ ---
79
+
80
+ Keep the strip for whitespace only.
81
+ ```
82
+
83
+ Read an issue and its whole history with one command:
84
+
85
+ ```sh
86
+ cat issues/*/*-7k2x9m.md comments/7k2x9m/*.md
87
+ ```
88
+
89
+ ## Why
90
+
91
+ Every git-native tracker (Backlog.md, git-issues, beaver-backlog, beads)
92
+ converged on Markdown plus frontmatter, one file per issue. None
93
+ published the format separately from the tool. OIF is the format,
94
+ written down, with a validator, so issues outlive whichever tool wrote
95
+ them and any agent can read them cold.
96
+
97
+ Design rules that fall out of being agent-first and git-native:
98
+
99
+ - **State is the directory.** A move is one atomic rename, not a
100
+ read-modify-write that two agents can trample.
101
+ - **Identity is a random six-character id in the filename.** Two
102
+ branches can create issues at once and merge with no counter, no
103
+ scan, no renumbering. Sequential keys like `APP-2753` survive as
104
+ aliases.
105
+ - **One comment is one file**, keyed by the issue's id. Two agents
106
+ commenting at once write two different paths, so nothing conflicts and
107
+ nothing is lost. Appending to a shared file does not survive concurrent
108
+ writers; see the changelog.
109
+ - **Unknown keys are preserved.** Your tracker's extra fields round-trip.
110
+ - **Every column has a `column.md`.** It keeps empty columns in git and
111
+ tells an arriving agent what belongs there and how to leave.
112
+ - **The board declares its own vocabulary.** `board.md` lists the columns
113
+ and, optionally, the `kinds` an issue may have and which kinds may
114
+ contain which. Epic, story and task are one team's words, not the
115
+ format's. Ready `board.md` files for Kanban, Scrum and Shape Up are in
116
+ [`profiles/`](profiles/).
117
+
118
+ An OIF board is a conforming [Open Knowledge Format](https://okf.md)
119
+ bundle: same substrate, same actor convention, `type: issue` on every
120
+ file. The one deliberate divergence, identity by id rather than by path,
121
+ is spelled out in SPEC.md section 9. Trackers are platforms, OIF is the
122
+ file.
123
+
124
+ ## This repository
125
+
126
+ The roadmap for OIF itself lives in [`board/`](board/), in OIF.
127
+ GitHub Issues stays open for conversation; accepted work lands in
128
+ `board/issues/` with the GitHub number kept in `external_ids`.
129
+
130
+ ## Status
131
+
132
+ 0.1 is a draft. Expect 0.x to move.
133
+
134
+ ## License
135
+
136
+ Apache-2.0.
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/oifmd/__init__.py
5
+ src/oifmd/__main__.py
6
+ src/oifmd/cli.py
7
+ src/oifmd.egg-info/PKG-INFO
8
+ src/oifmd.egg-info/SOURCES.txt
9
+ src/oifmd.egg-info/dependency_links.txt
10
+ src/oifmd.egg-info/entry_points.txt
11
+ src/oifmd.egg-info/requires.txt
12
+ src/oifmd.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ oifmd = oifmd.cli:main
@@ -0,0 +1 @@
1
+ pyyaml>=6
@@ -0,0 +1 @@
1
+ oifmd