@christang/keel 5.20.0 → 5.44.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
@@ -132,7 +132,7 @@ because a permission granted in conversation does not survive a context reset. D
132
132
  `keel/config.yaml` instead:
133
133
 
134
134
  ```yaml
135
- authorize: # accepted names: commit, push, release, archive
135
+ authorize: # accepted names: commit, push, release, archive, continuation
136
136
  - commit
137
137
  - push
138
138
  ```
@@ -141,6 +141,16 @@ A task that authors no `Autonomy boundary:` inherits the declaration, and the co
141
141
  names `keel/config.yaml` as that entry's source so an inherited authorization is never mistaken
142
142
  for one the task decided. A task that authors its own boundary keeps it.
143
143
 
144
+ `continuation`, the fifth name, is the between-task one: it covers exactly the boundary between a
145
+ durably complete task and the next unchecked task of the same change, inside a change whose
146
+ `tasks.md` you approved — the stop that re-asks for an approval already given. It removes only that
147
+ confirmation: the next task still starts through `keel gate task-start` with its own recorded
148
+ fingerprint, every gate, evidence requirement, semantic Review, and the write guard run unchanged,
149
+ and a stop with its own trigger — a blocker, fingerprint drift, an out-of-scope need, an unresolved
150
+ question — still stops. It authorizes no repository action and schedules nothing. On an older Keel
151
+ whose vocabulary predates the word, the entry is unrecognized and the whole declaration authorizes
152
+ nothing until corrected — fail-closed, never a silent grant.
153
+
144
154
  Three things the declaration is not:
145
155
 
146
156
  - **Not a way past a gate.** It authorizes the action, never the proof. `keel gate task-complete`
@@ -148,7 +158,7 @@ Three things the declaration is not:
148
158
  anything.
149
159
  - **Not a trigger.** It removes a confirmation, not the step that reaches the action. Nothing
150
160
  schedules itself, and no next task is selected for you.
151
- - **Not open-ended.** The four names above are the whole vocabulary. An unrecognized entry is
161
+ - **Not open-ended.** The five names above are the whole vocabulary. An unrecognized entry is
152
162
  reported with the accepted names and the declaration authorizes nothing until you fix it — a
153
163
  typo never becomes a silent grant.
154
164
 
@@ -198,21 +208,40 @@ The last thing a loop needs is permission to *start*. Declare which issues may b
198
208
  being asked about:
199
209
 
200
210
  ```yaml
201
- triage: # issue labels that admit work; absent means nothing does
202
- - auto
211
+ triage: # either source admits; absent means nothing does
212
+ labels: # labels the issue carries
213
+ - auto
214
+ issues: # issue numbers, declared here rather than on the issue
215
+ - 62
203
216
  ```
204
217
 
218
+ A bare list directly under `triage:` still means labels, so a declaration written before the second
219
+ source existed keeps its exact meaning.
220
+
205
221
  ```bash
206
- gh issue view 42 --json labels --jq '[.labels[].name]|join(",")' | xargs keel triage --labels
222
+ gh issue view 42 --json labels,number \
223
+ --jq '"--labels \([.labels[].name]|join(",")) --issue \(.number)"' \
224
+ | xargs keel triage
207
225
  ```
208
226
 
209
227
  **Keel never fetches the issue.** You pass what `gh` returned, and the evaluation stays local,
210
228
  offline and deterministic — the same properties that make every other Keel answer worth trusting.
