@descent-vtt/spec-brief 0.1.0 → 0.2.0

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 (128) hide show
  1. package/CHANGELOG.md +264 -0
  2. package/README.md +90 -20
  3. package/dist/archive.d.ts +52 -2
  4. package/dist/archive.js +205 -37
  5. package/dist/archive.js.map +1 -1
  6. package/dist/brief.d.ts +2 -0
  7. package/dist/brief.js +5 -1
  8. package/dist/brief.js.map +1 -1
  9. package/dist/cli.d.ts +1 -1
  10. package/dist/cli.js +48 -14
  11. package/dist/cli.js.map +1 -1
  12. package/dist/collisions.d.ts +43 -12
  13. package/dist/collisions.js +90 -58
  14. package/dist/collisions.js.map +1 -1
  15. package/dist/config.d.ts +11 -0
  16. package/dist/config.js +58 -10
  17. package/dist/config.js.map +1 -1
  18. package/dist/corpus.d.ts +21 -1
  19. package/dist/corpus.js +51 -2
  20. package/dist/corpus.js.map +1 -1
  21. package/dist/engine.d.ts +33 -3
  22. package/dist/engine.js +112 -9
  23. package/dist/engine.js.map +1 -1
  24. package/dist/frontmatter.d.ts +18 -61
  25. package/dist/frontmatter.js +31 -300
  26. package/dist/frontmatter.js.map +1 -1
  27. package/dist/glob.d.ts +77 -54
  28. package/dist/glob.js +104 -433
  29. package/dist/glob.js.map +1 -1
  30. package/dist/index.d.ts +10 -8
  31. package/dist/index.js +8 -6
  32. package/dist/index.js.map +1 -1
  33. package/dist/links.d.ts +1 -1
  34. package/dist/links.js +3 -4
  35. package/dist/links.js.map +1 -1
  36. package/dist/lint.d.ts +33 -2
  37. package/dist/lint.js +9 -2
  38. package/dist/lint.js.map +1 -1
  39. package/dist/markdown.d.ts +26 -23
  40. package/dist/markdown.js +60 -237
  41. package/dist/markdown.js.map +1 -1
  42. package/dist/plugins.d.ts +14 -0
  43. package/dist/plugins.js +142 -6
  44. package/dist/plugins.js.map +1 -1
  45. package/dist/report.d.ts +40 -3
  46. package/dist/report.js +227 -11
  47. package/dist/report.js.map +1 -1
  48. package/dist/rules.d.ts +22 -4
  49. package/dist/rules.js +175 -45
  50. package/dist/rules.js.map +1 -1
  51. package/dist/scaffold.d.ts +1 -1
  52. package/dist/scaffold.js +10 -9
  53. package/dist/scaffold.js.map +1 -1
  54. package/dist/schedule.d.ts +133 -0
  55. package/dist/schedule.js +265 -0
  56. package/dist/schedule.js.map +1 -0
  57. package/dist/scope.d.ts +52 -0
  58. package/dist/scope.js +56 -0
  59. package/dist/scope.js.map +1 -0
  60. package/dist/text.d.ts +13 -0
  61. package/dist/text.js +23 -0
  62. package/dist/text.js.map +1 -1
  63. package/dist/trigger.d.ts +17 -0
  64. package/dist/trigger.js +49 -0
  65. package/dist/trigger.js.map +1 -0
  66. package/dist/types.d.ts +14 -5
  67. package/dist/types.js.map +1 -1
  68. package/dist/vendor/spec-core/markdown/frontmatter.d.ts +103 -0
  69. package/dist/vendor/spec-core/markdown/frontmatter.js +459 -0
  70. package/dist/vendor/spec-core/markdown/frontmatter.js.map +1 -0
  71. package/dist/vendor/spec-core/markdown/headings.d.ts +45 -0
  72. package/dist/vendor/spec-core/markdown/headings.js +199 -0
  73. package/dist/vendor/spec-core/markdown/headings.js.map +1 -0
  74. package/dist/vendor/spec-core/markdown/index.d.ts +5 -0
  75. package/dist/vendor/spec-core/markdown/index.js +4 -0
  76. package/dist/vendor/spec-core/markdown/index.js.map +1 -0
  77. package/dist/vendor/spec-core/markdown/layout.d.ts +39 -0
  78. package/dist/vendor/spec-core/markdown/layout.js +28 -0
  79. package/dist/vendor/spec-core/markdown/layout.js.map +1 -0
  80. package/dist/vendor/spec-core/markdown/links.d.ts +22 -0
  81. package/dist/vendor/spec-core/markdown/links.js +364 -0
  82. package/dist/vendor/spec-core/markdown/links.js.map +1 -0
  83. package/dist/vendor/spec-core/markdown/lists.d.ts +22 -0
  84. package/dist/vendor/spec-core/markdown/lists.js +127 -0
  85. package/dist/vendor/spec-core/markdown/lists.js.map +1 -0
  86. package/dist/vendor/spec-core/markdown/scan.d.ts +36 -0
  87. package/dist/vendor/spec-core/markdown/scan.js +439 -0
  88. package/dist/vendor/spec-core/markdown/scan.js.map +1 -0
  89. package/dist/vendor/spec-core/markdown/syntax.d.ts +75 -0
  90. package/dist/vendor/spec-core/markdown/syntax.js +181 -0
  91. package/dist/vendor/spec-core/markdown/syntax.js.map +1 -0
  92. package/dist/vendor/spec-core/markdown/tables.d.ts +18 -0
  93. package/dist/vendor/spec-core/markdown/tables.js +86 -0
  94. package/dist/vendor/spec-core/markdown/tables.js.map +1 -0
  95. package/dist/vendor/spec-core/markdown/types.d.ts +287 -0
  96. package/dist/vendor/spec-core/markdown/types.js +9 -0
  97. package/dist/vendor/spec-core/markdown/types.js.map +1 -0
  98. package/dist/vendor/spec-core/path/index.d.ts +1 -0
  99. package/dist/vendor/spec-core/path/index.js +2 -0
  100. package/dist/vendor/spec-core/path/index.js.map +1 -0
  101. package/dist/vendor/spec-core/path/posix.d.ts +83 -0
  102. package/dist/vendor/spec-core/path/posix.js +150 -0
  103. package/dist/vendor/spec-core/path/posix.js.map +1 -0
  104. package/dist/vendor/spec-core/pattern/automaton.d.ts +129 -0
  105. package/dist/vendor/spec-core/pattern/automaton.js +340 -0
  106. package/dist/vendor/spec-core/pattern/automaton.js.map +1 -0
  107. package/dist/vendor/spec-core/pattern/charset.d.ts +58 -0
  108. package/dist/vendor/spec-core/pattern/charset.js +116 -0
  109. package/dist/vendor/spec-core/pattern/charset.js.map +1 -0
  110. package/dist/vendor/spec-core/pattern/glob.d.ts +121 -0
  111. package/dist/vendor/spec-core/pattern/glob.js +481 -0
  112. package/dist/vendor/spec-core/pattern/glob.js.map +1 -0
  113. package/dist/vendor/spec-core/pattern/index.d.ts +6 -0
  114. package/dist/vendor/spec-core/pattern/index.js +4 -0
  115. package/dist/vendor/spec-core/pattern/index.js.map +1 -0
  116. package/dist/vendor/spec-core/pattern/regex.d.ts +67 -0
  117. package/dist/vendor/spec-core/pattern/regex.js +813 -0
  118. package/dist/vendor/spec-core/pattern/regex.js.map +1 -0
  119. package/dist/vendor/spec-core/text/index.d.ts +2 -0
  120. package/dist/vendor/spec-core/text/index.js +2 -0
  121. package/dist/vendor/spec-core/text/index.js.map +1 -0
  122. package/dist/vendor/spec-core/text/lines.d.ts +72 -0
  123. package/dist/vendor/spec-core/text/lines.js +156 -0
  124. package/dist/vendor/spec-core/text/lines.js.map +1 -0
  125. package/package.json +3 -2
  126. package/schema.json +17 -0
  127. package/src/vendor/spec-core/LICENSE +21 -0
  128. package/src/vendor/spec-core/README.md +8 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,270 @@
