@christang/keel 5.72.0 → 5.73.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
@@ -287,8 +287,8 @@ because the only thing the protocol says about merging is that the agent may not
287
287
 
288
288
  `merge: human` says the opposite. A bare `repository` is refused — "nobody reviews this" is only honest
289
289
  beside what replaced the reviewer. It is not a permission: `authorize:` has no `merge` entry and should
290
- not gain one. Keel reads the declaration and never GitHub, so it cannot check that auto-merge is really
291
- on; it reports what you declared.
290
+ not gain one. Keel reads the declaration and never GitHub, so it cannot check that your repository really
291
+ merges that way; it reports what you declared.
292
292
 
293
293
  ### Full vs Lite
294
294
 
@@ -1,4 +1,4 @@
1
- <!-- keel:start version=5.72.0 -->
1
+ <!-- keel:start version=5.73.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.
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.72.0",
5
+ "version": "5.73.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.72.0",
3
+ "version": "5.73.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.72.0",
3
+ "version": "5.73.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",
@@ -38,8 +38,8 @@ REQUIRED_SCRIPTS = [
38
38
  "scripts/validate_plugin.py",
39
39
  ]
40
40
 
41
- PACKAGE_VERSION = "5.72.0"
42
- PROTOCOL_VERSION = "5.72.0"
41
+ PACKAGE_VERSION = "5.73.0"
42
+ PROTOCOL_VERSION = "5.73.0"
43
43
  LEGACY_MANAGED_START = "<!-- keel:start version=2.1 -->"
44
44
  OPENSPEC_SCHEMA_NAME = "keel-spec-driven"
45
45
  # Mirrors KEEL_PACKAGE_NAME in scripts/install_to_repo.py, one of the two
@@ -5031,6 +5031,71 @@ def validate_section_boundary_scenario() -> int:
5031
5031
  report(repr(problems_of(unstarted["tail"])))
5032
5032
  return 1
5033
5033
 
5034
+ # Cells 5 and 6: the heading half of the boundary, in the tail, where
5035
+ # it is the only half that applies. An indented `##` line inside the
5036
+ # section's own body is not a heading, and the entry after it must still
5037
+ # be judged. The tolerant spelling `parseTasks()` uses for task bodies
5038
+ # ends the section there and drops that entry without a word — issue
5039
+ # #160, measured on the abandoned first attempt at #71 as `7bd6448`.
5040
+ # Both readers are covered because each slices its own section.
5041
+ indented = " ## an indented line is not a heading\n"
5042
+ indented_invalidates = (
5043
+ "## Invalidates\n\n"
5044
+ '- I1: "the wording that is now wrong" — README.md. Updated by: 1.1\n'
5045
+ + indented
5046
+ + '- I2: "wording nothing updates" — README.md.\n'
5047
+ )
5048
+ payload = start(
5049
+ "invalidates-indented-tail",
5050
+ section_boundary_tasks_md(
5051
+ section=indented_invalidates, task=start_task, position="tail"
5052
+ ),
5053
+ )
5054
+ if not readable(payload, "invalidates-indented-tail", "task-start"):
5055
+ return 1
5056
+ if not [
5057
+ message
5058
+ for message in closure_problems(payload, "invalidation-closure")
5059
+ if "I2" in message
5060
+ ]:
5061
+ report(
5062
+ f"{label} accepted an invalidation that closes nothing because "
5063
+ "it follows an indented `##` line; the section ended at a line "
5064
+ "that is not a heading."
5065
+ )
5066
+ report(repr(payload.get("problems")))
5067
+ return 1
5068
+
5069
+ indented_coverage = (
5070
+ "## Invalidates\n\n- None.\n\n"
5071
+ "## Expectation Coverage\n\n"
5072
+ "- E1: The task owns its file. Covered by: 1.1\n"
5073
+ + indented
5074
+ + "- E3: Nothing closes this one.\n"
5075
+ )
5076
+ payload = close(
5077
+ "coverage-indented-tail",
5078
+ section_boundary_tasks_md(
5079
+ section=indented_coverage,
5080
+ task=section_boundary_task("1.1", checked=True),
5081
+ position="tail",
5082
+ ),
5083
+ )
5084
+ if not readable(payload, "coverage-indented-tail", "change-close"):
5085
+ return 1
5086
+ if not [
5087
+ message
5088
+ for message in closure_problems(payload, "expectation-closure")
5089
+ if "E3" in message
5090
+ ]:
5091
+ report(
5092
+ f"{label} accepted an expectation that closes nothing because "
5093
+ "it follows an indented `##` line; the section ended at a line "
5094
+ "that is not a heading."
5095
+ )
5096
+ report(repr(payload.get("problems")))
5097
+ return 1
5098
+
5034
5099
  report(f"{label} scenario passed.")