229
+ At least one of `--labels` / `--issue` is required; supplying neither is asking for a fetch. A
230
+ `triage:` block Keel cannot fully read admits nothing at all and names the entry that failed —
231
+ granting the readable half would grant the entries beside your typo.
232
+
233
+ **One issue is the unit, in both sources.** A person applies a label, or types a number, for one
234
+ specific issue, so the policy admits a class you curate one issue at a time — not a guess about
235
+ which issues look easy, which is exactly the judgement that should not be automated.
236
+
237
+ They differ in **where your decision is written down**, which is why both exist:
211
238
 
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.
239
+ - A **label** records it on the issue. In a repository whose issues come from other people, that
240
+ means an operations switch sits in the vocabulary you asked reporters to classify with, visible
241
+ and editable by whoever filed it. Keel also cannot check that a human applied it; if your
242
+ automation can label issues, that source is wider than it looks.
243
+ - An **issue number** records it in your own file. The reporter never sees it, only a committer can
244
+ change it, it shows up in a diff, and you can revoke one entry without touching the rest.
216
245
 
217
246
  **Admission answers "may this begin" and nothing after it.** Alignment still escalates every
218
247
  material choice, every gate still runs, and the write guard still binds. In particular:
@@ -324,8 +353,8 @@ keel lenses list
324
353
  keel lenses add <name> [--force]
325
354
 
326
355
  # 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]
356
+ # Keel never fetches the issue; pass what gh returned. At least one of the two.
357
+ keel triage [--labels <l1,l2>] [--issue <n>] [--json]
329
358
 
330
359
  # Install / maintenance
331
360
  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.44.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.44.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.44.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.44.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
@@ -50,7 +50,7 @@ If any of these hold, stop and use the normal manual Keel loop.
50
50
  4. Surface every command result and gate outcome in the transcript before any success claim.
51
51
  5. Pass the current agent's Review, then `keel gate task-complete`.
52
52
  6. The current agent durably checks the task checkbox.
53
- 7. Stop. Continuing to another task requires a new explicit user instruction and a new start fingerprint; there is no hidden scheduler or automatic next-task selection.
53
+ 7. Stop. Continuing to another task requires a new explicit user instruction and a new start fingerprint; a standing `continuation` authorization in `keel/config.yaml` is the durable form of that instruction, covering only the next unchecked task of the same change in its approved `tasks.md` order. Each next task still passes `keel gate task-start` with its own recorded fingerprint, and there is still no hidden scheduler or automatic next-task selection — the declaration removes the confirmation, never the selection, the gates, or the evidence.
54
54
 
55
55
  Resume reconstructs the goal from OpenSpec and Git only. Fingerprint drift, checkout divergence, a completed authorization, or missing authorization hard-stops before any product write; pass `--expected-fingerprint` and `--expected-owner` to assert the recorded authorization.
56
56
 
@@ -112,7 +112,7 @@ TASKS_COMMIT_STATUS_PATTERNS = (
112
112
  "remove dirty/uncommitted state from tasks.md; keep durable work state in OpenSpec and use HANDOFF only as an explicit pointer override",
113
113
  ),
114
114
  (
115
- re.compile(r"(?:未提交|待提交|已提交|尚未提交|未合入|待合入|已合入|合入\s*(?:master|main))"),
115
+ re.compile(r"(?:未合入|待合入|已合入|合入\s*(?:master|main))"),
116
116
  "remove commit or merge state from tasks.md; git log is the source of truth",
117
117
  ),
118
118
  (
@@ -120,6 +120,28 @@ TASKS_COMMIT_STATUS_PATTERNS = (
120
120
  "remove branch merge state from tasks.md; git log is the source of truth",
121
121
  ),
122
122
  )
