markback 0.3.0__tar.gz → 0.3.1__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.
Files changed (97) hide show
  1. markback-0.3.1/AGENTS.md +213 -0
  2. {markback-0.3.0 → markback-0.3.1}/PKG-INFO +55 -7
  3. {markback-0.3.0 → markback-0.3.1}/README.md +54 -6
  4. {markback-0.3.0 → markback-0.3.1}/SPEC.md +3 -3
  5. markback-0.3.1/markback/_headers.py +14 -0
  6. {markback-0.3.0 → markback-0.3.1}/markback/linter.py +15 -1
  7. {markback-0.3.0 → markback-0.3.1}/markback/parser.py +28 -24
  8. {markback-0.3.0 → markback-0.3.1}/markback/writer.py +41 -39
  9. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/CHANGELOG.md +10 -0
  10. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/PUBLISH.md +5 -0
  11. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/package.json +1 -1
  12. markback-0.3.1/packages/markback-vscode/publish.ps1 +79 -0
  13. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/src/preview/inject.js +58 -1
  14. markback-0.3.1/packages/markback-vscode/test/wordDelete.test.js +79 -0
  15. {markback-0.3.0 → markback-0.3.1}/pyproject.toml +1 -1
  16. markback-0.3.1/scripts/gen_headers.py +94 -0
  17. markback-0.3.1/shared/headers.json +7 -0
  18. markback-0.3.1/skills/README.md +26 -0
  19. markback-0.3.1/skills/markback/SKILL.md +432 -0
  20. markback-0.3.1/skills/markback/references/tooling.md +132 -0
  21. markback-0.3.1/tests/test_headers_registry.py +56 -0
  22. {markback-0.3.0 → markback-0.3.1}/tests/test_linter.py +30 -0
  23. {markback-0.3.0 → markback-0.3.1}/tests/test_parser.py +71 -0
  24. markback-0.3.0/AGENTS.md +0 -135
  25. {markback-0.3.0 → markback-0.3.1}/.gitignore +0 -0
  26. {markback-0.3.0 → markback-0.3.1}/.ishipped/card.md +0 -0
  27. {markback-0.3.0 → markback-0.3.1}/.ishipped/markback-icon.png +0 -0
  28. {markback-0.3.0 → markback-0.3.1}/IMPLEMENTATION_NOTES.md +0 -0
  29. {markback-0.3.0 → markback-0.3.1}/LICENSE +0 -0
  30. {markback-0.3.0 → markback-0.3.1}/markback/__init__.py +0 -0
  31. {markback-0.3.0 → markback-0.3.1}/markback/cli.py +0 -0
  32. {markback-0.3.0 → markback-0.3.1}/markback/types.py +0 -0
  33. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/.vscode/launch.json +0 -0
  34. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/.vscode/tasks.json +0 -0
  35. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/.vscodeignore +0 -0
  36. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/LICENSE +0 -0
  37. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/README.md +0 -0
  38. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/esbuild.js +0 -0
  39. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/icon.png +0 -0
  40. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/media/SHOTLIST.md +0 -0
  41. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/package-lock.json +0 -0
  42. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/src/actionState.ts +0 -0
  43. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/src/author.ts +0 -0
  44. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/src/commandLayer.ts +0 -0
  45. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/src/commentControlPlane.ts +0 -0
  46. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/src/excerpt.ts +0 -0
  47. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/src/extension.ts +0 -0
  48. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/src/focusCollision.ts +0 -0
  49. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/src/ids.ts +0 -0
  50. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/src/markdownItPlugin.ts +0 -0
  51. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/src/output.ts +0 -0
  52. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/src/preview/inject.css +0 -0
  53. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/src/projection.ts +0 -0
  54. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/src/rangeCodec.ts +0 -0
  55. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/src/sidecarPath.ts +0 -0
  56. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/src/sidecarRepository.ts +0 -0
  57. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/test/actionState.test.js +0 -0
  58. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/test/excerpt.test.js +0 -0
  59. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/test/focusCollision.test.js +0 -0
  60. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/test/ids.test.js +0 -0
  61. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/test/projection.test.js +0 -0
  62. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/test/rangeCodec.test.js +0 -0
  63. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/test/sidecarPath.test.js +0 -0
  64. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/test/sidecarRepository.test.js +0 -0
  65. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/test-integration/runTest.js +0 -0
  66. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/test-integration/suite/actions.test.js +0 -0
  67. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/test-integration/suite/commentMultiAndCancel.test.js +0 -0
  68. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/test-integration/suite/commentSelection.test.js +0 -0
  69. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/test-integration/suite/index.js +0 -0
  70. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/test-integration/suite/inlineExcerpt.test.js +0 -0
  71. {markback-0.3.0 → markback-0.3.1}/packages/markback-vscode/tsconfig.json +0 -0
  72. {markback-0.3.0 → markback-0.3.1}/scripts/bump-version.sh +0 -0
  73. {markback-0.3.0 → markback-0.3.1}/scripts/publish-npm.sh +0 -0
  74. {markback-0.3.0 → markback-0.3.1}/scripts/publish-pypi.sh +0 -0
  75. {markback-0.3.0 → markback-0.3.1}/scripts/publish.sh +0 -0
  76. {markback-0.3.0 → markback-0.3.1}/tests/__init__.py +0 -0
  77. {markback-0.3.0 → markback-0.3.1}/tests/fixtures/compact_source.mb +0 -0
  78. {markback-0.3.0 → markback-0.3.1}/tests/fixtures/errors/content_with_source.mb +0 -0
  79. {markback-0.3.0 → markback-0.3.1}/tests/fixtures/errors/empty_feedback.mb +0 -0
  80. {markback-0.3.0 → markback-0.3.1}/tests/fixtures/errors/malformed_uri.mb +0 -0
  81. {markback-0.3.0 → markback-0.3.1}/tests/fixtures/errors/missing_feedback.mb +0 -0
  82. {markback-0.3.0 → markback-0.3.1}/tests/fixtures/errors/multiple_feedback.mb +0 -0
  83. {markback-0.3.0 → markback-0.3.1}/tests/fixtures/essay.label.txt +0 -0
  84. {markback-0.3.0 → markback-0.3.1}/tests/fixtures/essay.txt +0 -0
  85. {markback-0.3.0 → markback-0.3.1}/tests/fixtures/external_source.mb +0 -0
  86. {markback-0.3.0 → markback-0.3.1}/tests/fixtures/freeform_feedback.mb +0 -0
  87. {markback-0.3.0 → markback-0.3.1}/tests/fixtures/json_feedback.mb +0 -0
  88. {markback-0.3.0 → markback-0.3.1}/tests/fixtures/label_list.mb +0 -0
  89. {markback-0.3.0 → markback-0.3.1}/tests/fixtures/minimal.mb +0 -0
  90. {markback-0.3.0 → markback-0.3.1}/tests/fixtures/minimal.mb.mb +0 -0
  91. {markback-0.3.0 → markback-0.3.1}/tests/fixtures/multi_record.mb +0 -0
  92. {markback-0.3.0 → markback-0.3.1}/tests/fixtures/with_uri.mb +0 -0
  93. {markback-0.3.0 → markback-0.3.1}/tests/test_actions.py +0 -0
  94. {markback-0.3.0 → markback-0.3.1}/tests/test_cli.py +0 -0
  95. {markback-0.3.0 → markback-0.3.1}/tests/test_eol.py +0 -0
  96. {markback-0.3.0 → markback-0.3.1}/tests/test_types.py +0 -0
  97. {markback-0.3.0 → markback-0.3.1}/tests/test_writer.py +0 -0