5035
5100
  return 0
5036
5101
 
@@ -10016,6 +10081,48 @@ def validate_task_body_ends_at_heading_scenario() -> int:
10016
10081
  "preceding task's fields."
10017
10082
  )
10018
10083
  return 1
10084
+ # An indented `##` line is not a heading — issue #160. The tolerant
10085
+ # test ended the task there, and a field declared after it with a
10086
+ # documented default was replaced by that default without a word: the
10087
+ # stop rule below vanished and task-start still passed. The fields are
10088
+ # ordered so the dropped one is exactly such a field.
10089
+ write_text(
10090
+ repo / "openspec/changes/indented/tasks.md",
10091
+ header
10092
+ + "- [ ] 1.1 Exercise task contract\n"
10093
+ " - Covers:\n"
10094
+ " - E1: Public behavior passes.\n"
10095
+ " - Touch:\n"
10096
+ " - src/feature.js\n"
10097
+ " - Verify:\n"
10098
+ " - Strategy: evidence-first\n"
10099
+ " - Reason: this is a gate fixture; it exercises contract structure and has no executable behavior that can fail first\n"
10100
+ " - M1: node test.js asserts the recorded feed status\n"
10101
+ " - Evidence:\n"
10102
+ " - Contract: pending\n"
10103
+ " - M1: pending\n"
10104
+ " - Acceptance:\n"
10105
+ " - Public behavior passes.\n"
10106
+ " ## an indented line is not a heading\n"
10107
+ " - Stop Rules:\n"
10108
+ " - Stop on the rule declared after the indented line.\n"
10109
+ "\n## Invalidates\n\n- None.\n",
10110
+ )
10111
+ indented = json.loads(
10112
+ run_keel(
10113
+ repo, "gate", "task-start", "--change", "indented", "--task",
10114
+ "1.1", "--no-guard", "--json",
10115
+ ).stdout
10116
+ )
10117
+ if "Stop on the rule declared after the indented line." not in json.dumps(
10118
+ indented
10119
+ ):
10120
+ report(
10121
+ "task-body-ends-at-heading: task-start dropped the fields "
10122
+ "declared after an indented `##` line; the task ended at a line "
10123
+ f"that is not a heading (status {indented.get('status')!r})."
10124
+ )
10125
+ return 1
10019
10126
  # --record must anchor the last task's own Contract line, not the stray
10020
10127
  # one planted in the trailing section.