123
+ # 合入 names the git act and has no other reading, so it is refused on its own
124
+ # above. 提交 is an ordinary transitive verb — 提交资料, 提交审核, 提交申请 — and
125
+ # matching it bare refused an Evidence line about a third-party review queue
126
+ # while accepting the same sentence in English (#103). A check whose verdict
127
+ # depends on which language the author wrote in is not checking what it claims
128
+ # to, so this family is refused only where the line says the subject is git.
129
+ TASKS_SUBMISSION_STATE_RE = re.compile(r"(?:未提交|待提交|已提交|尚未提交)")
130
+ # The words that name git or a git object. Deliberately not `_HASH_CONTEXT_WORD`:
131
+ # that list holds 提交 and 合入 themselves, because its job is to let a state word
132
+ # supply context to a hash-shaped token, and reusing it here would let 已提交
133
+ # supply its own context and change nothing. The ASCII words carry boundaries for
134
+ # the reason #65 established; the Chinese ones cannot and do not.
135
+ TASKS_GIT_CONTEXT_RE = re.compile(
136
+ r"(?i)\b(?:git|commits?|committed|committing|master|main|HEAD|branch(?:es)?"
137
+ r"|PR|merges?|merged)\b"
138
+ r"|分支|仓库|代码库|代码|合入|工作区|暂存"
139
+ )
140
+ TASKS_SUBMISSION_MESSAGE = (
141
+ "remove commit or merge state from tasks.md; git log is the source of truth"
142
+ )
143
+
144
+
123
145
  # A commit identifier is hexadecimal, and `a`-`f` are the only reason those
124
146
  # letters appear in one. A run of decimal digits alone is a phone number, a
125
147
  # timestamp, an order number, or a port — evidence prose, not state git owns.
@@ -129,7 +151,22 @@ TASKS_COMMIT_STATUS_PATTERNS = (
129
151
  # verbatim inside the lookahead, so the token that matches is the token that
130
152
  # always matched, minus the all-decimal ones.
131
153
  _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|哈希)"
154
+ # A context word has to be a word. Matched anywhere on the line, `main` was
155
+ # supplied by `remaining`, `domain`, and `maintains`, and `head` by `heading`
156
+ # — and the tokens sitting beside those words in this repository's own
157
+ # history are `sha256:` contract anchors, not commit identifiers (#65).
158
+ # The inflections are spelled out rather than left to a prefix match: they are
159
+ # the words an author writes about the act this rule exists to catch, and the
160
+ # stricter list that drops `committed` and `hashes` would cost real refusals.
161
+ # The Chinese words carry no boundary because none is definable between two
162
+ # word characters — `\b提交\b` matches neither `已提交` nor `未提交`. What that
163
+ # buys is this rule: a state word beside a hash-shaped token still supplies the
164
+ # context that makes the token an identifier. The wording rule above no longer
165
+ # rests on it, because 提交 needs a git word of its own (#103).
166
+ _HASH_CONTEXT_WORD = (
167
+ r"(?:\b(?:commits?|committed|committing|master|main|HEAD|hash(?:es)?)\b"
168
+ r"|提交|合入|哈希)"
169
+ )
133
170
  TASKS_CONTEXTUAL_HASH_RE = re.compile(
134
171
  rf"(?i){_HASH_CONTEXT_WORD}.*{_HASH_SHAPED_TOKEN}|"
135
172
  rf"{_HASH_SHAPED_TOKEN}.*{_HASH_CONTEXT_WORD}"
@@ -662,6 +699,66 @@ def is_tasks_rule_line(line: str) -> bool:
662
699
  )
663
700
 
664
701
 