@@ -0,0 +1,213 @@
1
+ # Writing Markback (.mb) files
2
+
3
+ Markback pairs content with single-line feedback using `<<<` as the delimiter.
4
+
5
+ This is the quickstart. For the full read/write manual — including the traps
6
+ that cost real time — load `skills/markback/SKILL.md`. `SPEC.md` is the
7
+ authority.
8
+
9
+ ## Minimal record
10
+
11
+ ```
12
+ Some content here.
13
+ <<< positive
14
+ ```
15
+
16
+ ## Record with headers
17
+
18
+ ```
19
+ @id item-001
20
+ @by reviewer@example.com
21
+ @file ./file.txt
22
+ @input ./prompt.txt
23
+ @tag review p1
24
+
25
+ Inline content goes here.
26
+ <<< good; quality=high
27
+ ```
28
+
29
+ Headers: `@id`, `@reply-to`, `@by`, `@action`, `@tag`, `@input`, `@file`. All
30
+ optional, and that is also their canonical order.
31
+
32
+ ## Rules
33
+
34
+ - `<<<` must be followed by one space then feedback text
35
+ - a blank line is **required** between headers and inline content — omit it and
36
+ a content line starting with `@` is parsed as a header, and the content is
37
+ silently dropped
38
+ - `@file` + inline content can coexist (file is provenance, content is snapshot)
39
+ - full records are separated by `---`, written directly after the preceding
40
+ `<<<` line with no blank line before it
41
+ - files must be UTF-8 with LF line endings
42
+ - `@id` values are plain strings (no URI validation)
43
+
44
+ ## Compact format (one record per line)
45
+
46
+ ```
47
+ @file ./images/001.jpg <<< approved; scene=beach
48
+ @file ./images/002.jpg <<< rejected; too dark
49
+ ```
50
+
51
+ No `---` separator needed between compact records. `@id` can go on the line above:
52
+
53
+ ```
54
+ @id item-001
55
+ @file ./file.txt <<< good
56
+ ```
57
+
58
+ Do not leave a blank line before those headers — in 0.3.0 that makes the parser
59
+ discard the record's own `@id`, `@by`, and `@tag` and inherit the previous
60
+ record's instead ([#15](https://github.com/dandriscoll/markback/issues/15)).
61
+ Pack compact records together, or separate them with `---`.
62
+
63
+ ## Multi-record file
64
+
65
+ ```
66
+ @id first
67
+
68
+ First content.
69
+ <<< positive
70
+ ---
71
+ @id second
72
+
73
+ Second content.
74
+ <<< negative; needs work
75
+ ```
76
+
77
+ ## Multi-segment section
78
+
79
+ Several comments on one source, without repeating the headers. Write successive
80
+ content + `<<<` pairs with no `---` between them; the first segment's `@file`,
81
+ `@by`, `@tag`, and `@input` carry to the rest.
82
+
83
+ ```
84
+ @file ./essay.txt
85
+ @by dan
86
+
87
+ the lazy fox
88
+ <<< awkward
89
+
90
+ weak ending
91
+ <<< needs punch
92
+ ```
93
+
94
+ That is two records, both on `./essay.txt`. A `---` ends the section. `@id`,
95
+ `@reply-to`, and `@action` are per-record and never inherited.
96
+
97
+ ## File-level headers (% prefix)
98
+
99
+ ```
100
+ %markback 2
101
+ %scope issue-A issue-B
102
+ %covers ./gen/batch3/*.txt
103
+
104
+ @file ./gen/batch3/file2.txt <<< issue-B; tone is off
105
+ ```
106
+
107
+ - `%markback 2` — version declaration
108
+ - `%scope` — what issues are being checked (sweep pattern)
109
+ - `%covers` — glob of all files reviewed (absence = clean for scope)
110
+
111
+ All `%` lines go at the very top, before any record.
112
+
113
+ ## Tags
114
+
115
+ ```
116
+ @id item-001
117
+ @tag training positive-examples batch-2024-03
118
+ @file ./data/example.txt
119
+ <<< approved
120
+ ```
121
+
122
+ Space-separated. Multiple `@tag` lines merge.
123
+
124
+ ## Threading and lifecycle
125
+
126
+ `@reply-to` carries the `@id` of the record being replied to:
127
+
128
+ ```
129
+ @id c1
130
+ @file ./login.py:42 <<< this branch never fires
131
+ ---
132
+ @id c2
133
+ @reply-to c1
134
+ @file ./login.py:42 <<< it does — covered by test_login_edge()
135
+ ```
136
+
137
+ `@action <verb> <timestamp> [actor]` records a lifecycle event. Repeatable and
138
+ order-preserving; the well-known verbs are `created`, `viewed`, `resolved`,
139
+ `reopened`.
140
+
141
+ ```
142
+ @action created 2026-06-17T10:00:00Z dan@example.com
143
+ @action resolved 2026-06-18T14:30:00Z Reviewer Two
144
+ ```
145
+
146
+ A record is resolved when its most recent `resolved`/`reopened` action is
147
+ `resolved`.
148
+
149
+ ## Feedback format
150
+
151
+ Feedback is freeform text. Optional structured convention:
152
+
153
+ | Pattern | Meaning |
154
+ |---------|---------|
155
+ | `<<< positive` | label |
156
+ | `<<< negative; too vague` | label + comment |
157
+ | `<<< good; quality=high` | label + attribute |
158
+ | `<<< quality=high; confidence=0.9` | attributes only |
159
+ | `<<< json:{"key":"value"}` | JSON mode |
160
+
161
+ Segments are separated by `; ` (semicolon + space). Segments with `=` are key-value attributes; without are labels or comments.
162
+
163
+ ## Multi-line feedback
164
+
165
+ When the text right after `<<< ` is exactly `"""`, the feedback runs until a
166
+ line whose only content is `"""`:
167
+
168
+ ```
169
+ @id c1
170
+ @file ./login.py:42
171
+ <<< """
172
+ This branch looks dead, but I want to double-check before
173
+ suggesting removal.
174
+ """
175
+ ```
176
+
177
+ Use it only when the feedback genuinely contains a newline.
178
+
179
+ ## Sidecar files
180
+
181
+ Content in `name.ext`, annotation in `name.ext.mb`:
182
+
183
+ **report.pdf** — the content
184
+ **report.pdf.mb:**
185
+ ```
186
+ @id report-001
187
+ <<< good; grade=B+
188
+ ```
189
+
190
+ ## Line/character ranges
191
+
192
+ `@file` and `@input` support position references:
193
+
194
+ ```
195
+ @file ./code.py:42 ← line 42
196
+ @file ./code.py:42-50 ← lines 42–50
197
+ @file ./code.py:10:5-15:20 ← line 10 col 5 to line 15 col 20
198
+ ```
199
+
200
+ ## V1 backward compatibility
201
+
202
+ V1 headers (`@uri`, `@source`, `@prior`) are automatically mapped to V2 (`@id`, `@file`, `@input`) with a W010 warning.
203
+
204
+ ## Quick checklist
205
+
206
+ - [ ] every record has exactly one `<<<` line
207
+ - [ ] feedback is a single line, or fenced with `"""`
208
+ - [ ] blank line before inline content
209
+ - [ ] `---` between full records, no blank line before it; not needed between compact records
210
+ - [ ] file ends with a newline
211
+
212
+ Validate with `mb --lint file.mb`. Add `--no-canonical-check` if the file has
213
+ `%` headers — W008 always fires on those.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: markback
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Markback — comments for anything, in plain text, in git.
5
5
  Project-URL: Homepage, https://markback.org
