@christang/keel 5.20.0 → 5.39.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.
package/README.md CHANGED
@@ -198,21 +198,40 @@ The last thing a loop needs is permission to *start*. Declare which issues may b
198
198
  being asked about:
199
199
 
200
200
  ```yaml
201
- triage: # issue labels that admit work; absent means nothing does
202
- - auto
201
+ triage: # either source admits; absent means nothing does
202
+ labels: # labels the issue carries
203
+ - auto
204
+ issues: # issue numbers, declared here rather than on the issue
205
+ - 62
203
206
  ```
204
207
 
208
+ A bare list directly under `triage:` still means labels, so a declaration written before the second
209
+ source existed keeps its exact meaning.
210
+
205
211
  ```bash
206
- gh issue view 42 --json labels --jq '[.labels[].name]|join(",")' | xargs keel triage --labels
212
+ gh issue view 42 --json labels,number \
213
+ --jq '"--labels \([.labels[].name]|join(",")) --issue \(.number)"' \
214
+ | xargs keel triage
207
215
  ```
208
216
 
209
217
  **Keel never fetches the issue.** You pass what `gh` returned, and the evaluation stays local,
210
218
  offline and deterministic — the same properties that make every other Keel answer worth trusting.
219
+ At least one of `--labels` / `--issue` is required; supplying neither is asking for a fetch. A
220
+ `triage:` block Keel cannot fully read admits nothing at all and names the entry that failed —
221
+ granting the readable half would grant the entries beside your typo.
222
+
223
+ **One issue is the unit, in both sources.** A person applies a label, or types a number, for one
224
+ specific issue, so the policy admits a class you curate one issue at a time — not a guess about
225
+ which issues look easy, which is exactly the judgement that should not be automated.
226
+
227
+ They differ in **where your decision is written down**, which is why both exist:
211
228
 
212
- A **label** is the unit on purpose. A person applies one to one issue, so the policy admits a class
213
- you curate one issue at a time not a guess about which issues look easy, which is exactly the
214
- judgement that should not be automated. Keel cannot check that a human applied the label; if your
215
- automation can label issues, this declaration is wider than it looks.
229
+ - A **label** records it on the issue. In a repository whose issues come from other people, that
230
+ means an operations switch sits in the vocabulary you asked reporters to classify with, visible
231
+ and editable by whoever filed it. Keel also cannot check that a human applied it; if your
232
+ automation can label issues, that source is wider than it looks.
233
+ - An **issue number** records it in your own file. The reporter never sees it, only a committer can
234
+ change it, it shows up in a diff, and you can revoke one entry without touching the rest.
216
235
 
217
236
  **Admission answers "may this begin" and nothing after it.** Alignment still escalates every
218
237
  material choice, every gate still runs, and the write guard still binds. In particular:
@@ -324,8 +343,8 @@ keel lenses list
324
343
  keel lenses add <name> [--force]
325
344
 
326
345
  # Unattended triage — may this issue start work without asking?
327
- # Keel never fetches the issue; pass what gh returned.
328
- keel triage --labels <l1,l2> [--json]
346
+ # Keel never fetches the issue; pass what gh returned. At least one of the two.
347
+ keel triage [--labels <l1,l2>] [--issue <n>] [--json]
329
348
 
330
349
  # Install / maintenance
331
350
  keel --init | --install | --check | --doctor | --uninstall [--target <t>] [--dry-run]
@@ -1,4 +1,4 @@
1
- <!-- keel:start version=5.20.0 -->
1
+ <!-- keel:start version=5.39.0 -->
2
2
  ## Keel Bootstrap
3
3
 
4
4
  - Start every session with `keel context`; OpenSpec artifacts and Git are the only durable authority — never native memory, goals, or transcripts.
@@ -17,7 +17,7 @@
17
17
 
18
18
  - [ ] 1.1 <!-- Task description -->
19
19
  - Covers:
20
- - <source expectation: spec scenario as `capability / requirement or scenario heading`, hidden-knowledge assumption, domain lens requirement, or critical D<n>/F<n>/A<n>/Q<n>; a Q<n> that OPENS an entry is an open question and requires an authorized fallback, while one named inside an entry about a fact is a citation and does not>
20
+ - <source expectation: spec authority as `capability / requirement` or `capability / requirement / scenario` a scenario is named in the THIRD segment, never the second, hidden-knowledge assumption, domain lens requirement, or critical D<n>/F<n>/A<n>/Q<n>; a Q<n> that OPENS an entry is an open question and requires an authorized fallback, while one named inside an entry about a fact is a citation and does not>
21
21
  - Touch:
22
22
  - <path>
23
23
  - Verify:
@@ -50,6 +50,7 @@
50
50
  - Scope check: pending
51
51
  - Findings: pending
52
52
  - Blocker: none
53
+ - Reauthorizations: none
53
54
 
54
55
  <!-- Exceptional boundaries are declared only when they differ from defaults:
55
56
  - Mode: diagnose-only or repo-action (both with `Touch: none`), or plan-first.
@@ -87,6 +88,7 @@
87
88
  - Scope check: pending
88
89
  - Findings: pending
89
90
  - Blocker: none
91
+ - Reauthorizations: none
90
92
 
91
93
  ## 3. <!-- Task Group Name -->
92
94
 
@@ -117,6 +119,7 @@
117
119
  - Scope check: pending
118
120
  - Findings: pending
119
121
  - Blocker: none
122
+ - Reauthorizations: none
120
123
 
121
124
  ## Invalidates
122
125
 
package/bin/keel.js CHANGED
@@ -97,6 +97,7 @@ Usage:
97
97
  keel gate task-start|task-complete|change-close [repo] [--change name] [--task id] [--action sync|archive] [--base git-ref] [--no-guard] [--record] [--json]
98
98
  keel guard start|status|clear [repo] [--change name] [--task id] [--force] [--json]
99
99
  keel lenses list|add [name] [repo] [--force]
100
+ keel triage [repo] [--labels <l1,l2>] [--issue <n>] [--json]
100
101
  keel openspec [args...]
101
102
  keel --init [repo] [--target claude|codex|opencode] [--dry-run] [--force-template-update]
102
103
  keel --install [repo] [--target claude|codex|opencode] [--dry-run] [--force-template-update] [--with-git-hooks]
@@ -134,6 +135,8 @@ Examples:
134
135
  keel lenses list
135
136
  keel lenses add web
136
137
  keel lenses add web --force
138
+ keel triage --labels auto
139
+ keel triage --issue 42
137
140
  keel --init
138
141
  keel --install
139
142
  keel --install --target codex