702
+ # The task contract compiler's own field bound, from `parseTasks()` in
703
+ # `src/core/task-contract.js`: a field starts at a two-space `- Name:` line and
704
+ # holds every line until the next one. Keeping the two readers on one rule is
705
+ # what stops a line being a Covers entry for the compiler and prose for this
706
+ # check.
707
+ TASKS_FIELD_LABEL_RE = re.compile(r"^ {2}- ([A-Za-z][A-Za-z /-]+):")
708
+
709
+
710
+ def covers_field_lines(content: str) -> set[int]:
711
+ """1-based line numbers inside a `Covers` field.
712
+
713
+ A Covers entry is a citation — its segments must resolve to a requirement
714
+ or scenario that exists in a spec — so naming a requirement about dirty
715
+ worktrees or commit identifiers is not recording one. Read as prose, those
716
+ citations left an author no repair but to rename the requirement, which is
717
+ what 5.19.0 did and what #65 reports. Every citation is written on a line
718
+ below the label, so the exempt region is the field and not the label line.
719
+ """
720
+ inside = False
721
+ lines: set[int] = set()
722
+ for number, line in enumerate(content.splitlines(), start=1):
723
+ label = TASKS_FIELD_LABEL_RE.match(line)
724
+ if label is not None:
725
+ inside = label.group(1) == "Covers"
726
+ if inside:
727
+ lines.add(number)
728
+ return lines
729
+
730
+
731
+ # What an author quotes is content they cite, not a claim they make. Evidence
732
+ # prose quotes the command that ran, the output it printed, the branch base it
733
+ # ran against, and the name of the requirement under change — and every one of
734
+ # those carries exactly the words these rules refuse. `withoutInlineCode()` in
735
+ # `src/core/task-contract.js` settled this shape for the field reader; the two
736
+ # reports that reached this reader (#65 items 2 and 4) are the same shape
737
+ # arriving late.
738
+ #
739
+ # The ASCII single quote is deliberately absent. It is an apostrophe far more
740
+ # often than a quotation mark, and a span opened by `doesn't` would silence the
741
+ # remainder of the line — a rule that stops running without saying so, which is
742
+ # the failure no fixture catches by accident.
743
+ QUOTED_SPAN_RE = re.compile(
744
+ r"`[^`\n]*`"
745
+ r"|\"[^\"\n]*\""
746
+ r"|\u201c[^\u201d\n]*\u201d"
747
+ r"|\u300c[^\u300d\n]*\u300d"
748
+ r"|\u300a[^\u300b\n]*\u300b"
749
+ )
750
+ FENCE_RE = re.compile(r"^\s*(?:```|~~~)")
751
+
752
+
753
+ def without_quoted_spans(line: str) -> str:
754
+ """Blank out the quoted spans of a line, leaving its length reading intact.
755
+
756
+ The replacement is a space rather than nothing so two tokens either side of
757
+ a span cannot be joined into a third word that neither of them was.
758
+ """
759
+ return QUOTED_SPAN_RE.sub(" ", line)
760
+
761
+
665
762
  def check_tasks_semantics(repo: Path) -> list[str]:
666
763
  changes_root = repo / OPENSPEC_ROOT / "changes"
667
764
  if not changes_root.is_dir():
@@ -682,12 +779,28 @@ def check_tasks_semantics(repo: Path) -> list[str]:
682
779
  line = line_number_for_offset(content, match.start())
683
780
  errors.append(f"{relative}:{line}: {message}")
684
781
 
782
+ cited = covers_field_lines(content)
783
+ fenced = False
685
784
  for line_number, line in enumerate(content.splitlines(), start=1):
686
- if is_tasks_rule_line(line):
785
+ # A fenced block is one long quoted span, and its own delimiter
786
+ # lines carry no prose. Tracking the state here rather than
787
+ # pre-stripping the file keeps every reported line number the
788
+ # line number the author sees.
789
+ if FENCE_RE.match(line):
790
+ fenced = not fenced
791
+ continue
792
+ if fenced or is_tasks_rule_line(line) or line_number in cited:
687
793
  continue
794
+ line = without_quoted_spans(line)
688
795
  for pattern, message in TASKS_COMMIT_STATUS_PATTERNS:
689
796
  if pattern.search(line):
690
797
  errors.append(f"{relative}:{line_number}: {message}")
798
+ if TASKS_SUBMISSION_STATE_RE.search(line) and TASKS_GIT_CONTEXT_RE.search(
799
+ line
800
+ ):
801
+ errors.append(
802
+ f"{relative}:{line_number}: {TASKS_SUBMISSION_MESSAGE}"
803
+ )
691
804
  if TASKS_CONTEXTUAL_HASH_RE.search(line):
692
805
  errors.append(
693
806
  f"{relative}:{line_number}: remove contextual commit hash from tasks.md; git log is the source of truth"