6
6
  Project-URL: Repository, https://github.com/dandriscoll/markback
@@ -49,7 +49,8 @@ work. CLI, Python, Node, a browser editor, and a VS Code extension.
49
49
  - **Try it without installing** — [markback.org/try-it](https://markback.org/try-it)
50
50
  - **Install the CLI** — `pip install markback` or `npm install markbackjs`
51
51
  - **Read the format** — [Specification](https://markback.org/format/spec)
52
- - **Comment from your editor** — [VS Code extension v0.1](https://github.com/dandriscoll/markback/tree/main/packages/markback-vscode)
52
+ - **Comment from your editor** — [VS Code extension](https://marketplace.visualstudio.com/items?itemName=dandriscoll.markback-vscode)
53
+ - **Point your coding agent at it** — [`skills/markback`](skills/markback/SKILL.md)
53
54
 
54
55
  ## Installation
55
56
 
@@ -57,7 +58,7 @@ work. CLI, Python, Node, a browser editor, and a VS Code extension.
57
58
  pip install -e .
58
59
  ```
59
60
 
60
- ## Quick Start
61
+ ## Quick start
61
62
 
62
63
  ### Parse a Markback file
63
64
 
@@ -106,7 +107,7 @@ if result.has_errors:
106
107
  print(d)
107
108
  ```
108
109
 
109
- ## CLI Usage
110
+ ## CLI usage
110
111
 
111
112
  The CLI is available via `markback` or `mb` (shorthand).
112
113
 
@@ -178,18 +179,22 @@ mb --upgrade *.mb # preview
178
179
  mb --upgrade --apply --in-place *.mb # apply
179
180
  ```
180
181
 
181
- ## File Format
182
+ ## File format
182
183
 
183
- ### V2 Headers
184
+ ### Headers
184
185
 
185
186
  | Header | Purpose |
186
187
  |--------|---------|
187
188
  | `@id` | Record identifier (plain string) |
189
+ | `@reply-to` | The `@id` this record replies to |
188
190
  | `@by` | Who provided feedback |
191
+ | `@action` | `<verb> <timestamp> [actor]` lifecycle event; repeatable |
189
192
  | `@tag` | Space-separated tags |
190
193
  | `@input` | What produced the content (e.g., a prompt) |
191
194
  | `@file` | Path to the content being annotated |
192
195
 
196
+ Listed in canonical order. All are optional.
197
+
193
198
  ### File-level headers (% prefix)
194
199
 
195
200
  ```
@@ -216,6 +221,49 @@ mb --upgrade --apply --in-place *.mb # apply
216
221
  @file ./images/002.jpg <<< rejected; too dark
217
222
  ```
218
223
 
224
+ ### Multi-segment section
225
+
226
+ Several comments on one source, without repeating the headers — write
227
+ successive content + `<<<` pairs with no `---` between them:
228
+
229
+ ```
230
+ @file ./essay.txt
231
+
232
+ the lazy fox
233
+ <<< awkward
234
+
235
+ weak ending
236
+ <<< needs punch
237
+ ```
238
+
239
+ Two records, both on `./essay.txt`. A `---` ends the section.
240
+
241
+ ### Threading and lifecycle
242
+
243
+ ```
244
+ @id c1
245
+ @action created 2026-06-17T10:00:00Z dan@example.com
246
+ @file ./login.py:42 <<< this branch never fires
247
+ ---
248
+ @id c2
249
+ @reply-to c1
250
+ @file ./login.py:42 <<< it does — covered by test_login_edge()
251
+ ```
252
+
253
+ ### Multi-line feedback
254
+
255
+ When the text right after `<<< ` is exactly `"""`, feedback runs until a line
256
+ whose only content is `"""`:
257
+
258
+ ```
259
+ @id c1
260
+ @file ./login.py:42
261
+ <<< """
262
+ This branch looks dead, but I want to double-check before
263
+ suggesting removal.
264
+ """
265
+ ```
266
+
219
267
  ### Sidecar files
220
268
 
221
269
  Content in `report.pdf`, annotation in `report.pdf.mb`:
@@ -240,7 +288,7 @@ Track issues across batches with meaningful absence:
240
288
 
241
289
  Files matching `%covers` without annotations are implicitly clean for all `%scope` items.
242
290
 
243
- ## V1 Backward Compatibility
291
+ ## V1 backward compatibility
244
292
 
245
293
  V1 headers (`@uri`, `@source`, `@prior`) are automatically mapped to V2 equivalents with a W010 warning. The V2 parser reads V1 files transparently.
246
294
 
@@ -16,7 +16,8 @@ work. CLI, Python, Node, a browser editor, and a VS Code extension.
16
16
  - **Try it without installing** — [markback.org/try-it](https://markback.org/try-it)
17
17
  - **Install the CLI** — `pip install markback` or `npm install markbackjs`
18
18
  - **Read the format** — [Specification](https://markback.org/format/spec)
19
- - **Comment from your editor** — [VS Code extension v0.1](https://github.com/dandriscoll/markback/tree/main/packages/markback-vscode)
19
+ - **Comment from your editor** — [VS Code extension](https://marketplace.visualstudio.com/items?itemName=dandriscoll.markback-vscode)
20
+ - **Point your coding agent at it** — [`skills/markback`](skills/markback/SKILL.md)
20
21
 
21
22
  ## Installation
22
23
 
@@ -24,7 +25,7 @@ work. CLI, Python, Node, a browser editor, and a VS Code extension.
24
25
  pip install -e .
25
26
  ```
26
27
 
27
- ## Quick Start
28
+ ## Quick start
28
29
 
29
30
  ### Parse a Markback file
30
31
 
@@ -73,7 +74,7 @@ if result.has_errors:
73
74
  print(d)
74
75
  ```
75
76
 
76
- ## CLI Usage
77
+ ## CLI usage
77
78
 
78
79
  The CLI is available via `markback` or `mb` (shorthand).
79
80
 
@@ -145,18 +146,22 @@ mb --upgrade *.mb # preview
145
146
  mb --upgrade --apply --in-place *.mb # apply
146
147
  ```
147
148
 
148
- ## File Format
149
+ ## File format
149
150
 
150
- ### V2 Headers
151
+ ### Headers
151
152
 
152
153
  | Header | Purpose |
153
154
  |--------|---------|
154
155
  | `@id` | Record identifier (plain string) |
156
+ | `@reply-to` | The `@id` this record replies to |
155
157
  | `@by` | Who provided feedback |
158
+ | `@action` | `<verb> <timestamp> [actor]` lifecycle event; repeatable |
156
159
  | `@tag` | Space-separated tags |
157
160
  | `@input` | What produced the content (e.g., a prompt) |
158
161
  | `@file` | Path to the content being annotated |
159
162
 
163
+ Listed in canonical order. All are optional.
164
+
160
165
  ### File-level headers (% prefix)
161
166
 
162
167
  ```
@@ -183,6 +188,49 @@ mb --upgrade --apply --in-place *.mb # apply
183
188
  @file ./images/002.jpg <<< rejected; too dark
184
189
  ```
185
190
 
191
+ ### Multi-segment section
192
+
193
+ Several comments on one source, without repeating the headers — write
194
+ successive content + `<<<` pairs with no `---` between them:
195
+
196
+ ```
197
+ @file ./essay.txt
198
+
199
+ the lazy fox
200
+ <<< awkward
201
+
202
+ weak ending
203
+ <<< needs punch
204
+ ```
205
+
206
+ Two records, both on `./essay.txt`. A `---` ends the section.
207
+
208
+ ### Threading and lifecycle
209
+
210
+ ```
211
+ @id c1
212
+ @action created 2026-06-17T10:00:00Z dan@example.com
213
+ @file ./login.py:42 <<< this branch never fires
214
+ ---
215
+ @id c2
216
+ @reply-to c1
217
+ @file ./login.py:42 <<< it does — covered by test_login_edge()
218
+ ```
219
+
220
+ ### Multi-line feedback
221
+
222
+ When the text right after `<<< ` is exactly `"""`, feedback runs until a line
223
+ whose only content is `"""`:
224
+
225
+ ```
226
+ @id c1
227
+ @file ./login.py:42
228
+ <<< """
229
+ This branch looks dead, but I want to double-check before
230
+ suggesting removal.
231
+ """
232
+ ```
233
+
186
234
  ### Sidecar files
187
235
 
188
236
  Content in `report.pdf`, annotation in `report.pdf.mb`:
@@ -207,7 +255,7 @@ Track issues across batches with meaningful absence:
207
255
 
208
256
  Files matching `%covers` without annotations are implicitly clean for all `%scope` items.
209
257
 
210
- ## V1 Backward Compatibility
258
+ ## V1 backward compatibility
211
259
 
212
260
  V1 headers (`@uri`, `@source`, `@prior`) are automatically mapped to V2 equivalents with a W010 warning. The V2 parser reads V1 files transparently.
213
261
 
@@ -1253,18 +1253,18 @@ full-record = [headers] [content-block] feedback-line
1253
1253
  headers = 1*header-line
1254
1254
  header-line = "@" keyword SP value LF
1255
1255
  keyword = 1*LOWER
1256
- value = *VCHAR
1256
+ value = *(VCHAR / SP) ; freeform; may contain interior spaces; ends at LF
1257
1257
 
1258
1258
  content-block = blank-line content
1259
1259
  content = 1*content-line
1260
- content-line = *VCHAR LF ; any line not starting with <<<
1260
+ content-line = *(VCHAR / SP) LF ; any line not starting with <<<
1261
1261
 
1262
1262
  blank-line = LF
1263
1263
 
1264
1264
  ; === Feedback ===
1265
1265
 
1266
1266
  feedback-line = "<<<" SP feedback-content LF
1267
- feedback-content = *VCHAR ; no LF allowed
1267
+ feedback-content = *(VCHAR / SP) ; freeform; no LF allowed
1268
1268
 
1269
1269
  ; === Compact Record ===
1270
1270
 
@@ -0,0 +1,14 @@
1
+ """DO NOT EDIT — generated from shared/headers.json by scripts/gen_headers.py."""
2
+
3
+ # Header keywords the parser recognizes (others emit W002).
4
+ KNOWN_HEADERS = set(["id", "by", "file", "input", "tag", "reply-to", "action"])
5
+
6
+ # The order headers are emitted in canonical output (SPEC §7.1).
7
+ CANONICAL_ORDER = ["id", "reply-to", "by", "action", "tag", "input", "file"]
8
+
9
+ # Headers inherited by continuation segments of a multi-segment section
10
+ # (@id, @reply-to, and @action are per-record and never inherited).
11
+ SECTION_INHERITED = set(["file", "by", "tag", "input"])
12
+
13
+ # V1 header names mapped to their V2 equivalents (each emits W010).
14
+ V1_HEADER_MAP = {"uri": "id", "source": "file", "prior": "input"}
@@ -248,11 +248,22 @@ def lint_canonical_format(
248
248
  records: list[Record],
249
249
  original_text: str,
250
250
  file: Optional[Path],
251
+ scope: Optional[list[str]] = None,
252
+ covers: Optional[str] = None,
253
+ version: Optional[int] = None,
251
254
  ) -> list[Diagnostic]:
252
255
  """Check if file is in canonical format."""
253
256
  diagnostics: list[Diagnostic] = []
254
257
 
255
- canonical = write_string(records, version_header=False)
258
+ # The comparison render must carry the file's own %-headers, otherwise any
259
+ # canonical file with %markback/%scope/%covers mismatches and reports a
260
+ # false W008. version_header is on iff the file declared a %markback line.
261
+ canonical = write_string(
262
+ records,
263
+ scope=scope,
264
+ covers=covers,
265
+ version_header=version is not None,
266
+ )
256
267
  original_normalized = original_text.replace('\r\n', '\n').replace('\r', '\n')
257
268
 
258
269
  if original_normalized != canonical:
@@ -307,6 +318,9 @@ def lint_string(
307
318
  result.records,
308
319
  text,
309
320
  source_file,
321
+ scope=result.scope,
322
+ covers=result.covers,
323
+ version=result.version,
310
324
  ))
311
325
 
312
326
  return result
@@ -4,6 +4,7 @@ import re
4
4
  from pathlib import Path
5
5
  from typing import Optional
6
6
 
7
+ from ._headers import KNOWN_HEADERS, SECTION_INHERITED, V1_HEADER_MAP
7
8
  from .types import (
8
9
  Action,
9
10
  Diagnostic,
@@ -15,13 +16,6 @@ from .types import (
15
16
  WarningCode,
16
17
  )
17
18
 
18
-
19
- # V2 known header keywords
20
- KNOWN_HEADERS = {"id", "by", "file", "input", "tag", "reply-to", "action"}
21
-
22
- # V1 header mapping for backward compatibility
23
- V1_HEADER_MAP = {"uri": "id", "source": "file", "prior": "input"}
24
-
25
19
  # Patterns
26
20
  HEADER_PATTERN = re.compile(r"^@([a-z][a-z-]*)\s+(.+)$")
27
21
  FEEDBACK_DELIMITER = "<<<"
@@ -192,21 +186,22 @@ def parse_string(
192
186
  had_blank_line: bool = False
193
187
  past_file_headers: bool = False
194
188
 
195
- SECTION_INHERITED = ("file", "by", "tag", "input")
196
-
197
189
  def finalize_record(feedback: str, end_line: int):
198
190
  """Create a record from current state, then reset for next segment."""
199
191
  nonlocal current_headers, current_actions, current_content_lines, current_start_line
200
192
  nonlocal pending_id, in_content, had_blank_line, section_headers
201
193
 
194
+ # Section-inherited headers (@file, @by, @tag, @input) fall back to the
195
+ # section value only when this segment did not declare its own. @id and
196
+ # @reply-to are per-record and never inherited.
202
197
  record_id = current_headers.get("id") or pending_id
203
- by = current_headers.get("by")
198
+ by = current_headers.get("by") or section_headers.get("by")
204
199
  reply_to = current_headers.get("reply-to")
205
- file_str = current_headers.get("file")
200
+ file_str = current_headers.get("file") or section_headers.get("file")
206
201
  file_ref = FileRef(file_str) if file_str else None
207
- input_str = current_headers.get("input")
202
+ input_str = current_headers.get("input") or section_headers.get("input")
208
203
  input_ref = FileRef(input_str) if input_str else None
209
- tag_str = current_headers.get("tag")
204
+ tag_str = current_headers.get("tag") or section_headers.get("tag")
210
205
  tags = tag_str.split() if tag_str else []
211
206
 
212
207
  content = None
@@ -243,8 +238,12 @@ def parse_string(
243
238
  if k in SECTION_INHERITED
244
239
  }
245
240
 
246
- # Reset state for the next segment, inheriting section headers.
247
- current_headers = section_headers.copy()
241
+ # Reset state for the next segment. current_headers holds only what the
242
+ # NEXT segment declares itself; inherited values live in section_headers
243
+ # and are merged in at finalize. Starting empty (rather than a copy of
244
+ # section_headers) is what lets a blank line between records read as a
245
+ # record separator, and a re-declared @tag override rather than merge.
246
+ current_headers = {}
248
247
  current_actions = []
249
248
  current_content_lines = []
250
249
  current_start_line = end_line + 1
@@ -312,7 +311,7 @@ def parse_string(
312
311
  if line_type == LineType.SEPARATOR:
313
312
  # `---` ends the section: clear inheritable headers entirely.
314
313
  # Error only if user added headers/content since last finalize.
315
- if current_content_lines or current_headers != section_headers:
314
+ if current_content_lines or current_headers:
316
315
  add_diagnostic(
317
316
  Severity.ERROR,
318
317
  ErrorCode.E001,
@@ -330,10 +329,15 @@ def parse_string(
330
329
  continue
331
330
 
332
331
  if line_type == LineType.BLANK:
333
- if current_headers and not in_content:
334
- had_blank_line = True
335
- elif in_content:
332
+ if in_content:
336
333
  current_content_lines.append("")
334
+ elif current_headers:
335
+ # A blank AFTER this segment's own headers is the
336
+ # header/content separator (§3.2.1). A blank when the segment
337
+ # has declared no headers of its own is a between-records
338
+ # separator and is ignored (§3.5) — so the next @-lines are
339
+ # read as the following record's headers, not as content.
340
+ had_blank_line = True
337
341
  continue
338
342
 
339
343
  if line_type == LineType.COMPACT_RECORD:
@@ -383,11 +387,11 @@ def parse_string(
383
387
  )
384
388
 
385
389
  record_id = pending_id or current_headers.get("id")
386
- by = current_headers.get("by")
390
+ by = current_headers.get("by") or section_headers.get("by")
387
391
  reply_to = current_headers.get("reply-to")
388
- input_str = current_headers.get("input")
392
+ input_str = current_headers.get("input") or section_headers.get("input")
389
393
  input_ref = FileRef(input_str) if input_str else None
390
- tag_str = current_headers.get("tag")
394
+ tag_str = current_headers.get("tag") or section_headers.get("tag")
391
395
  tags = tag_str.split() if tag_str else []
392
396
 
393
397
  record = Record(
@@ -415,7 +419,7 @@ def parse_string(
415
419
  }
416
420
  # The compact line itself supplied @file:
417
421
  section_headers["file"] = str(file_ref)
418
- current_headers = section_headers.copy()
422
+ current_headers = {}
419
423
  current_actions = []
420
424
  current_content_lines = []
421
425
  current_start_line = end_line + 1
@@ -539,7 +543,7 @@ def parse_string(
539
543
  continue
540
544
 
541
545
  # Check for unterminated record at end of file
542
- if current_content_lines or current_headers != section_headers:
546
+ if current_content_lines or current_headers:
543
547
  add_diagnostic(
544
548
  Severity.ERROR,
545
549
  ErrorCode.E001,