@@ -187,6 +190,7 @@ function parseArgs(argv) {
187
190
  lensesSubcommand: null,
188
191
  lensName: null,
189
192
  labels: null,
193
+ issue: null,
190
194
  openspecArgs: [],
191
195
  force: false,
192
196
  projectionEvent: null,
@@ -238,6 +242,10 @@ function parseArgs(argv) {
238
242
  parsed.labels = argv[++index] || "";
239
243
  continue;
240
244
  }
245
+ if (arg === "--issue" && parsed.action === "triage") {
246
+ parsed.issue = argv[++index] || "";
247
+ continue;
248
+ }
241
249
  if (arg === "openspec" && parsed.action === null && parsed.repo === null) {
242
250
  parsed.action = "openspec";
243
251
  parsed.openspecArgs = argv.slice(index + 1);
@@ -559,14 +567,25 @@ function parseArgs(argv) {
559
567
  fail("lens subcommands apply only to keel lenses");
560
568
  }
561
569
  if (parsed.action === "triage") {
562
- if (parsed.labels === null) {
570
+ // At least one attribute, rather than labels specifically: a repository
571
+ // that admits by issue number alone should not have to pass an empty label
572
+ // list to be answered. The requirement stays because Keel never fetches the
573
+ // issue, so a caller supplying nothing is expecting a fetch.
574
+ if (parsed.labels === null && parsed.issue === null) {
563
575
  fail(
564
- "keel triage requires --labels; Keel never fetches the issue, so pass "
565
- + "what `gh issue view --json labels` returned"
576
+ "keel triage requires --labels or --issue; Keel never fetches the "
577
+ + "issue, so pass what `gh issue view --json labels,number` returned"
566
578
  );
567
579
  }
568
- } else if (parsed.labels !== null) {
569
- fail("--labels applies only to keel triage");
580
+ if (parsed.issue !== null && !/^[1-9]\d*$/.test(parsed.issue)) {
581
+ fail(
582
+ `--issue takes an issue number as a bare number, such as --issue 62; `
583
+ + `got ${parsed.issue || "nothing"}`
584
+ );
585
+ }
586
+ } else {
587
+ if (parsed.labels !== null) fail("--labels applies only to keel triage");
588
+ if (parsed.issue !== null) fail("--issue applies only to keel triage");
570
589
  }
571
590
  if (parsed.noGuard && parsed.action !== "gate") {
572
591
  fail("--no-guard applies only to keel gate task-start");
@@ -1188,6 +1207,70 @@ function mergeOpenSpecSurfaceOverlay(content, action) {
1188
1207
  return `${content}${separator}${overlay}`;
1189
1208
  }
1190
1209
 
1210
+ // The block plus the whitespace the merge above inserted in front of it, and
1211
+ // nothing else. Removing only the marked span leaves the separator behind, and
1212
+ // the file it is left in belongs to OpenSpec — so the match takes the newlines
1213
+ // immediately before the block and the one that closes it, and the replacement
1214
+ // puts a single newline back. A blank line anywhere else in the file is not
1215
+ // this code's business.
1216
+ const OPENSPEC_SURFACE_OVERLAY_REMOVE_RE =
1217
+ /\n*<!--\s*keel:openspec-surface-overlay(?:\s+[^>]*)?\s*-->[\s\S]*?<!--\s*keel:openspec-surface-overlay:end\s*-->[ \t]*\n?/;
1218
+
1219
+ function stripOpenSpecSurfaceOverlay(content) {
1220
+ let next = content;
1221
+ // A loop rather than one replace: the merge only ever writes one block, so a
1222
+ // file carrying two is already unexpected, and leaving one of them behind is
1223
+ // the outcome this is cheapest to rule out.
1224
+ while (OPENSPEC_SURFACE_OVERLAY_RE.test(next)) {
1225
+ next = next.replace(OPENSPEC_SURFACE_OVERLAY_REMOVE_RE, (match, offset) =>
1226
+ offset === 0 ? "" : "\n"
1227
+ );
1228
+ }
1229
+ return next;
1230
+ }
1231
+
1232
+ function removeOpenSpecSurfaceOverlay(repo, target, options = {}) {
1233
+ const counts = {
1234
+ removed: 0,
1235
+ absent: 0,
1236
+ missing: 0,
1237
+ };
1238
+
1239
+ for (const surface of openspecOverlaySurfacesForTarget(target, repo)) {
1240
+ // `keel --install` without `--init` never creates the OpenSpec surfaces,
1241
+ // so a repository can legitimately reach uninstall with none of them.
1242
+ if (!fs.existsSync(surface.path)) {
1243
+ counts.missing += 1;
1244
+ continue;
1245
+ }
1246
+ const content = fs.readFileSync(surface.path, "utf8");
1247
+ const next = stripOpenSpecSurfaceOverlay(content);
1248
+ if (next === content) {
1249
+ counts.absent += 1;
1250
+ continue;
1251
+ }
1252
+ counts.removed += 1;
1253
+ if (options.dryRun) {
1254
+ process.stdout.write(
1255
+ `keel: would remove OpenSpec ${surface.action} overlay from ${surface.path}\n`
1256
+ );
1257
+ continue;
1258
+ }
1259
+ fs.writeFileSync(surface.path, next, "utf8");
1260
+ }
1261
+
1262
+ // Always reported, including `removed=0`: an uninstall that found nothing to
1263
+ // clean and an uninstall that never looked leave the same tree behind, and
1264
+ // the second one is the defect this exists to close.
1265
+ process.stdout.write(
1266
+ `keel: ${options.dryRun ? "would remove " : ""}OpenSpec ${overlayActionLabel()} `
1267
+ + `overlay removed=${counts.removed} absent=${counts.absent} `
1268
+ + `missing=${counts.missing}\n`
1269
+ );
1270
+
1271
+ return { status: 0, ...counts };
1272
+ }
1273
+
1191
1274
  function refreshOpenSpecSurfaceOverlay(repo, target, options = {}) {
1192
1275
  const surfaces = openspecOverlaySurfacesForTarget(target, repo);
1193
1276
  const counts = {
@@ -1223,7 +1306,7 @@ function refreshOpenSpecSurfaceOverlay(repo, target, options = {}) {
1223
1306
 
1224
1307
  if (counts.refreshed > 0 || counts.current > 0) {
1225
1308
  process.stdout.write(
1226
- `keel: ${options.dryRun ? "would refresh " : ""}OpenSpec apply/archive `
1309
+ `keel: ${options.dryRun ? "would refresh " : ""}OpenSpec ${overlayActionLabel()} `
1227
1310
  + `overlay refreshed=${counts.refreshed} current=${counts.current} `
1228
1311
  + `missing=${counts.missing}\n`
1229
1312
  );
@@ -1547,17 +1630,9 @@ function gitConfigHooksPath(repo) {
1547
1630
 
1548
1631
  function printStandingAuthorizationSurface(repo) {
1549
1632
  process.stdout.write("\nStanding authorization:\n");
1550
- const { declared, unknown } = readStandingAuthorization(repo);
1633
+ const { declared, unknown, message } = readStandingAuthorization(repo);
1551
1634
  if (unknown.length > 0) {
1552
- printDoctorLine(
1553
- "authorize",
1554
- "failed",
1555
- `keel/config.yaml declares unrecognized ${
1556
- unknown.length === 1 ? "action" : "actions"
1557
- }: ${unknown.join(", ")}; accepted names are `
1558
- + `${STANDING_AUTHORIZATION_ACTIONS.join(", ")}. The whole declaration `
1559
- + "authorizes nothing until it is corrected"
1560
- );
1635
+ printDoctorLine("authorize", "failed", message);
1561
1636
  return false;
1562
1637
  }
1563
1638
  printDoctorLine(
@@ -1578,12 +1653,31 @@ function printStandingAuthorizationSurface(repo) {
1578
1653
 
1579
1654
  function printTriageSurface(repo) {
1580
1655
  process.stdout.write("\nUnattended triage:\n");
1581
- const { labels } = readTriagePolicy(repo);
1656
+ const { labels, issues, unreadable } = readTriagePolicy(repo);
1657
+ // An unreadable declaration is reported as broken rather than as absent. The
1658
+ // owner who typed it needs to know their file is the reason nothing runs,
1659
+ // which "undeclared" would tell them is not the case.
1660
+ if (unreadable.length > 0) {
1661
+ printDoctorLine(
1662
+ "triage",
1663
+ "unreadable",
1664
+ `keel/config.yaml declares triage entries Keel could not read — `
1665
+ + `${unreadable.join(", ")}; no issue starts work unattended until the `
1666
+ + "declaration is corrected"
1667
+ );
1668
+ return;
1669
+ }
1670
+ // Only the declared sources are named. Naming a source with nothing under it
1671
+ // would read as a policy that exists, on the one surface asked to answer
1672
+ // "what may start work here" in a single line.
1673
+ const declared = [];
1674
+ if (labels.length > 0) declared.push(`issues labelled ${labels.join(", ")}`);
1675
+ if (issues.length > 0) declared.push(`issues numbered ${issues.join(", ")}`);
1582
1676
  printDoctorLine(
1583
1677
  "triage",
1584
- labels.length > 0 ? "ok" : "none",
1585
- labels.length > 0
1586
- ? `issues labelled ${labels.join(", ")} may start work unattended; `
1678
+ declared.length > 0 ? "ok" : "none",
1679
+ declared.length > 0
1680
+ ? `${declared.join(", and ")} may start work unattended; `
1587
1681
  + "admission decides nothing after it, and no declaration authorizes a merge"
1588
1682
  : "undeclared; no issue starts work unattended"
1589
1683
  );
@@ -1876,7 +1970,7 @@ function runAction(options) {
1876
1970
  .split(",")
1877
1971
  .map((label) => label.trim())
1878
1972
  .filter(Boolean);
1879
- const verdict = triageIssue(repo, labels);
1973
+ const verdict = triageIssue(repo, labels, options.issue);
1880
1974
  const payload = {
1881
1975
  schemaVersion: 1,
1882
1976
  command: "triage",
@@ -2004,7 +2098,18 @@ function runAction(options) {
2004
2098
  }
2005
2099
 
2006
2100
  if (options.action === "clear" || options.action === "uninstall") {
2007
- return runPython(INSTALL_SCRIPT, installerArgs(options, ["--uninstall"]));
2101
+ const uninstallStatus = runPython(
2102
+ INSTALL_SCRIPT,
2103
+ installerArgs(options, ["--uninstall"])
2104
+ );
2105
+ if (uninstallStatus !== 0) {
2106
+ return uninstallStatus;
2107
+ }
2108
+ return removeOpenSpecSurfaceOverlay(
2109
+ path.resolve(options.repo || process.cwd()),
2110
+ options.target,
2111
+ { dryRun: options.dryRun }
2112
+ ).status;
2008
2113
  }
2009
2114
 
2010
2115
  if (options.action === "check") {
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@christang/keel",
3
3
  "displayName": "Keel",
4
4
  "description": "Keel OpenSpec execution discipline CLI for Claude Code, Codex, and OpenCode.",
5
- "version": "5.20.0",
5
+ "version": "5.39.0",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keel",
3
- "version": "5.20.0",
3
+ "version": "5.39.0",
4
4
  "description": "Keel OpenSpec execution discipline: stateless continuity, task capsules, deterministic gates, and expectation alignment for Codex and Claude Code.",
5
5
  "author": {
6
6
  "name": "TanglmChris",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keel",
3
- "version": "5.20.0",
3
+ "version": "5.39.0",
4
4
  "description": "Keel OpenSpec execution discipline: stateless continuity, task capsules, deterministic gates, and expectation alignment for Codex and Claude Code.",
5
5
  "author": {
6
6
  "name": "TanglmChris",
@@ -73,24 +73,10 @@ write guard are untouched by anything in the store.
73
73
 
74
74
  ## Unattended runs
75
75
 
76
- Work enters an unattended run only by the repository's declared triage policy — an issue carrying a
77
- label listed under `triage:` in `keel/config.yaml`, evaluated with `keel triage --labels <labels>`.
78
- Pass what `gh` returned; Keel never fetches the issue. Admission comes from that declaration and
79
- never from a precedent, however much triage history the store accumulates: whether an issue becomes
80
- work is a materiality decision, and a precedent may not move one out of that list.
81
-
82
- Admission answers "may this begin" and decides nothing after it. Alignment still escalates material
83
- choices, the gates still run, and the write guard still binds.
84
-
85
- An unattended run may triage, author, implement, verify, push where `authorize:` permits, and
86
- **open a pull request**. It **may not merge** one — merging is where an unreviewed decision becomes
87
- the project's history, and no declaration in Keel authorizes it.
88
-
89
- Stopping at a decision the user must make is the **designed boundary rather than a failure**.
90
- Report where the run stopped and why. Do not widen the triage policy to stop it happening.
91
-
92
- **Keel schedules nothing.** `/loop`, cron, and CI triggers belong to the host runtime; Keel's part
93
- is making each step decidable with authority.
76
+ The admission rule, what admission does and does not authorize, the open-a-pull-request/no-merge
77
+ boundary, and that Keel schedules nothing are stated once, in this repository's `AGENTS.md`, under
78
+ its own `## Unattended runs` section. Read the boundary there this skill states no separate copy,
79
+ so the two surfaces cannot drift against each other.
94
80
 
95
81
  ## Domain lenses
96
82
 
@@ -23,7 +23,7 @@ Read the selected OpenSpec proposal, design, specs, tasks, diff, and command evi
23
23
  - For a red-green strategy (`vertical-tdd`, `regression-first`), confirm concrete per-label `.red` and `.green` Evidence exists for the same check; evidence-first tasks instead name their observable proof.
24
24
  - A failure message must **name the actual cause** of what it reports. Watch for one condition guarding **two distinct failures** — `if result is None or result["status"] != expected` reports the first failure's message when the second one happened, sending the reader to a place with no problem in it. Split the condition. No gate can judge this: deciding whether a sentence misleads needs a model, so it stays here.
25
25
  - When two tasks in the change declared the same Touch set under a red-green strategy — `keel gate task-start` warns about this — ask whether they turned out to be **one behavior** split in half. The tell is that the first task's minimal implementation was wrong in the field, or that the second had no honest red left because the first already made its checks pass. The gate can only see the shape; by completion you can see the outcome, which is the only point at which this is answerable.
26
- - Scope attribution is now the gate's job by default: `keel gate task-complete` compares the worktree against the dirty set `task-start` recorded and refuses a path outside Touch, so a `pass` is evidence about scope rather than silence about it. Two cases still need you. When the gate reports paths as *unattributed* — no recorded set, because the manifest predates the field or the guard was cleared — nothing was checked and the review is the only scope evidence. And a path already dirty when the task started is never attributed even if the task changed it again, so read the gate's silence about such a path as absence of a check rather than absence of a write.
26
+ - Scope attribution is now the gate's job by default: `keel gate task-complete` compares the worktree against the dirty set `task-start` recorded and refuses a path outside Touch, so a `pass` is evidence about scope rather than silence about it. Two cases still need you. When the gate reports paths as *unattributed* — no recorded set, because the manifest predates the field or the guard was cleared — nothing was checked and the review is the only scope evidence. And a path already dirty when the task started stays unattributed only while its content matches what was recorded then; if the task changed it again, the gate attributes it — so read the gate's silence about such a path as evidence its content was never touched again, not as an unconditional exemption.
27
27
  - For `Coupling: required`, confirm one complete candidate reached its completion gate and generated artifacts are aligned.
28
28
 
29
29
  ## Semantic Review
@@ -129,7 +129,20 @@ TASKS_COMMIT_STATUS_PATTERNS = (
129
129
  # verbatim inside the lookahead, so the token that matches is the token that
130
130
  # always matched, minus the all-decimal ones.
131
131
  _HASH_SHAPED_TOKEN = r"\b(?=[0-9a-f]{7,40}\b)[0-9a-f]*[a-f][0-9a-f]*\b"
132
- _HASH_CONTEXT_WORD = r"(?:commit|提交|合入|master|main|HEAD|hash|哈希)"
132
+ # A context word has to be a word. Matched anywhere on the line, `main` was
133
+ # supplied by `remaining`, `domain`, and `maintains`, and `head` by `heading`
134
+ # — and the tokens sitting beside those words in this repository's own
135
+ # history are `sha256:` contract anchors, not commit identifiers (#65).
136
+ # The inflections are spelled out rather than left to a prefix match: they are
137
+ # the words an author writes about the act this rule exists to catch, and the
138
+ # stricter list that drops `committed` and `hashes` would cost real refusals.
139
+ # The Chinese words carry no boundary because none is definable between two
140
+ # word characters — `\b提交\b` matches neither `已提交` nor `未提交`, which are
141
+ # exactly what the wording rule is for.
142
+ _HASH_CONTEXT_WORD = (
143
+ r"(?:\b(?:commits?|committed|committing|master|main|HEAD|hash(?:es)?)\b"
144
+ r"|提交|合入|哈希)"
145
+ )
133
146
  TASKS_CONTEXTUAL_HASH_RE = re.compile(
134
147
  rf"(?i){_HASH_CONTEXT_WORD}.*{_HASH_SHAPED_TOKEN}|"
135
148
  rf"{_HASH_SHAPED_TOKEN}.*{_HASH_CONTEXT_WORD}"
@@ -662,6 +675,35 @@ def is_tasks_rule_line(line: str) -> bool:
662
675
  )
663
676
 
664
677
 
678
+ # The task contract compiler's own field bound, from `parseTasks()` in
679
+ # `src/core/task-contract.js`: a field starts at a two-space `- Name:` line and
680
+ # holds every line until the next one. Keeping the two readers on one rule is
681
+ # what stops a line being a Covers entry for the compiler and prose for this
682
+ # check.
683
+ TASKS_FIELD_LABEL_RE = re.compile(r"^ {2}- ([A-Za-z][A-Za-z /-]+):")
684
+
685
+
686
+ def covers_field_lines(content: str) -> set[int]:
687
+ """1-based line numbers inside a `Covers` field.
688
+
689
+ A Covers entry is a citation — its segments must resolve to a requirement
690
+ or scenario that exists in a spec — so naming a requirement about dirty
691
+ worktrees or commit identifiers is not recording one. Read as prose, those
692
+ citations left an author no repair but to rename the requirement, which is
693
+ what 5.19.0 did and what #65 reports. Every citation is written on a line
694
+ below the label, so the exempt region is the field and not the label line.
695
+ """
696
+ inside = False
697
+ lines: set[int] = set()
698
+ for number, line in enumerate(content.splitlines(), start=1):
699
+ label = TASKS_FIELD_LABEL_RE.match(line)
700
+ if label is not None:
701
+ inside = label.group(1) == "Covers"
702
+ if inside:
703
+ lines.add(number)
704
+ return lines
705
+
706
+
665
707
  def check_tasks_semantics(repo: Path) -> list[str]:
666
708
  changes_root = repo / OPENSPEC_ROOT / "changes"
667
709
  if not changes_root.is_dir():
@@ -682,8 +724,9 @@ def check_tasks_semantics(repo: Path) -> list[str]:
682
724
  line = line_number_for_offset(content, match.start())
683
725
  errors.append(f"{relative}:{line}: {message}")
684
726
 
727
+ cited = covers_field_lines(content)
685
728
  for line_number, line in enumerate(content.splitlines(), start=1):
686
- if is_tasks_rule_line(line):
729
+ if is_tasks_rule_line(line) or line_number in cited:
687
730
  continue
688
731
  for pattern, message in TASKS_COMMIT_STATUS_PATTERNS:
689
732
  if pattern.search(line):