10021
10128
  recorded = run_keel(
package/src/core/gates.js CHANGED
@@ -13,6 +13,7 @@ const {
13
13
  declaredCommandLabels,
14
14
  field,
15
15
  isConcrete,
16
+ isHeadingLine,
16
17
  isPassingReviewStatus,
17
18
  parseTasks,
18
19
  unfilledToken,
@@ -1597,10 +1598,11 @@ function taskComplete(repo, options) {
1597
1598
  //
1598
1599
  // The task half is the task list already parsed for this file rather than a
1599
1600
  // second checkbox pattern, so it cannot drift from the boundary `parseTasks()`
1600
- // applies to a task's own body. The heading half stays as it was: the two
1601
- // spellings are not interchangeable, and unifying them truncates a tail-position
1602
- // section at an indented `##` line inside its own body, which is this same
1603
- // defect pointed the other way.
1601
+ // applies to a task's own body. The heading half is `isHeadingLine()`, the same
1602
+ // test `parseTasks()` uses. It must stay the column-zero one: the tolerant
1603
+ // spelling truncates a tail-position section at an indented `##` line inside
1604
+ // its own body and drops every entry after it, which is this same defect
1605
+ // pointed the other way (#160).
1604
1606
  function sectionBody(content, headingOffset, tasks) {
1605
1607
  const lines = content.split(/\r?\n/);
1606
1608
  const headingLine = content.slice(0, headingOffset).split(/\r?\n/).length - 1;
@@ -1609,7 +1611,7 @@ function sectionBody(content, headingOffset, tasks) {
1609
1611
  if (task.line > headingLine && task.line < end) end = task.line;
1610
1612
  }
1611
1613
  for (let cursor = headingLine + 1; cursor < end; cursor += 1) {
1612
- if (/^##\s+/.test(lines[cursor])) {
1614
+ if (isHeadingLine(lines[cursor])) {
1613
1615
  end = cursor;
1614
1616
  break;
1615
1617
  }
@@ -59,6 +59,17 @@ function unfilledToken(value) {
59
59
  return match ? match[0] : null;
60
60
  }
61
61
 
62
+ // A heading is a `##` line at column zero. An indented one is text: inside a
63
+ // task it belongs to the field that is open, inside a change-level section it
64
+ // is not an entry. Both readers decide through this one test — issue #160.
65
+ // The tolerant spelling `/^\s*##\s/` ended a task at an indented line and
66
+ // dropped every field after it, and a field with a documented default was
67
+ // replaced by that default without a word; in a section it dropped the entries
68
+ // after the line with no refusal at all.
69
+ function isHeadingLine(line) {
70
+ return /^##\s/.test(line);
71
+ }
72
+
62
73
  function parseTasks(content) {
63
74
  const lines = content.split(/\r?\n/);
64
75
  const tasks = [];
@@ -75,16 +86,17 @@ function parseTasks(content) {
75
86
  });
76
87
  }
77
88
  for (let index = 0; index < tasks.length; index += 1) {
78
- // A task body ends at the next task or the next `##` heading, whichever
79
- // comes first. Without the heading bound a change-level section such as
80
- // `## Invalidates` was appended to whichever field was open last — the
81
- // Evidence, in every shipped template — so a token quoted there made the
82
- // Evidence non-concrete and the gate blamed a task that was fine.
89
+ // A task body ends at the next task or the next `##` heading (see
90
+ // `isHeadingLine`), whichever comes first. Without the heading bound a
91
+ // change-level section such as `## Invalidates` was appended to whichever
92
+ // field was open last — the Evidence, in every shipped template — so a
93
+ // token quoted there made the Evidence non-concrete and the gate blamed a
94
+ // task that was fine.
83
95
  const nextTask =
84
96
  index + 1 < tasks.length ? tasks[index + 1].line : lines.length;
85
97
  let end = nextTask;
86
98
  for (let cursor = tasks[index].line + 1; cursor < nextTask; cursor += 1) {
87
- if (/^\s*##\s/.test(lines[cursor])) {
99
+ if (isHeadingLine(lines[cursor])) {
88
100
  end = cursor;
89
101
  break;
90
102
  }
@@ -1538,6 +1550,7 @@ module.exports = {
1538
1550
  isConcrete,
1539
1551
  isPassingReviewStatus,
1540
1552
  loadTaskContract,
1553
+ isHeadingLine,
1541
1554
  parseTasks,
1542
1555
  taskStartContractProblems,
1543
1556
  unfilledToken,