2
2
 
3
3
  Notable changes, newest first. Versions follow [semver](https://semver.org).
4
4
 
5
+ ## 0.2.0
6
+
7
+ Scopes are read by spec-core's glob automaton, so a collision is proved with a
8
+ file both briefs would write and protected paths are subtracted from a scope
9
+ exactly. `spec-brief schedule` places briefs in waves from their dependencies
10
+ and provable collisions. Deferred work waits on an observable trigger. A plugin
11
+ may waive a `protected-file` or `out-of-scope` refusal, which is how
12
+ spec-harness applies a signed ruling. Briefs are read with spec-core's
13
+ CommonMark scanner, and the review of 0.1.0 is fixed throughout.
14
+
15
+ ### Upgrading from 0.1.0
16
+
17
+ - **A configuration that already uses the word "deferred" no longer loads.**
18
+ Deferred work is a new status, and its word is `"deferred"` unless the
19
+ configuration says otherwise, so `"status": { "draft": "deferred" }` now
20
+ names two statuses with one word and stops the run with exit 2: "the
21
+ status words must differ: "deferred" is the word for draft and deferred".
22
+ Set `"status": { "deferred": null }` where the repository defers no work,
23
+ or give the new status a word of its own, `"deferred": "parked"`.
24
+ - **JSON documents are `schemaVersion: 2`**, every command's: the version
25
+ is one number for all of them, and `matrix`'s document changed meaning. A
26
+ collision is a pair of briefs and carries `overlaps`, a list of
27
+ `{ patterns, witness }`, in place of `patterns` and `witness`, and a
28
+ witness is always a file, never a directory; a `sharedDirectories` entry
29
+ carries `directories` in place of `directory`; and deferred briefs, and
30
+ the briefs waiting on them, left `waves[].briefs` for `deferred` and
31
+ `waiting`. `list`, `lint` and `archive` changed only by adding: `trigger`
32
+ and `sections`, a `"deferred"` status, and `path` and `paths` on a
33
+ finding. A reader of those documents reads version 2 as it read 1.
34
+ - **`**` inside a name is an error**, where it was read as `*`: a scope
35
+ written `docs/**.md` is a `glob` error in `lint`, and a `files` or
36
+ `exclude` pattern written so stops the run with exit 2. Parentheses are
37
+ literal unless a group holds a `|`. Both are under "Globs" below.
38
+ - **A note that continues its box's paragraph no longer closes the box**:
39
+ `- [ ] Add a cache` over ` **Rejected** by 012` refuses an archival as
40
+ `open-task` until the note is a bullet or has a blank line above it. See
41
+ the entry on open boxes below.
42
+
43
+ ### Breaking (library API)
44
+
45
+ - `Config['status']['deferred']` is required, `null` where a repository
46
+ has no such word. A configuration from `resolveConfig` or
47
+ `DEFAULT_CONFIG` has it; one built by hand must set it.
48
+ - `Status` gains `'deferred'`, and `Format` and `FORMATS` gain
49
+ `'gitlab'`: an exhaustive `switch` over either needs the new case.
50
+ - `CollisionReport` gains the required `deferred` and `waiting`, and
51
+ `WaveMatrix` the required `undecided`; a report built by hand carries
52
+ them. A wave's `briefs` no longer holds a deferred brief or one waiting on
53
+ deferred work.
54
+ - `Collision` carries `overlaps` in place of `patterns` and `witness`, and
55
+ `SharedDirectory` carries `directories` in place of `directory`, as the
56
+ JSON does.
57
+ - `parseGlob`'s `isFile` option is replaced by `literal` (`'file'`,
58
+ `'directory'`, `'either'` or a function, such as `readingIn(files)`), a
59
+ `Glob` carries `compiled` and `literals` in place of `alternatives`, and
60
+ `intersectGlobs` throws where the search is undecided.
61
+
62
+ ### Changes
63
+
64
+ - Markdown is read by spec-core's scanner and front matter by its reader,
65
+ copied into `src/vendor/spec-core/` beside the glob engine and verified by
66
+ hash (ADR-0001, amended). What a brief's structure is stays as it was: a
67
+ section, and the title, is an ATX heading, `## Name`, outside a block
68
+ quote, so prose over a `---` line is still prose and a rule, not a section,
69
+ and a heading quoted from another document is not one of the brief's; a task
70
+ is a list item outside a block quote with `[ ]`, `[x]` or `[X]`. What counts
71
+ as code, a comment or a link is now what CommonMark says it is:
72
+ - **Indented code and raw-text HTML are code.** Four spaces at the top
73
+ level after a blank line, and a `<pre>`, `<script>`, `<style>` or
74
+ `<textarea>` block, hold no heading, task or link: a box in an indented
75
+ example no longer refuses an archival, and a fence indented four spaces
76
+ there no longer hides the rest of the brief. Inside a list item, code
77
+ starts four columns past the item's text: a box written that deep after a
78
+ blank line is code, and a fence that deep opens nothing.
79
+ - **A `<!--` in the middle of a line with no `-->` after it is text.** It
80
+ hid everything after it, so every later section was missing. One that
81
+ opens a line still runs to the end.
82
+ - **A heading's text leaves out a comment on its line**:
83
+ `## Intent <!-- required -->` fills `Intent`, where it was reported
84
+ missing.
85
+ - **A code span may close on a later line of its paragraph**, and a link
86
+ inside one is not rewritten.
87
+ - **A thematic break ends a task**, so a note after `***` no longer closes
88
+ the box above it; and **a tab indents to the next multiple of four
89
+ columns**, so a tab-indented note under a box indented by two spaces is
90
+ part of the item.
91
+ - **A link is one CommonMark reads.** `a](b.md)` and `[a](b.md c)` are text
92
+ and are no longer rewritten on archival; a definition in a block quote
93
+ is, and so is a destination after link text that runs over lines.
94
+ - **TOML front matter** between `+++` lines is recognised. `front-matter`
95
+ reports it, where it was read as body text and only the missing status
96
+ was reported.
97
+ - **An inline list on the line under its key** - `affectedFiles:` and then
98
+ ` [src/**]` - is reported as "an inline list starts on the line after
99
+ its key; write it after the colon", where the advice was to quote it,
100
+ which would have made the list a string.
101
+ - Nothing is written into front matter that cannot hold it: TOML, or a block
102
+ never closed. `schedule --write` and `unarchive` refuse such a brief as
103
+ `front-matter`, and so does `archive` where lint's own `front-matter` error
104
+ does not refuse it already. `unarchive` stopped with an unexpected error on
105
+ a block never closed, as `archive` did with an empty banner template, and
106
+ `archive` otherwise prepended a second block above the banner.
107
+ - Reopening a brief that had no front matter, in a repository with no status
108
+ field, gives it back with none. Archival wrote a block to hold the
109
+ integrity hash, and `unarchive` took the hash out and left `---` over
110
+ `---` behind; a block the reopening empties now goes with it.
111
+ - A carriage return that ends no line, as a `\r\r\n` ending leaves one, is
112
+ read as a space; line numbers are unchanged. A front-matter line ending in
113
+ one now reads, where it was "not a key: value line".
114
+ - Library API: `Brief.frontMatter` is spec-core's `FrontMatter`. It gains
115
+ `kind` (`'yaml'` or `'toml'`), and each entry gains `parent`, `keyStart`,
116
+ `valueStart` and `valueEnd`, offsets into the brief's `lines` joined by
117
+ `\n`. `Brief.scan` gains `links`, the destinations archival rewrites.
118
+ - Globs are spec-core's `path` dialect, matched and intersected by its
119
+ automaton, which is copied into `src/vendor/spec-core/` and verified by
120
+ hash. The syntax is unchanged but for two refusals; what it means changes
121
+ in four places.
122
+ - **`**` inside a name is an error**: `docs/**.md`, `**.ts`, `a**b`. It
123
+ was read as `*`, one level, and dropped every nested file from a scope
124
+ that tools elsewhere read as recursive. The message says to write
125
+ `docs/**/*.md` for any depth, or `*.md` for one level. A scope pattern
126
+ written so is a `glob` error in `lint`, and a `files` or `exclude`
127
+ pattern stops the run with exit 2.
128
+ - **Parentheses are literal unless a group holds a `|`**:
129
+ `C++(notes).md`, `*(2017).md` and `@(a)` are names, where every
130
+ `?(`, `*(`, `+(`, `@(` or `!(` was refused as an extended glob.
131
+ `+(a|b)` is still refused, and the message now says to write `{a,b}`,
132
+ and a literal parenthesis as `[(]`.
133
+ - **A literal path is read from the tree.** A file the tree holds is that
134
+ file, a directory it holds is everything beneath it, and a path it does
135
+ not hold is a file unless written with a trailing `/`. The extension
136
+ guess is gone: it read `Dockerfile` as a directory, so `Dockerfile` and
137
+ `**/x.ts` collided through `Dockerfile/x.ts`, and read `docs/v1.2` as a
138
+ file, missing every collision inside it. Without a tree - the library
139
+ called with no files - every literal is a file.
140
+ - **A trailing `/**` or `/` is a directory's contents**, at least one name
141
+ below it: `src/**` no longer matches `src`, and a collision witness is
142
+ always a file, `docs/adr/x` where it was `docs/adr`.
143
+ - **`./` alone, and a `..` segment, are refused in the core's words**
144
+ ("the pattern names the root itself", "a pattern cannot climb out of its
145
+ root"), and a lone `}` is a literal rather than an error. A leading `/`
146
+ and a leading `!` are refused as before.
147
+ - **Protection wins.** What a brief may write is `affectedFiles` less
148
+ `protectedFiles`, so `affectedFiles: [src/**]` with
149
+ `protectedFiles: [src/db/schema.ts]` is valid. `scope-contradiction` now
150
+ fires only for affected patterns the protections cover entirely, as one
151
+ finding per brief naming each; it used to fire for any file in both.
152
+ `matrix` leaves out a file either brief of a pair protects.
153
+ - `literal-read-as-file`, a note: a path with no glob syntax that the tree
154
+ does not hold and whose name has no extension, such as `src/newmod`, is
155
+ read as a file, and the hint says to write `src/newmod/` for a directory.
156
+ `glob-matches-nothing` leaves such a pattern to it.
157
+ - `collision-undecided`, a warning: a pair of briefs whose collision the
158
+ witness search could not decide within its budget. It is never reported
159
+ as a collision and never as clean. `matrix` marks the pair `?`, and its
160
+ JSON gains an `undecided` list per wave (`{ a, b, patterns }`).
161
+ - Deferred work has a status: `status.deferred` in configuration, `"deferred"`
162
+ by default and `null` for a repository without one. A deferred brief lives
163
+ in the briefs directory, is never ready, is listed and left out by
164
+ `matrix`, and is refused by `archive` as `archive-deferred`. It names the
165
+ event that brings it back in `trigger`, a new built-in field that `list`
166
+ reports. A brief that depends on deferred work, directly or through another
167
+ brief, runs in no wave either: `matrix` lists it under `waits`, and in JSON
168
+ under `waiting` with the brief it waits on, and compares it with nothing,
169
+ as `schedule` places it nowhere.
170
+ - `deferral-trigger`, an error: a deferred brief with no `trigger`, or one
171
+ that is only a date or a time - `2026-10`, `Q3`, `next month`, `October`,
172
+ `in two weeks` - or a placeholder. "when the second tenant signs" and
173
+ "when p95 > 200 ms" pass.
174
+ - `spec-brief schedule` computes the waves the live briefs can run in
175
+ (ADR-0011): dependency order, ties by id, each brief in the lowest wave
176
+ after its live dependencies that holds nothing it collides with, a brief
177
+ with no scope in a wave of its own. Each brief's proposed wave is shown
178
+ beside its declared one with the reason for a move - the dependency, or the
179
+ brief and the file that kept it out of a wave - and deferred briefs, the
180
+ briefs waiting on them, and dependency cycles are listed. `--write` sets
181
+ `wave` in the front matter of each brief that moves, every other line
182
+ untouched and a comment after the old wave kept, as one transaction, and
183
+ writes nothing over a cycle. A declared
184
+ wave that differs from the computed one is judged as `lint` and `matrix`
185
+ judge it: no collision in it, dependencies before it, dependents after it.
186
+ One that holds is a person's choice to keep, and its move is a note.
187
+ Exit 0 when every declared wave holds, 1 when a brief declares no wave or
188
+ one that does not hold, or on a cycle. Pretty, JSON, SARIF, GitHub and
189
+ GitLab output; `wave-schedule`, an error by default, is the finding per
190
+ move, and a note for a move whose declared wave holds.
191
+ - A plugin may export a `waive` hook beside its rules (ADR-0007, amended).
192
+ After an archival is planned, and only when it has a refusal a plugin may
193
+ lift, each hook is given `{ root, brief: { id, file, text }, findings,
194
+ base, commit }`, a frozen copy, and answers `[{ rule, path, reason }]`. A
195
+ `protected-file` or `out-of-scope` refusal of the plan's own that it names
196
+ by rule and path becomes a `waived` note; a waiver for any other rule is
197
+ ignored with a `waiver-ignored` warning, and one that matches no refusal
198
+ changes nothing. A hook that returns nothing waives nothing; one that
199
+ throws - a write to the copy included - or answers in another shape stops
200
+ the run with exit 2. This is how spec-harness's plugin lets a signed ruling
201
+ allow a protected file, without spec-brief reading signatures.
202
+ - A `protected-file` refusal names its file in a new `path` field, one
203
+ finding per file as before, and an `out-of-scope` finding lists its files
204
+ in `paths`. Both appear in the JSON of `archive`; a plugin reads them.
205
+ - `--format gitlab` wherever `sarif` is offered - `lint`, `matrix`,
206
+ `schedule` - writes a GitLab Code Quality report: an array of
207
+ `{ description, check_name, fingerprint, severity, location }`, errors as
208
+ `major`, warnings as `minor`, notes as `info`, and a fingerprint that
209
+ hashes the rule, the file and the message but not the line.
210
+ - 24 lint rules and 5 collision rules.
211
+ - The library's `parseGlob`, `matchGlob`, `intersectGlobs` and `globBase`
212
+ keep their signatures over the new engine, with the options and shapes
213
+ under "Breaking (library API)" above. `globWitness`, `globCovers`,
214
+ `globBases`, `readingIn`, `meet`, `scopeOf`, `contradictions` and
215
+ `waitingOnDeferred` are new. `intersectTokens` is gone.
216
+ - A `shared-directory` finding has a hint, and a `glob` finding says what a
217
+ scope pattern looks like.
218
+ - Versions are staged by CI through npm's trusted publishing, with
219
+ provenance that names the commit and the run, and a maintainer releases
220
+ each with a second factor (ADR-0010).
221
+ - `archive` no longer passes a scope it never measured. With no `--commit`,
222
+ `--base` or `archiving.base`, without git, or with a commit already in the
223
+ base branch - where the diff from the merge base is empty - a brief that
224
+ declares `affectedFiles` or `protectedFiles` gets `scope-unmeasured`, a
225
+ warning that names the next step, and a refusal under `--strict`. It is the
226
+ first archive rule, and configuration sets its severity like any other.
227
+ - An open box is closed only by a note under it that starts with a
228
+ disposition marker, and a note starts a bullet, a paragraph after a blank
229
+ line, or a line after one that ends a sentence (`.`, `?` or `!`, then
230
+ only closing marks). A marker anywhere in the item used to count, the
231
+ box's own text included, so `- [ ] Explain why the **Rejected** designs
232
+ failed` archived as dispositioned, and so did the same sentence wrapped
233
+ after "the", indented or not. A line that continues a sentence no longer
234
+ closes the box, whatever it starts with; `- [ ] Add a cache.` over
235
+ ` **Rejected** by 012` still does. A note under a box whose text ends
236
+ without a stop, `- [ ] Add a cache` over ` **Rejected** by 012`, now
237
+ refuses the archival as `open-task`, and the hint says to make it a bullet
238
+ or put a blank line above it.
239
+ - `archiving.rewriteLinks: false` now holds for the links other briefs hold
240
+ to a moving brief, which were rewritten regardless. Those left pointing at
241
+ the old path are reported as `stale-link`, a warning naming each file and
242
+ line, on `archive` and `unarchive` alike; `unarchive --strict` refuses on
243
+ it, as `archive --strict` does.
244
+ - `matrix` reports two briefs whose scopes meet as one collision, however
245
+ many pairs of their patterns meet, and the finding lists every pair with a
246
+ path both cover. A pair writing into several shared directories is likewise
247
+ one `shared-directory` finding.
248
+ - A plugin package whose `exports` offer only the `import` condition loads.
249
+ Packages were resolved with `require.resolve`, which reads `exports` under
250
+ the require conditions, so an ESM-only plugin could not be found.
251
+ A subpath is held to `exports` as Node holds it: a target, or what a
252
+ `*` matched, with a `.`, `..` or `node_modules` segment is refused,
253
+ split on `/` and on a backslash alike, so `pkg/..\..\outside` cannot
254
+ climb out of `node_modules` on Windows.
255
+ - A brace group is split on its own commas only: `{[,]x,y}` has the two
256
+ alternatives `[,]x` and `y`, where a comma or brace inside a class used to
257
+ split the group or unbalance it.
258
+ - A section rule takes a `hint`: what the section must answer. A new brief
259
+ carries it as a comment under the heading, `missing-section`,
260
+ `empty-section` and `placeholder` findings give it as their hint, and the
261
+ JSON of `list` and `lint` gains `sections`, every section the configuration
262
+ asks for with its type, switches and hint. The default sections carry one,
263
+ and `init` writes them out. `empty-section` and `placeholder` findings have
264
+ a hint even without one.
265
+ - A `--base` or `archiving.base` that names no commit stops `archive` with a
266
+ message that says so, where git's failure to diff from it surfaced as an
267
+ unexpected error with a stack trace.
268
+
5
269
  ## 0.1.0
6
270
 
7
271
  The first release.
package/README.md CHANGED
@@ -48,7 +48,11 @@ Every privilege change issues a new session token and invalidates the old one.
48
48
  - [ ] a rotated token is rejected by every endpoint
49
49
  ```
50
50
 
51
- The id comes from the file name (`012_rotate-session-tokens.md`) or from an `id` field. `affectedFiles` is the scope the round may write; `protectedFiles` is what it is not empowered to change, and archival refuses a round that changed it.
51
+ Work put off for later is a brief too, with `status: deferred` and a `trigger` naming the event that brings it back - "when the second tenant signs", "when p95 exceeds 200 ms", never a date. A deferred brief stays in the briefs directory, is never ready, and runs in no wave until someone sets it `active`.
52
+
53
+ The id comes from the file name (`012_rotate-session-tokens.md`) or from an `id` field. `affectedFiles` is the scope the round may write; `protectedFiles` is what it is not empowered to change, and archival refuses a round that changed it. Protection wins: what a round may write is `affectedFiles` less `protectedFiles`, so "all of `src/` but the schema" is `affectedFiles: [src/**]` with `protectedFiles: [src/db/schema.ts]`.
54
+
55
+ Scopes are globs in spec-core's `path` dialect, the one every spec-\* tool reads: `*`, `?`, `[a-z]` and `{a,b}` within a name, `**` for any number of directories, compared case-sensitively on every host. `**` is a whole segment: `docs/**.md` is refused, since tools read it three ways - write `docs/**/*.md` for any depth, or `docs/*.md` for one level. Parentheses are literal, `C++(notes).md`, unless a group holds a `|`: an extended glob such as `+(a|b)` is refused, and written `{a,b}`. A trailing `/**` or `/` is a directory's contents - at least one name below it, never the directory itself. A path with no glob syntax is read from the tree: a file the tree holds is that file, a directory it holds is everything beneath it, and a path it does not hold yet is a file unless it ends in `/`. Write `src/newmod/` for a directory the round creates; `lint` notes a bare `src/newmod` it cannot place.
52
56
 
53
57
  ## Commands
54
58
 
@@ -58,7 +62,7 @@ Writes `.spec-brief.json` with every default spelled out, and creates the brief
58
62
 
59
63
  ### `spec-brief new <title>`
60
64
 
61
- Scaffolds a brief with the next free number - one more than the highest id, live or archived, since ids are never reused - and every required section, each holding a hint in a comment. A comment is not content, so a fresh brief reads as unwritten until someone writes it. `--id`, `--type`, `--wave`, `--depends-on 7,8` and `--date` fill the front matter.
65
+ Scaffolds a brief with the next free number - one more than the highest id, live or archived, since ids are never reused - and every required section, each holding its `hint` in a comment. A comment is not content, so a fresh brief reads as unwritten until someone writes it. `--id`, `--type`, `--wave`, `--depends-on 7,8` and `--date` fill the front matter.
62
66
 
63
67
  ### `spec-brief lint [brief...]`
64
68
 
@@ -76,11 +80,11 @@ briefs/035_the-background-side.md
76
80
 
77
81
  ### `spec-brief list`
78
82
 
79
- Live briefs with their status, wave, task count and readiness. A brief is *ready* when every brief it depends on is archived. `--ready` shows only those, which is the question an orchestrator asks; `--archived` includes the archive; `--format json` gives an orchestrator the whole table.
83
+ Live briefs with their status, wave, task count and readiness. A brief is *ready* when every brief it depends on is archived, and it is neither a draft nor deferred. `--ready` shows only those, which is the question an orchestrator asks; `--archived` includes the archive; `--format json` gives an orchestrator the whole table, and the sections the configuration asks for, each with its `hint`.
80
84
 
81
85
  ### `spec-brief matrix`
82
86
 
83
- Compares the `affectedFiles` of every pair of live briefs in the same wave, `--all-waves` for every pair.
87
+ Compares the `affectedFiles` of every pair of live briefs in the same wave, `--all-waves` for every pair. Deferred briefs, and briefs that wait on one directly or through another, are listed and left out: they run in no wave until the deferred work comes back, as `schedule` places them nowhere, so the two agree on which briefs run side by side.
84
88
 
85
89
  ```text
86
90
  wave 1 · 3 briefs
@@ -92,28 +96,52 @@ wave 1 · 3 briefs
92
96
  ? 003 declares no affectedFiles and cannot be checked
93
97
  ```
94
98
 
95
- Scopes are intersected as globs, not compared as strings: the two above share no prefix and still meet, and the file named is one both patterns match. A brief with no scope is reported as unscoped rather than counted as safe. Exit 1 on a collision.
99
+ Scopes are intersected as globs, not compared as strings: the two above share no prefix and still meet, and the file named is one both patterns match and neither brief protects - always a file, never a directory. Two briefs whose scopes meet in several places are one collision, listing every pair of patterns that meets. A brief with no scope is reported as unscoped rather than counted as safe. The search for a shared file has a budget; a pair it cannot decide within it is marked `?` and reported as `collision-undecided`, a warning, never as a collision and never as clean. Exit 1 on a collision.
100
+
101
+ ### `spec-brief schedule`
102
+
103
+ Computes the waves the live briefs can run in, drafts included, and sets each beside the wave it declares. A brief runs after every dependency that is still live - an archived one is done - and shares a wave with no brief whose writable scope meets its own. Briefs are taken in dependency order, ties broken by id, and each goes into the lowest wave that is after its dependencies and holds nothing it collides with. A brief with no `affectedFiles` cannot be proved apart from anything, so it runs in a wave of its own and is reported as `unscoped`. The first wave is the lowest any brief declares, or 1.
104
+
105
+ ```text
106
+ wave 1 · 2 briefs
107
+ 001 Rotate session tokens
108
+ 003 Audit log moves from wave 2
109
+ wave 2 also holds
110
+ nothing holds it later
111
+ wave 2 · 1 brief
112
+ 002 Store sessions moves from wave 1
113
+ wave 1 does not hold: 001 there also writes src/auth/session.ts
114
+ not wave 1, where 001 also writes src/auth/session.ts ("src/**/session.ts" and "src/auth/**")
115
+
116
+ waits: 007 on 006, which is deferred
117
+ deferred: 006
118
+ 2 briefs would move; "spec-brief schedule --write" writes the waves
119
+ ```
120
+
121
+ Each move says why: the dependency that sets the earliest wave, and for every wave passed over, the brief there and the file both would write. It says first whether the declared wave holds - whether `lint` and `matrix` would pass it: no brief there that it collides with, its dependencies in earlier waves, and the briefs that depend on it in later ones. A deferred brief, and one that waits on it, is placed nowhere; a dependency cycle is an error, and nothing in or after it is placed. `--write` sets `wave` in the front matter of each brief whose wave changes - every other line as it was, in one transaction - and writes nothing over a cycle. A comment after the old wave, `wave: 2 # after the review`, stays after the new one. A brief whose lines end in both LF and CRLF is written with the ending of its first line throughout, as `archive` writes one. `--format json` gives an orchestrator the waves, each brief's declared and proposed wave with the reasons and whether the declared one holds, and what waits on what; `sarif`, `github` and `gitlab` carry a `wave-schedule` finding per move.
122
+
123
+ The result is the same every time for the same briefs. It is a valid schedule, not always the shortest: no fast method promises the fewest waves, and a person can always move a brief later by hand, which `lint` and `matrix` then check. So a move is an error only when the declared wave does not hold, or there is none: a declared wave that holds is the person's to keep, and its move is a note. Exit 0 when every declared wave holds, 1 when a brief declares no wave or one that does not hold, or the dependencies form a cycle, 2 when the run cannot be trusted.
96
124
 
97
125
  ### `spec-brief archive <brief>`
98
126
 
99
127
  Closes a round. Refused, with every reason, when:
100
128
 
101
129
  - the brief has lint errors, is a draft, or depends on a brief that is still live;
102
- - a task item is neither ticked nor dispositioned - an open box counts as closed when a note under it starts with one of the `dispositions` (`**Delegated`, `**Accepted debt`, `**Rejected` by default);
130
+ - a task item is neither ticked nor dispositioned - an open box counts as closed when a note under it starts with one of the `dispositions` (`**Delegated`, `**Accepted debt`, `**Rejected` by default), a note being a line under the box, above any box nested in it, that starts a bullet, a paragraph after a blank line, or a line after one that ends a sentence. A line that continues a sentence is the box's text wrapping, not a note, so `- [ ] Explain why the` over ` **Rejected** designs failed` is open, and `- [ ] Add a cache.` over ` **Rejected** by 012` is closed;
103
131
  - the working tree holds uncommitted work outside the brief directories (`--allow-dirty` to proceed);
104
- - the round's commit changed a file in `protectedFiles`.
132
+ - the round's commit changed a file in `protectedFiles` - one refusal per file, naming it, unless a plugin that verifies rulings [waives it](#waiving-a-refusal).
105
133
 
106
- Changes outside `affectedFiles` are a warning, and an error under `--strict`. Otherwise it writes the archived brief with its status set, a frozen banner under the front matter, every relative link rewritten for the archive directory, and an `integrity` hash; rewrites the links other live briefs hold to it; and removes the original. `--commit <rev>` records the commit and the files it changed; `--base <rev>` measures from the merge base instead, for a branch of several commits; `--pr <n>` links the pull request; `--summary <text>` is what the round did, in the banner. `--dry-run` prints the plan and the banner and writes nothing. Archiving an archived brief does nothing and exits 0.
134
+ Changes outside `affectedFiles` are a warning, and an error under `--strict`. A scope is never passed by checks that measured nothing: when the files the round changed are unknown - no `--commit`, `--base` or `archiving.base`, `--no-git`, or a commit already in the base branch, as on `main` after the merge - a brief that declares one gets `scope-unmeasured`, a warning, and a refusal under `--strict`. Otherwise it writes the archived brief with its status set, a frozen banner under the front matter, every relative link rewritten for the archive directory, and an `integrity` hash; rewrites the links other live briefs hold to it; and removes the original. With `archiving.rewriteLinks` off, no link is rewritten, and the links other live briefs are left holding are reported as `stale-link`. `--commit <rev>` records the commit and the files it changed; `--base <rev>` measures from the merge base instead, for a branch of several commits; `--pr <n>` links the pull request; `--summary <text>` is what the round did, in the banner. `--dry-run` prints the plan and the banner and writes nothing. Archiving an archived brief does nothing and exits 0.
107
135
 
108
136
  spec-brief reads git and never writes it. Review the change and commit it with the round.
109
137
 
110
138
  ### `spec-brief unarchive <brief>`
111
139
 
112
- Reopens an archived brief: the banner and the hash come off, the status goes back to the live word, and the links are rewritten again. A brief archived and reopened is the brief it was, blank lines and final newline included, with two exceptions: the status line is written in its plain spelling, and a relative link that had to be rewritten comes back in its shortest form (`./b.md` returns as `b.md`).
140
+ Reopens an archived brief: the banner and the hash come off, the status goes back to the live word, and the links are rewritten again. A brief archived and reopened is the brief it was, blank lines and final newline included, with four exceptions: the status line is written in its plain spelling; a relative link that had to be rewritten comes back in its shortest form (`./b.md` returns as `b.md`); an empty front matter block, `---` over `---`, comes back as none, since archival gives a brief with no front matter a block for its hash and reopening takes away a block it leaves empty; and a brief whose lines end in both LF and CRLF comes back with the ending of its first line throughout.
113
141
 
114
142
  ### Every command
115
143
 
116
- `--root <dir>` runs from another directory. `--config <file>` names the configuration; `--no-config` uses the defaults. `--format` is `pretty`, `json`, `sarif` or `github` where the command reports findings. `--strict` makes warnings fail the run. `--no-git` leaves git out even inside a repository: the tree is read from disk, and `archive` records no commit. `--color` and `--no-color` override `NO_COLOR`, `FORCE_COLOR` and the terminal check. `--help` and `--version` do what they say.
144
+ `--root <dir>` runs from another directory. `--config <file>` names the configuration; `--no-config` uses the defaults. `--format` is `pretty`, `json`, `sarif`, `github` or `gitlab` where the command reports findings. `--strict` makes warnings fail the run. `--no-git` leaves git out even inside a repository: the tree is read from disk, and `archive` records no commit. `--color` and `--no-color` override `NO_COLOR`, `FORCE_COLOR` and the terminal check. `--help` and `--version` do what they say.
117
145
 
118
146
  **Exit codes:** `0` clean, `1` findings, a collision, or a refused action, `2` the run could not be trusted - a bad flag, a configuration that does not load, a brief that does not exist. A run over a briefs directory that does not exist exits 2, because a check over nothing looks exactly like a clean one.
119
147
 
@@ -129,16 +157,16 @@ Reopens an archived brief: the banner and the hash come off, the status goes bac
129
157
  | `exclude` | `[]` | File-name globs that are never briefs, such as an index. |
130
158
  | `template` | `null` | A template file for `new`, with `{id}`, `{title}`, `{date}`, `{type}`, `{wave}`, `{status}`. |
131
159
  | `id` | `{ "source": "filename", "separator": "_", "digits": 3 }` | Where an id comes from, and how a new one is written. |
132
- | `status` | `{ "field": "status", "draft": "draft", "active": "active", "archived": "archived" }` | The words a repository uses. `field: null` reads status from location alone. |
133
- | `sections` | Intent, Negative Scope, Not Empowered (optional), Invariants (checklist) | Sections every live brief carries: a name, or `{ name, aliases, mustContain, checklist, optional }`. |
160
+ | `status` | `{ "field": "status", "draft": "draft", "active": "active", "deferred": "deferred", "archived": "archived" }` | The words a repository uses. `field: null` reads status from location alone; `draft` and `deferred` may be `null` where a repository has no such word. |
161
+ | `sections` | Intent, Negative Scope, Not Empowered (optional), Invariants (checklist) | Sections every live brief carries: a name, or `{ name, aliases, mustContain, checklist, optional, hint }`. `hint` says what the section must answer: a new brief carries it as a comment under the heading, a finding that the section is missing or unwritten gives it as the next step, and the JSON of `list` and `lint` reports it, so a tool helping to write a brief can ask for each section by what it is for. |
134
162
  | `sectionOrder` | `false` | Sections must appear in the listed order. |
135
163
  | `types` | `feature`, `defect`, `refactor`, `chore` | Brief types and the sections each adds. |
136
164
  | `placeholders` | `TBD`, `TODO`, `FIXME`, ... | Words that mark a section as unwritten. |
137
165
  | `fields` | `[]` | Front-matter keys the repository uses beyond the built-in ones. |
138
166
  | `archiving.tasks` | `"all"` | `"all"`, or the sections whose boxes must be closed. |
139
- | `archiving.dispositions` | `**Delegated`, `**Accepted debt`, `**Rejected` | Text under an open box that closes it. |
167
+ | `archiving.dispositions` | `**Delegated`, `**Accepted debt`, `**Rejected` | What a note under an open box starts with to close it. |
140
168
  | `archiving.banner` | see [`src/config.ts`](src/config.ts) | Banner lines, with `{date}`, `{summary}`, `{pr}`, `{commit}`, `{diffstat}`, `{links}`, `{id}`, `{title}`, `{author}`. A line with an empty placeholder is left out. |
141
- | `archiving.rewriteLinks` | `true` | Rewrite relative links when a brief moves. |
169
+ | `archiving.rewriteLinks` | `true` | Rewrite relative links when a brief moves: its own, and the ones other live briefs hold to it. Off, neither is edited, and the links other briefs are left holding are reported as `stale-link`. |
142
170
  | `archiving.freeze` | `true` | Write an integrity hash, so a later edit is caught. |
143
171
  | `archiving.base` | `null` | Branch the diff is measured from, such as `"main"`. |
144
172
  | `rules` | `{}` | Severity per rule: `off`, `note`, `warning` or `error`. |
@@ -172,12 +200,14 @@ A repository whose briefs have eight ordered sections, `proposed` and `archived`
172
200
 
173
201
  Section names compare without case, typographic quotes, emphasis, a leading number or a trailing colon, and a heading may add a qualifier after a separator: `## 2. Commander’s Intent:` fills `Commander's Intent`, and `## Invariants (must hold)` fills `Invariants`.
174
202
 
203
+ A brief is read as CommonMark reads it wherever that decides what is code or a comment - fenced and indented code, `<pre>`, `<script>`, `<style>` and `<textarea>` blocks, code spans, HTML comments - and nothing inside those is structure. A section, and the title, is an ATX heading, `## Name`, outside a block quote: prose over a `---` line is prose and a rule, and a heading quoted from another document is not one of the brief's sections. A task is a list item outside a block quote with `[ ]`, `[x]` or `[X]`. The links archival rewrites are the ones that write a destination: `[text](dest)`, `![alt](dest)` - one inside a link's text too - and a definition, `[label]: dest`, through which a reference is rewritten once.
204
+
175
205
  ## Rules
176
206
 
177
207
  <!-- rules:start -->
178
208
  | Rule | Default | Reports |
179
209
  | --- | --- | --- |
180
- | `front-matter` | error | Front matter that does not parse, a duplicate key, YAML beyond the flat subset. |
210
+ | `front-matter` | error | Front matter that does not parse, a duplicate key, YAML beyond the flat subset, TOML. |
181
211
  | `field` | error | A field spec-brief reads with a value of the wrong shape: a wave that is not a whole number, a list where one value belongs. |
182
212
  | `unknown-field` | warning | A front-matter key nobody declared, with the one probably meant. |
183
213
  | `status` | error | No status, a word the configuration does not use, or a status that disagrees with the directory. |
@@ -195,25 +225,42 @@ Section names compare without case, typographic quotes, emphasis, a leading numb
195
225
  | `dependency` | error | A dependency on itself, or on a brief that does not exist. |
196
226
  | `dependency-cycle` | error | Live briefs that depend on each other in a cycle, reported once, as a path. |
197
227
  | `wave-order` | error | A live dependency that does not run in an earlier wave. |
228
+ | `deferral-trigger` | error | A deferred brief with no `trigger`, or one that is only a date or a time - `2026-10`, `Q3`, `next month`, `October` - or a placeholder, rather than an event such as "when the second tenant signs". |
198
229
  | `glob` | error | A scope pattern spec-brief cannot read. |
199
- | `scope-contradiction` | error | A file that is both in `affectedFiles` and in `protectedFiles`. |
230
+ | `scope-contradiction` | error | Patterns in `affectedFiles` that `protectedFiles` cover entirely, so nothing of them is writable - one finding per brief, naming each. A pattern the search cannot decide is a warning. |
200
231
  | `glob-matches-nothing` | note | A scope pattern that matches no file git sees, tracked or untracked - expected when the round creates it. |
232
+ | `literal-read-as-file` | note | A path with no glob syntax that the tree does not hold and whose name has no extension, such as `src/newmod`: read as a file, and `src/newmod/` if a directory was meant. |
201
233
  | `archive-freeze` | error | An archived brief that changed after it was archived. |
202
234
  | `collision` | error | Two briefs in one wave whose scopes can name the same file (`matrix`). |
203
- | `unscoped` | note | A brief sharing a wave that declares no scope (`matrix`). |
235
+ | `collision-undecided` | warning | Two briefs in one wave whose collision the search could not decide within its budget (`matrix`), or a wave `schedule` passed over for it. |
236
+ | `unscoped` | note | A brief sharing a wave that declares no scope (`matrix`), or one `schedule` runs alone. |
204
237
  | `shared-directory` | off | Two briefs in one wave writing into the same directory (`matrix`). |
238
+ | `wave-schedule` | error | A brief with no wave, or a declared wave that does not hold - a collision in it, a dependency out of order - with the wave `schedule` computes and why (`schedule`). A declared wave that holds and is not the computed one is a note. |
239
+ | `scope-unmeasured` | warning | A brief with a scope archived without the files its round changed, so nothing checked the scope (`archive`). |
240
+ | `stale-link` | warning | Links in live briefs left pointing where a brief used to be, when `archiving.rewriteLinks` is off (`archive`, `unarchive`). |
205
241
  <!-- rules:end -->
206
242
 
207
- `archive` refuses with its own reasons - `open-task`, `archive-draft`, `dependency-open`, `dirty-tree`, `protected-file`, `out-of-scope`, `archive-exists` - which are not lint rules: they are about whether this round is done, not whether the brief is well written.
243
+ `archive` refuses with its own reasons - `open-task`, `archive-draft`, `archive-deferred`, `dependency-open`, `dirty-tree`, `protected-file`, `out-of-scope`, `archive-exists` - and says `waived` and `waiver-ignored` of what [plugins](#waiving-a-refusal) lift; none of these are lint rules: they are about whether this round is done, not whether the brief is well written. The rules marked `archive` above are raised by archival too, and configuration sets their severity like any other.
208
244
 
209
245
  ## In CI
210
246
 
211
247
  ```yaml
212
248
  - run: npx spec-brief lint --format github
213
249
  - run: npx spec-brief matrix --format github
250
+ - run: npx spec-brief schedule --format github
214
251
  ```
215
252
 
216
- `github` writes workflow commands, which annotate the pull request with no upload and no permission. `sarif` writes SARIF 2.1.0 for code-scanning upload. `json` is a versioned document (`schemaVersion`) for anything else.
253
+ `github` writes workflow commands, which annotate the pull request with no upload and no permission. `sarif` writes SARIF 2.1.0 for code-scanning upload. `gitlab` writes a GitLab Code Quality report - an array of `{ description, check_name, fingerprint, severity, location: { path, lines: { begin } } }` - for a merge request to show:
254
+
255
+ ```yaml
256
+ spec-brief:
257
+ script: npx spec-brief lint --format gitlab > gl-code-quality.json
258
+ artifacts:
259
+ reports:
260
+ codequality: gl-code-quality.json
261
+ ```
262
+
263
+ An error is `major`, a warning `minor` and a note `info`; GitLab's `critical` and `blocker` are for security holes and crashes, which no finding here is. The fingerprint is a SHA-256 of the rule, the file and the message, and not the line, so a finding that moves is the same issue. `json` is a versioned document for anything else: `schemaVersion` changes when a field changes meaning, and is 2 since a collision became a pair of briefs rather than a pair of patterns.
217
264
 
218
265
  ## As a library
219
266
 
@@ -224,11 +271,14 @@ const engine = await BriefEngine.open({ cwd: process.cwd() });
224
271
  const findings = await engine.lint();
225
272
  const ready = engine.ready(); // what can run now
226
273
  const { report } = await engine.collisions(); // who collides with whom
274
+ const { schedule } = await engine.schedule(); // the waves, computed
227
275
  const plan = await engine.planArchive('012', { commit: 'HEAD', pr: 41 });
228
276
  if (plan.blocking.length === 0) await engine.apply(plan);
229
277
  ```
230
278
 
231
- Everything below the engine is a pure function of text: `parseBrief`, `lint`, `collisions`, `intersectGlobs`, `planArchive`. `MemoryFileSystem` runs an engine over files that were never written, which is how a harness can ask what archiving a brief would do.
279
+ Everything below the engine is a pure function of text: `parseBrief`, `lint`, `collisions`, `meet`, `globWitness`, `planArchive`. `MemoryFileSystem` runs an engine over files that were never written, which is how a harness can ask what archiving a brief would do.
280
+
281
+ `parseGlob`, `matchGlob`, `intersectGlobs` and `globBase` keep their 0.1 signatures as a compatibility layer over the spec-core engine. They follow its dialect: a literal is read as a file unless `parseGlob` is told otherwise (`{ literal: 'directory' | 'either' | (path) => ... }`, where `readingIn(files)` reads it from a tree), and `intersectGlobs` throws where `globWitness` would answer `undecided`.
232
282
 
233
283
  ## Plugins
234
284
 
@@ -253,6 +303,26 @@ export default (options) => ({
253
303
 
254
304
  This is where integrations belong. A tool that defines a format - signed departures, recorded reproducers, a code graph's blast radius - is the one that can check it, and spec-brief carries no copy of formats it does not own. Loading a plugin runs its code, exactly as loading a linter configuration does.
255
305
 
306
+ A path is relative to the root. A package is found from the root as an `import` there would find it, its `exports` read under the import conditions, so a plugin published as ESM only loads, and so does a subpath such as `@descent-vtt/spec-harness/spec-brief-plugin`.
307
+
308
+ ### Waiving a refusal
309
+
310
+ A plugin may also export `waive`, which lets the archive accept a change its own check allows - spec-harness's plugin verifies a signed ruling for a protected path, which spec-brief does not know how to read.
311
+
312
+ ```js
313
+ export default () => ({
314
+ name: 'rulings',
315
+ rules: [],
316
+ // context: { root, brief: { id, file, text }, findings, base, commit }
317
+ waive: async ({ findings }) =>
318
+ findings
319
+ .filter((f) => f.rule === 'protected-file' && verifiedRulingCovers(f.path))
320
+ .map((f) => ({ rule: f.rule, path: f.path, reason: 'ruling R3, signed by alice, allows it' })),
321
+ });
322
+ ```
323
+
324
+ `findings` are the archive's refusals, as a frozen copy: a hook reads them and cannot change them, and a write to them throws. A `protected-file` refusal is one per file and names its `path`; an `out-of-scope` refusal, which refuses only under `--strict`, lists its `paths`. Those two are the only ones a plugin can waive: a waiver matches one of the plan's own refusals by rule and path and turns it into a `waived` note naming the plugin, the rule, the path and the reason; a waiver for any other rule is ignored with a `waiver-ignored` warning. The hook is asked only when the plan has a refusal it could lift. A hook that returns nothing waives nothing. One that throws - a write to `findings` included - or answers in another shape stops the run with exit 2, as a plugin that fails to load does.
325
+
256
326
  ## What it does not do
257
327
 
258
328
  - **No status file.** There is no `manifest.json` to keep in step. A brief and the directory it sits in are the record; `list --format json` is the index, computed when asked. A committed index is also the one file every round merging in parallel edits.
package/dist/archive.d.ts CHANGED
@@ -15,14 +15,36 @@ export interface PullRequest {
15
15
  readonly number: number;
16
16
  readonly url: string | null;
17
17
  }
18
+ /**
19
+ * Why the files a round changed are unknown. `git`: there was no git to ask.
20
+ * `revision`: no commit was named and no base configured, so no diff was
21
+ * read. `merged`: the commit is already in the base branch, so the diff from
22
+ * their merge base is empty whatever the round changed - archiving on the base
23
+ * branch after the merge.
24
+ */
25
+ export type Unmeasured = {
26
+ readonly reason: 'git';
27
+ } | {
28
+ readonly reason: 'revision';
29
+ } | {
30
+ readonly reason: 'merged';
31
+ readonly base: string;
32
+ readonly commit: string;
33
+ };
18
34
  export interface ArchiveRequest {
19
35
  /** `YYYY-MM-DD`. */
20
36
  readonly date: string;
21
37
  readonly summary?: string | undefined;
22
38
  readonly pr?: PullRequest | undefined;
23
39
  readonly commit?: CommitInfo | undefined;
24
- /** The files the round changed, when a commit is known. */
40
+ /** The files the round changed, when they were read. */
25
41
  readonly changes?: readonly FileChange[] | undefined;
42
+ /**
43
+ * Why `changes` is missing, when the caller knows. Without changes the
44
+ * scope checks have nothing to check, and a plan that said nothing would
45
+ * read as a round that stayed in scope.
46
+ */
47
+ readonly unmeasured?: Unmeasured | undefined;
26
48
  /** Uncommitted paths, when the working tree was read. */
27
49
  readonly dirty?: readonly string[] | undefined;
28
50
  readonly allowDirty?: boolean | undefined;
@@ -73,9 +95,37 @@ export declare function openTasks(brief: Brief, corpus: Corpus): Brief['tasks'];
73
95
  */
74
96
  export declare function renderBanner(template: readonly string[], values: Readonly<Record<string, string>>): string[];
75
97
  export declare function planArchive(corpus: Corpus, brief: Brief, request: ArchiveRequest): Plan;
98
+ /** A plugin's answer to a refusal it may lift: which rule, for which path, and why. */
99
+ export interface Waiver {
100
+ readonly rule: string;
101
+ readonly path: string;
102
+ readonly reason: string;
103
+ }
104
+ /**
105
+ * The refusals a plugin may lift. A tool that verifies a ruling can say a
106
+ * protected file was allowed to change, or that a file outside the scope was;
107
+ * whether a round is done is spec-brief's to say.
108
+ */
109
+ export declare const WAIVABLE: readonly string[];
110
+ /**
111
+ * The plan with the refusals plugins waived turned into notes. A waiver
112
+ * matches a blocking finding by rule and path: a protected-file finding is
113
+ * replaced by the note, and an out-of-scope finding loses the path and goes
114
+ * when none is left. A waiver that matches nothing changes nothing, and one
115
+ * for any other rule is ignored with a warning, which refuses under --strict
116
+ * as any warning does.
117
+ */
118
+ export declare function applyWaivers(plan: Plan, waivers: readonly {
119
+ readonly plugin: string;
120
+ readonly waiver: Waiver;
121
+ }[], strict?: boolean): Plan;
122
+ export interface UnarchiveRequest {
123
+ /** Warnings refuse the reopening, as they fail lint. */
124
+ readonly strict?: boolean | undefined;
125
+ }
76
126
  /**
77
127
  * Reopens an archived brief: the banner and the freeze come off, the status
78
128
  * goes back to the live word, and the links are rewritten for the directory
79
129
  * it returns to. What the round wrote stays in history, where it belongs.
80
130
  */
81
- export declare function planUnarchive(corpus: Corpus, brief: Brief): Plan;
131
+ export declare function planUnarchive(corpus: Corpus, brief: Brief, request?: UnarchiveRequest): Plan;