@christang/keel 5.39.0 → 5.46.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.
@@ -37,8 +37,8 @@ REQUIRED_SCRIPTS = [
37
37
  "scripts/validate_plugin.py",
38
38
  ]
39
39
 
40
- PACKAGE_VERSION = "5.39.0"
41
- PROTOCOL_VERSION = "5.39.0"
40
+ PACKAGE_VERSION = "5.46.0"
41
+ PROTOCOL_VERSION = "5.46.0"
42
42
  LEGACY_MANAGED_START = "<!-- keel:start version=2.1 -->"
43
43
  OPENSPEC_SCHEMA_NAME = "keel-spec-driven"
44
44
  # Mirrors KEEL_PACKAGE_NAME in scripts/install_to_repo.py, one of the two
@@ -1656,10 +1656,12 @@ def validate_unparsed_covers_critical_statement_scenario() -> int:
1656
1656
 
1657
1657
  with tempfile.TemporaryDirectory(prefix="keel-unparsed-covers-unparsed-") as raw:
1658
1658
  repo = Path(raw)
1659
+ # A colon after the identifier stays outside the accepted shapes now
1660
+ # that bullet and bold wrapping resolve (issue #49, owner-decided).
1659
1661
  write_text(
1660
1662
  repo / "openspec/changes/demo/design.md",
1661
1663
  "## Decisions\n\n"
1662
- "- **D2** Keep one shared parser. Basis: fixture authority.\n",
1664
+ "D2: Keep one shared parser. Basis: fixture authority.\n",
1663
1665
  )
1664
1666
  returncode, message = covers_message(repo)
1665
1667
  if returncode == 0:
@@ -1723,6 +1725,250 @@ def validate_unparsed_covers_critical_statement_scenario() -> int:
1723
1725
  return 0
1724
1726
 
1725
1727
 
1728
+ def validate_widened_critical_statement_shapes_scenario() -> int:
1729
+ """Issue #49 Section 1, owner-decided 2026-08-17: criticalAuthority() must
1730
+ resolve a design.md critical-statement line in the shapes authors actually
1731
+ write — bulleted, bold-wrapped, or both — not only the bare line shape,
1732
+ while a reference matching more than one line still fails as duplicated and
1733
+ a still-unaccepted shape reports Unparsed naming the accepted shapes.
1734
+ """
1735
+ task = task_capsule_compact_fixture().replace(
1736
+ " - E1: Public behavior passes.\n",
1737
+ " - D2\n",
1738
+ )
1739
+
1740
+ def start(repo: Path) -> subprocess.CompletedProcess[str]:
1741
+ write_text(repo / "openspec/changes/demo/tasks.md", task)
1742
+ return run_keel(
1743
+ repo, "gate", "task-start", "--change", "demo", "--task", "1.1", "--json"
1744
+ )
1745
+
1746
+ accepted_shapes = [
1747
+ "- D2 — Keep one shared parser.",
1748
+ "**D2** — Keep one shared parser.",
1749
+ "- **D2** — Keep one shared parser.",
1750
+ "* D2 — Keep one shared parser.",
1751
+ "+ D2 — Keep one shared parser.",
1752
+ ]
1753
+ for shape in accepted_shapes:
1754
+ with tempfile.TemporaryDirectory(prefix="keel-widened-shape-") as raw:
1755
+ repo = Path(raw)
1756
+ write_text(
1757
+ repo / "openspec/changes/demo/design.md",
1758
+ f"## Decisions\n\n{shape}\n",
1759
+ )
1760
+ result = start(repo)
1761
+ if result.returncode != 0:
1762
+ report(
1763
+ f"widened-critical-statement-shapes: the shape {shape!r} "
1764
+ f"must resolve, got exit {result.returncode}: "
1765
+ f"{(result.stderr or result.stdout).strip()}"
1766
+ )
1767
+ return 1
1768
+ authority = (
1769
+ json.loads(result.stdout)
1770
+ .get("contract", {})
1771
+ .get("capsule", {})
1772
+ .get("authority", [])
1773
+ )
1774
+ if not any(
1775
+ item.get("reference") == "D2"
1776
+ and item.get("kind") == "critical-statement"
1777
+ and item.get("text") == "Keep one shared parser."
1778
+ for item in authority
1779
+ ):
1780
+ report(
1781
+ f"widened-critical-statement-shapes: the shape {shape!r} "
1782
+ "must resolve as critical-statement authority carrying the "
1783
+ "statement text."
1784
+ )
1785
+ return 1
1786
+
1787
+ with tempfile.TemporaryDirectory(prefix="keel-widened-duplicate-") as raw:
1788
+ repo = Path(raw)
1789
+ write_text(
1790
+ repo / "openspec/changes/demo/design.md",
1791
+ "## Decisions\n\nD2 — Keep one shared parser.\n\n"
1792
+ "- D2 — Keep one shared parser.\n",
1793
+ )
1794
+ result = start(repo)
1795
+ problems = json.loads(result.stdout).get("problems", [])
1796
+ if result.returncode == 0 or not any(
1797
+ item.get("code") == "ambiguous-covers"
1798
+ and item.get("message", "").startswith(
1799
+ "Duplicated Covers critical statement: D2."
1800
+ )
1801
+ for item in problems
1802
+ ):
1803
+ report(
1804
+ "widened-critical-statement-shapes: the same identifier in "
1805
+ "two accepted shapes must fail as duplicated."
1806
+ )
1807
+ return 1
1808
+
1809
+ with tempfile.TemporaryDirectory(prefix="keel-widened-unparsed-") as raw:
1810
+ repo = Path(raw)
1811
+ write_text(
1812
+ repo / "openspec/changes/demo/design.md",
1813
+ "## Decisions\n\nD2: Keep one shared parser.\n",
1814
+ )
1815
+ result = start(repo)
1816
+ problems = json.loads(result.stdout).get("problems", [])
1817
+ message = next(
1818
+ (
1819
+ item.get("message", "")
1820
+ for item in problems
1821
+ if item.get("code") == "unresolved-covers"
1822
+ ),
1823
+ "",
1824
+ )
1825
+ if result.returncode == 0 or not message.startswith(
1826
+ "Unparsed Covers critical statement: D2."
1827
+ ):
1828
+ report(
1829
+ "widened-critical-statement-shapes: a colon line must still "
1830
+ f"report Unparsed, got: {message!r}"
1831
+ )
1832
+ return 1
1833
+ if "- D2 — " not in message or "**D2** — " not in message:
1834
+ report(
1835
+ "widened-critical-statement-shapes: the Unparsed message must "
1836
+ f"name the bulleted and bold shapes as accepted, got: {message!r}"
1837
+ )
1838
+ return 1
1839
+
1840
+ if "widened-critical-statement-shapes" not in {name for name, _ in SCENARIOS}:
1841
+ report(
1842
+ "widened-critical-statement-shapes: the scenario registry does not "
1843
+ "include it."
1844
+ )
1845
+ return 1
1846
+ report("widened-critical-statement-shapes scenario passed.")
1847
+ return 0
1848
+
1849
+
1850
+ def validate_covers_annotation_entry_scenario() -> int:
1851
+ """Issue #49 Section 1's fail-open half, owner-decided 2026-08-17: a Covers
1852
+ entry that opens with a critical-statement identifier and a dash
1853
+ (`D2 — note`) must resolve as that critical statement — failing loudly when
1854
+ the identifier is missing from design.md — instead of silently degrading to
1855
+ an unlinked legacy-task-reference, while colon-form entries and hyphenated
1856
+ free text stay free-text references.
1857
+ """
1858
+ design_with_d2 = "## Decisions\n\nD2 — Keep one shared parser.\n"
1859
+
1860
+ def start_with_covers(
1861
+ repo: Path, covers_entry: str, design: str
1862
+ ) -> subprocess.CompletedProcess[str]:
1863
+ write_text(repo / "openspec/changes/demo/design.md", design)
1864
+ write_text(
1865
+ repo / "openspec/changes/demo/tasks.md",
1866
+ task_capsule_compact_fixture().replace(
1867
+ " - E1: Public behavior passes.\n",
1868
+ f" - {covers_entry}\n",
1869
+ ),
1870
+ )
1871
+ return run_keel(
1872
+ repo, "gate", "task-start", "--change", "demo", "--task", "1.1", "--json"
1873
+ )
1874
+
1875
+ def authority_of(result: subprocess.CompletedProcess[str]) -> list:
1876
+ return (
1877
+ json.loads(result.stdout)
1878
+ .get("contract", {})
1879
+ .get("capsule", {})
1880
+ .get("authority", [])
1881
+ )
1882
+
1883
+ with tempfile.TemporaryDirectory(prefix="keel-covers-annotation-") as raw:
1884
+ repo = Path(raw)
1885
+ result = start_with_covers(repo, "D2 — an annotation", design_with_d2)
1886
+ if result.returncode != 0:
1887
+ report(
1888
+ "covers-annotation-entry: `D2 — an annotation` with a resolvable "
1889
+ f"D2 must pass, got exit {result.returncode}: "
1890
+ f"{(result.stderr or result.stdout).strip()}"
1891
+ )
1892
+ return 1
1893
+ authority = authority_of(result)
1894
+ if not any(
1895
+ item.get("reference") == "D2"
1896
+ and item.get("kind") == "critical-statement"
1897
+ and item.get("text") == "Keep one shared parser."
1898
+ for item in authority
1899
+ ):
1900
+ report(
1901
+ "covers-annotation-entry: `D2 — an annotation` must resolve as "
1902
+ "critical-statement authority carrying the design.md statement "
1903
+ f"text, got: {authority!r}"
1904
+ )
1905
+ return 1
1906
+ if any("an annotation" in str(item.get("text", "")) for item in authority):
1907
+ report(
1908
+ "covers-annotation-entry: the annotation must not become "
1909
+ "authority text; design.md owns the statement."
1910
+ )
1911
+ return 1
1912
+
1913
+ with tempfile.TemporaryDirectory(prefix="keel-covers-annotation-missing-") as raw:
1914
+ repo = Path(raw)
1915
+ result = start_with_covers(
1916
+ repo,
1917
+ "D2 — an annotation",
1918
+ "## Decisions\n\nD1 — Unrelated decision. Basis: fixture authority.\n",
1919
+ )
1920
+ problems = json.loads(result.stdout).get("problems", [])
1921
+ message = next(
1922
+ (
1923
+ item.get("message", "")
1924
+ for item in problems
1925
+ if item.get("code") == "unresolved-covers"
1926
+ ),
1927
+ "",
1928
+ )
1929
+ if result.returncode == 0 or not message.startswith(
1930
+ "Missing Covers critical statement: D2."
1931
+ ):
1932
+ report(
1933
+ "covers-annotation-entry: `D2 — an annotation` with no D2 in "
1934
+ "design.md must fail as Missing instead of passing as an "
1935
+ f"unlinked reference, got exit {result.returncode}: {message!r}"
1936
+ )
1937
+ return 1
1938
+
1939
+ for covers_entry in ("E1: Public behavior passes.", "D2-compatible fixture text"):
1940
+ with tempfile.TemporaryDirectory(prefix="keel-covers-annotation-free-") as raw:
1941
+ repo = Path(raw)
1942
+ result = start_with_covers(repo, covers_entry, design_with_d2)
1943
+ if result.returncode != 0:
1944
+ report(
1945
+ f"covers-annotation-entry: {covers_entry!r} must stay a "
1946
+ "passing free-text reference, got exit "
1947
+ f"{result.returncode}: "
1948
+ f"{(result.stderr or result.stdout).strip()}"
1949
+ )
1950
+ return 1
1951
+ authority = authority_of(result)
1952
+ if not any(
1953
+ item.get("kind") == "legacy-task-reference" for item in authority
1954
+ ) or any(
1955
+ item.get("kind") == "critical-statement" for item in authority
1956
+ ):
1957
+ report(
1958
+ f"covers-annotation-entry: {covers_entry!r} must remain a "
1959
+ f"legacy-task-reference, got: {authority!r}"
1960
+ )
1961
+ return 1
1962
+
1963
+ if "covers-annotation-entry" not in {name for name, _ in SCENARIOS}:
1964
+ report(
1965
+ "covers-annotation-entry: the scenario registry does not include it."
1966
+ )
1967
+ return 1
1968
+ report("covers-annotation-entry scenario passed.")
1969
+ return 0
1970
+
1971
+
1726
1972
  def validate_expectation_completion_gates_scenario() -> int:
1727
1973
  protocol_snippets = [
1728
1974
  "Completion Gate",
@@ -4806,7 +5052,10 @@ def validate_authoring_surface_owner_and_tags_scenario() -> int:
4806
5052
  return 1
4807
5053
  created = run_openspec(repo, "new", "change", "surface-probe")
4808
5054
  if created is None:
4809
- report(f"{label} skipped: the openspec CLI is not on PATH.")
5055
+ report(
5056
+ f"{label} skipped: the openspec CLI could not be found. "
5057
+ "Searched " + ", then ".join(OPENSPEC_SEARCH_ORDER) + "."
5058
+ )
4810
5059
  return 3
4811
5060
  if created.returncode != 0:
4812
5061
  report(f"{label} could not scaffold a change to read the instruction.")
@@ -4836,32 +5085,845 @@ def validate_authoring_surface_owner_and_tags_scenario() -> int:
4836
5085
  f"{label} {surface} still reads as though `.red`/`.green` "
4837
5086
  "replace the bare M<n> Evidence rather than accompanying it."
4838
5087
  )
4839
- return 1
4840
- if "repo-relative path that exists" not in text:
4841
- report(
4842
- f"{label} {surface} does not state the existing-path owner form."
5088
+ return 1
5089
+ if "repo-relative path that exists" not in text:
5090
+ report(
5091
+ f"{label} {surface} does not state the existing-path owner form."
5092
+ )
5093
+ return 1
5094
+ if "HANDOFF" not in text:
5095
+ report(f"{label} {surface} does not state that HANDOFF is refused.")
5096
+ return 1
5097
+
5098
+ for needle in (
5099
+ "regression-only-strategy",
5100
+ "in addition to the bare",
5101
+ "any repo-relative path that exists",
5102
+ ):
5103
+ if needle not in resident:
5104
+ report(f"{label} resident protocol does not state: {needle}")
5105
+ return 1
5106
+
5107
+ for local, packaged in SCHEMA_COPY_PAIRS:
5108
+ if (ROOT / local).read_text(encoding="utf-8") != (
5109
+ ROOT / packaged
5110
+ ).read_text(encoding="utf-8"):
5111
+ report(f"{label} schema copies diverge: {local} vs {packaged}")
5112
+ return 1
5113
+
5114
+ report(f"{label} scenario passed.")
5115
+ return 0
5116
+
5117
+
5118
+ def validate_the_spec_names_the_managed_set_scenario() -> int:
5119
+ """Issue #86: the spec's summary named two of the four actions it covers.
5120
+
5121
+ `propose` and `sync` joined the managed set as new requirements appended to
5122
+ the overlay spec, and the `## Purpose` and the requirement that says which
5123
+ actions carry an overlay were never rewritten — so the top of the file
5124
+ disagreed with the requirements below it.
5125
+
5126
+ The Purpose line survived issue #79's sweep for a structural reason:
5127
+ OpenSpec's delta operations are Requirement-scoped, so no change can carry
5128
+ a Purpose edit, and the line is written once when the capability is
5129
+ created. It is therefore the location most likely to drift and the one
5130
+ least likely to be noticed, which is why it is checked by name here.
5131
+
5132
+ Two named locations, not a scan. Three statements in the published specs
5133
+ name a proper subset of the managed set and are correct — the doctor's
5134
+ command-surface label, which excludes the authoring action on purpose;
5135
+ `sync/archive decisions`, which names what the current agent owns; and
5136
+ `authoring/apply/archive/sync`, which spells `propose` as authoring. A
5137
+ check that refused those would cost more than the drift it catches.
5138
+ """
5139
+ label = "the-spec-names-the-managed-set"
5140
+
5141
+ source = (ROOT / "bin" / "keel.js").read_text(encoding="utf-8")
5142
+ declaration = re.search(
5143
+ r"const OPENSPEC_OVERLAY_ACTIONS = \[([^\]]*)\];", source
5144
+ )
5145
+ if declaration is None:
5146
+ report(
5147
+ f"{label}: bin/keel.js declares no OPENSPEC_OVERLAY_ACTIONS, so the "
5148
+ "check has no managed set to compare against. Restating the set "
5149
+ "here would be the same defect one layer out."
5150
+ )
5151
+ return 1
5152
+ managed = re.findall(r'"([a-z]+)"', declaration.group(1))
5153
+ if not managed:
5154
+ report(f"{label}: OPENSPEC_OVERLAY_ACTIONS parsed to an empty set.")
5155
+ return 1
5156
+
5157
+ spec_path = ROOT / "openspec/specs/keel-openspec-surface-overlay/spec.md"
5158
+ REQUIREMENT = "### Requirement: Keel overlays every action in the managed set"
5159
+
5160
+ def locations(text: str) -> tuple[str, str] | None:
5161
+ purpose = re.search(r"^## Purpose\s*\n+(.+?)\n", text, re.M)
5162
+ if purpose is None:
5163
+ return None
5164
+ start = text.find(REQUIREMENT)
5165
+ if start < 0:
5166
+ return None
5167
+ end = text.find("\n### Requirement:", start + len(REQUIREMENT))
5168
+ body = text[start : end if end > 0 else len(text)]
5169
+ return purpose.group(1), body
5170
+
5171
+ # Two distinct failures, kept apart on purpose: a location the check
5172
+ # cannot find is not a location that names the wrong thing, and reporting
5173
+ # the second when the first happened sends the reader to a paragraph with
5174
+ # nothing wrong in it.
5175
+ NOT_FOUND = "__not-found__"
5176
+
5177
+ def missing(text: str) -> list[tuple[str, list[str]]]:
5178
+ found = locations(text)
5179
+ if found is None:
5180
+ return [(NOT_FOUND, list(managed))]
5181
+ purpose, body = found
5182
+ gaps = []
5183
+ for name, region in (("## Purpose", purpose), (REQUIREMENT, body)):
5184
+ absent = [
5185
+ action for action in managed
5186
+ if not re.search(rf"\b{action}\b", region)
5187
+ ]
5188
+ if absent:
5189
+ gaps.append((name, absent))
5190
+ return gaps
5191
+
5192
+ published = spec_path.read_text(encoding="utf-8")
5193
+ gaps = missing(published)
5194
+ if gaps:
5195
+ for name, absent in gaps:
5196
+ if name == NOT_FOUND:
5197
+ report(
5198
+ f"{label}: keel-openspec-surface-overlay has no `## Purpose` "
5199
+ f"line or no `{REQUIREMENT}` heading, so this check found "
5200
+ "nothing to compare against the managed set. Nothing about "
5201
+ "the action names is being reported here — the location is "
5202
+ "what is missing."
5203
+ )
5204
+ continue
5205
+ report(
5206
+ f"{label}: the `{name}` of keel-openspec-surface-overlay does "
5207
+ f"not name {', '.join(absent)}, which bin/keel.js manages an "
5208
+ "overlay for. The file's summary and the requirements under it "
5209
+ "disagree, and a reader takes the summary."
5210
+ )
5211
+ return 1
5212
+
5213
+ # The check fails on a drifted copy, and names the location that drifted.
5214
+ drifted = published.replace(
5215
+ REQUIREMENT, "### Requirement: Keel overlays apply and archive surfaces", 1
5216
+ )
5217
+ if missing(drifted) == []:
5218
+ report(
5219
+ f"{label}: a copy whose requirement heading is gone was reported "
5220
+ "clean. A check that cannot find what it looks for must fail, not "
5221
+ "pass — a vacuous pass is how the drift got here."
5222
+ )
5223
+ return 1
5224
+
5225
+ for action in managed:
5226
+ reduced = published
5227
+ found = locations(published)
5228
+ assert found is not None
5229
+ purpose_line = found[0]
5230
+ reduced = reduced.replace(
5231
+ purpose_line, re.sub(rf"\b{action}\b[,/ ]*", "", purpose_line), 1
5232
+ )
5233
+ gaps = missing(reduced)
5234
+ if not any(name == "## Purpose" and action in absent for name, absent in gaps):
5235
+ report(
5236
+ f"{label}: the Purpose line with `{action}` removed was not "
5237
+ "reported. Each managed action is checked on its own, so an "
5238
+ "action that joins the set later is not covered by the others."
5239
+ )
5240
+ return 1
5241
+
5242
+ # The three subset spellings that are correct stay correct: the check is
5243
+ # two named locations in one file, not a scan for action words.
5244
+ diagnostics = (
5245
+ ROOT / "openspec/specs/keel-target-surface-diagnostics/spec.md"
5246
+ ).read_text(encoding="utf-8")
5247
+ for correct in (
5248
+ "apply/archive/sync overlay markers",
5249
+ "it names sync alongside apply and archive",
5250
+ ):
5251
+ if correct not in diagnostics:
5252
+ report(
5253
+ f"{label}: the diagnostics spec no longer carries the correct "
5254
+ f"subset spelling `{correct}`. This scenario asserts the check "
5255
+ "leaves it alone; if the wording moved, the assertion has to "
5256
+ "move with it rather than be dropped."
5257
+ )
5258
+ return 1
5259
+ if "sync/archive decisions" not in published:
5260
+ report(
5261
+ f"{label}: the overlay spec no longer carries `sync/archive "
5262
+ "decisions`, the third correct subset spelling this check must "
5263
+ "not refuse."
5264
+ )
5265
+ return 1
5266
+
5267
+ if label not in {name for name, _ in SCENARIOS}:
5268
+ report(f"{label}: the scenario registry does not include it.")
5269
+ return 1
5270
+ report(f"{label} scenario passed.")
5271
+ return 0
5272
+
5273
+
5274
+ def validate_an_invalidates_phrase_may_wrap_scenario() -> int:
5275
+ """Issue #108: the quoted phrase had to fit on one line.
5276
+
5277
+ An `## Invalidates` entry carries a quotation, a location, and a closure,
5278
+ and wraps as often as it needs to. The phrase test was
5279
+ `/"[^"\\n]{3,}"/`, so a quotation that ran past the end of a line was
5280
+ reported as `names where to look but not what to look for` — when it named
5281
+ exactly that, and the only repair was to reflow the text.
5282
+
5283
+ The corpus looked clean for the wrong reason: 42 of this repository's 194
5284
+ archived entries span more than one line and none carries a wrapped
5285
+ quotation, because the gate refused every one that did. Five entries in a
5286
+ single session were shortened to fit.
5287
+
5288
+ The bound is the entry, set by the section parser, so a quotation cannot
5289
+ reach past its own entry — asserted below by refusing a second, unquoted
5290
+ entry while the wrapped one is accepted.
5291
+ """
5292
+ label = "an-invalidates-phrase-may-wrap"
5293
+
5294
+ with tempfile.TemporaryDirectory(prefix="keel-wrapped-phrase-") as raw:
5295
+ repo = Path(raw) / "repo"
5296
+ repo.mkdir()
5297
+ tasks_path = repo / "openspec/changes/demo/tasks.md"
5298
+ write_text(repo / "openspec/changes/demo/proposal.md", "# Proposal\n")
5299
+ write_text(repo / "openspec/changes/demo/design.md", "## Context\n\nfixture\n")
5300
+ write_text(
5301
+ repo / "openspec/changes/demo/specs/demo/spec.md",
5302
+ "## ADDED Requirements\n",
5303
+ )
5304
+
5305
+ def start(section: str) -> dict:
5306
+ write_text(
5307
+ tasks_path,
5308
+ task_contract_fixture().replace(
5309
+ "## Invalidates\n\n- None.\n\n", section
5310
+ ),
5311
+ )
5312
+ result = run_keel(
5313
+ repo, "gate", "task-start", "--change", "demo", "--task", "1.1",
5314
+ "--json",
5315
+ )
5316
+ return json.loads(result.stdout)
5317
+
5318
+ def problems(payload: dict) -> str:
5319
+ return " ".join(
5320
+ item.get("code", "") + ": " + item.get("message", "")
5321
+ for item in payload.get("problems", [])
5322
+ )
5323
+
5324
+ wrapped = (
5325
+ '## Invalidates\n\n'
5326
+ '- I1: "the wording that is now wrong and runs on\n'
5327
+ ' past the end of this line" — somewhere in the repo.\n'
5328
+ ' Updated by: 1.1\n\n'
5329
+ )
5330
+ payload = start(wrapped)
5331
+ if payload.get("status") != "pass":
5332
+ report(
5333
+ f"{label}: an entry whose quotation wraps was refused. It named "
5334
+ "the wording a reader would search for; what it did not do was "
5335
+ "fit on one line, and reflowing text is not a repair."
5336
+ )
5337
+ report(json.dumps(payload.get("problems", []), indent=2))
5338
+ return 1
5339
+
5340
+ # The requirement itself is unchanged: an entry with no quotation
5341
+ # anywhere in its body is still refused.
5342
+ unquoted = (
5343
+ '## Invalidates\n\n'
5344
+ '- I1: the wording that is now wrong and runs on\n'
5345
+ ' past the end of this line — somewhere in the repo.\n'
5346
+ ' Updated by: 1.1\n\n'
5347
+ )
5348
+ payload = start(unquoted)
5349
+ if payload.get("status") == "pass":
5350
+ report(
5351
+ f"{label}: an entry carrying no quotation at all was accepted. "
5352
+ "Reading the phrase across lines must widen where the quotation "
5353
+ "may sit, not remove the requirement to write one."
5354
+ )
5355
+ return 1
5356
+ if "invalidation-phrase" not in problems(payload):
5357
+ report(
5358
+ f"{label}: the unquoted entry was refused for some other reason, "
5359
+ "so this assertion proves nothing about the phrase check. Got: "
5360
+ + problems(payload)
5361
+ )
5362
+ return 1
5363
+
5364
+ # The bound is the entry. A wrapped quotation in I1 must not satisfy an
5365
+ # unquoted I2 sitting below it.
5366
+ mixed = (
5367
+ '## Invalidates\n\n'
5368
+ '- I1: "the wording that is now wrong and runs on\n'
5369
+ ' past the end of this line" — somewhere in the repo.\n'
5370
+ ' Updated by: 1.1\n'
5371
+ '- I2: some other statement — elsewhere in the repo.\n'
5372
+ ' Updated by: 1.1\n\n'
5373
+ )
5374
+ payload = start(mixed)
5375
+ text = problems(payload)
5376
+ if payload.get("status") == "pass":
5377
+ report(
5378
+ f"{label}: an unquoted entry below a wrapped one was accepted. "
5379
+ "The quotation is bounded by its own entry; if it reaches the "
5380
+ "next one, every entry after a quoted one passes for free."
5381
+ )
5382
+ return 1
5383
+ if "I2" not in text:
5384
+ report(
5385
+ f"{label}: the run failed, but not for I2 — so this assertion "
5386
+ "proves nothing about the entry bound. The unquoted entry is "
5387
+ "the one that must be named. Got: " + text
5388
+ )
5389
+ return 1
5390
+ if "I1" in text:
5391
+ report(
5392
+ f"{label}: the wrapped entry was reported alongside the unquoted "
5393
+ "one. It carries its quotation and must not be named."
5394
+ )
5395
+ report(text)
5396
+ return 1
5397
+
5398
+ if label not in {name for name, _ in SCENARIOS}:
5399
+ report(f"{label}: the scenario registry does not include it.")
5400
+ return 1
5401
+ report(f"{label} scenario passed.")
5402
+ return 0
5403
+
5404
+
5405
+ def validate_the_tarball_is_the_repository_scenario() -> int:
5406
+ """Issue #110: what ships depended on the packer's working tree.
5407
+
5408
+ `files` names `scripts/`, and declaring a `files` array means `.gitignore`
5409
+ stops filtering inside it — while `__pycache__` is not on npm's default
5410
+ exclusion list. Packing one commit twice: 41 files on a clean checkout, 43
5411
+ after anyone has run the Python in `scripts/`.
5412
+
5413
+ The assertion is that every packed file is tracked by Git. That states the
5414
+ requirement directly and cannot be satisfied by a machine's leftovers; a
5415
+ recomputed list of expected files would have to reimplement npm's inclusion
5416
+ rules and would drift from them.
5417
+ """
5418
+ label = "the-tarball-is-the-repository"
5419
+
5420
+ npm = shutil.which("npm")
5421
+ if npm is None:
5422
+ report(f"{label} skipped: npm is not on PATH, and it is what packs.")
5423
+ return 3
5424
+
5425
+ # Reproduce the state the issue reports before asserting anything, so the
5426
+ # check runs against a tree that has the residue rather than one that
5427
+ # happens not to.
5428
+ residue = ROOT / "scripts" / "__pycache__" / "keel-pack-probe.pyc"
5429
+ created_dir = not residue.parent.exists()
5430
+ residue.parent.mkdir(parents=True, exist_ok=True)
5431
+ residue.write_bytes(b"probe\n")
5432
+ try:
5433
+ packed = subprocess.run(
5434
+ [npm, "pack", "--dry-run", "--json"],
5435
+ cwd=ROOT,
5436
+ text=True,
5437
+ encoding="utf-8",
5438
+ errors="replace",
5439
+ capture_output=True,
5440
+ check=False,
5441
+ )
5442
+ if packed.returncode != 0:
5443
+ report(f"{label}: npm pack --dry-run failed.")
5444
+ report((packed.stderr or packed.stdout).strip())
5445
+ return 1
5446
+ try:
5447
+ files = [item["path"] for item in json.loads(packed.stdout)[0]["files"]]
5448
+ except (ValueError, KeyError, IndexError):
5449
+ report(f"{label}: could not read the file list from npm pack --json.")
5450
+ report((packed.stdout or "").strip()[:400])
5451
+ return 1
5452
+
5453
+ tracked = subprocess.run(
5454
+ ["git", "ls-files", "-z"],
5455
+ cwd=ROOT,
5456
+ capture_output=True,
5457
+ check=False,
5458
+ )
5459
+ if tracked.returncode != 0:
5460
+ report(f"{label}: git ls-files failed, so tracked state is unknown.")
5461
+ return 1
5462
+ known = {
5463
+ name for name in tracked.stdout.decode("utf-8").split("\0") if name
5464
+ }
5465
+
5466
+ untracked = sorted(path for path in files if path not in known)
5467
+ if untracked:
5468
+ report(
5469
+ f"{label}: {len(untracked)} packed file(s) are not tracked by "
5470
+ "Git, so what ships depends on the machine that packs it "
5471
+ "rather than on the repository:"
5472
+ )
5473
+ for path in untracked:
5474
+ report(f" {path}")
5475
+ return 1
5476
+ if not files:
5477
+ report(f"{label}: npm pack reported no files, so nothing was checked.")
5478
+ return 1
5479
+ finally:
5480
+ residue.unlink(missing_ok=True)
5481
+ if created_dir:
5482
+ try:
5483
+ residue.parent.rmdir()
5484
+ except OSError:
5485
+ pass
5486
+
5487
+ if label not in {name for name, _ in SCENARIOS}:
5488
+ report(f"{label}: the scenario registry does not include it.")
5489
+ return 1
5490
+ report(f"{label} scenario passed: {len(files)} packed files, all tracked.")
5491
+ return 0
5492
+
5493
+
5494
+ def validate_a_declared_dependency_is_resolved_scenario() -> int:
5495
+ """Issue #105: the suite looked for its own dependency only on PATH.
5496
+
5497
+ `@fission-ai/openspec` is declared in `package.json` and lands at
5498
+ `node_modules/.bin/openspec`. npm scripts see that directory; a Python
5499
+ subprocess started by `node scripts/run_python.js` does not. So on a
5500
+ checkout that had only run `npm install`, `--all` reported
5501
+ `validation --all failed for: compact-task-authoring` while the schema it
5502
+ was said to be unable to resolve resolved perfectly.
5503
+
5504
+ The second half is that scenario's own condition: an unresolvable CLI and a
5505
+ CLI that ran and refused shared one message, so the reader was sent to a
5506
+ subject with nothing wrong with it.
5507
+ """
5508
+ label = "a-declared-dependency-is-resolved"
5509
+
5510
+ # `no openspec on PATH`, and nothing else removed. Emptying PATH outright
5511
+ # would also remove `node`, and the openspec shim needs it — the scenario
5512
+ # would then be asserting that a shell without an interpreter fails.
5513
+ without = dict(os.environ)
5514
+ without["PATH"] = os.pathsep.join(
5515
+ entry
5516
+ for entry in os.environ.get("PATH", "").split(os.pathsep)
5517
+ if entry and not (Path(entry) / "openspec").exists()
5518
+ )
5519
+ if shutil.which("openspec", path=without["PATH"]) is not None:
5520
+ report(
5521
+ f"{label}: could not build a PATH without openspec on it, so the "
5522
+ "reproduction cannot be set up."
5523
+ )
5524
+ return 1
5525
+ resolved = run_openspec(ROOT, "--version", env=without)
5526
+ if resolved is None:
5527
+ report(
5528
+ f"{label}: with no `openspec` on PATH the runner resolved nothing, "
5529
+ "but this package declares it as a dependency and installs it at "
5530
+ "node_modules/.bin. A tool the repository ships is not a tool the "
5531
+ "host has to provide."
5532
+ )
5533
+ return 1
5534
+ if resolved.returncode != 0 or not re.search(r"\d+\.\d+\.\d+", resolved.stdout):
5535
+ report(
5536
+ f"{label}: the resolved openspec did not report a version. "
5537
+ f"exit={resolved.returncode} out={(resolved.stdout or '').strip()!r}"
5538
+ )
5539
+ return 1
5540
+
5541
+ # And it is the declared one, not whatever a host happens to carry.
5542
+ declared = ROOT / "node_modules" / ".bin" / "openspec"
5543
+ if not declared.exists():
5544
+ report(
5545
+ f"{label}: {declared} is absent, so this scenario cannot tell a "
5546
+ "resolved dependency from a lucky PATH entry. Run `npm install`."
5547
+ )
5548
+ return 3
5549
+
5550
+ # The two failures carry their own messages. Asserted on the emitted text
5551
+ # of the scenario that had them fused, not on the source.
5552
+ fused = (ROOT / "scripts" / "validate_plugin.py").read_text(encoding="utf-8")
5553
+ marker = 'label = "compact-task-authoring"'
5554
+ start = fused.find(marker)
5555
+ if start < 0:
5556
+ report(f"{label}: compact-task-authoring is gone, so its branch cannot be read.")
5557
+ return 1
5558
+ body = fused[start : start + 3000]
5559
+ for needle, why in (
5560
+ ("could not be found", "the unresolvable branch must say the tool was not found"),
5561
+ ("searched", "the unresolvable branch must name where it looked"),
5562
+ ("return 3", "an unresolvable tool reports the skip contract, not a failure"),
5563
+ ):
5564
+ if needle not in body:
5565
+ report(f"{label}: {why}; `{needle}` is absent from its branch.")
5566
+ return 1
5567
+ if re.search(r"if which is None or which\.returncode", body):
5568
+ report(
5569
+ f"{label}: compact-task-authoring still guards two distinct "
5570
+ "failures behind one condition. A tool that was never found and a "
5571
+ "tool that ran and refused are different facts, and reporting the "
5572
+ "first message when the second happened names the wrong cause."
5573
+ )
5574
+ return 1
5575
+
5576
+ if label not in {name for name, _ in SCENARIOS}:
5577
+ report(f"{label}: the scenario registry does not include it.")
5578
+ return 1
5579
+ report(f"{label} scenario passed.")
5580
+ return 0
5581
+
5582
+
5583
+ def validate_a_root_file_is_a_path_scenario() -> int:
5584
+ """Issue #107: a file at the repository root had no separator to find.
5585
+
5586
+ `declaredPath()` located a path by finding a run of non-whitespace holding
5587
+ a path separator. Nine files sit at this repository's root and every one of
5588
+ them is a legitimate owner, so `Durable owner: AGENTS.md` was refused with
5589
+ `it names neither a check nor a path` — for a path whose file exists. The
5590
+ way past it was `./AGENTS.md`, which is a concession to the extractor
5591
+ rather than a path anyone meant.
5592
+
5593
+ The boundary is what keeps this from becoming worse than the defect: a bare
5594
+ word must stay unrecognized, or `Durable owner: pending` would be reported
5595
+ as a file that does not exist and send the author to create one.
5596
+ """
5597
+ label = "a-root-file-is-a-path"
5598
+
5599
+ with tempfile.TemporaryDirectory(prefix="keel-root-file-") as raw:
5600
+ repo = Path(raw) / "repo"
5601
+ repo.mkdir()
5602
+ tasks_path = repo / "openspec/changes/demo/tasks.md"
5603
+ write_text(repo / "openspec/changes/demo/proposal.md", "# Proposal\n")
5604
+ write_text(repo / "openspec/changes/demo/design.md", "## Context\n\nfixture\n")
5605
+ write_text(
5606
+ repo / "openspec/changes/demo/specs/demo/spec.md",
5607
+ "## ADDED Requirements\n",
5608
+ )
5609
+ write_text(repo / "AGENTS.md", "# Agents\n")
5610
+
5611
+ def completion(findings: str) -> dict:
5612
+ write_text(
5613
+ tasks_path,
5614
+ task_contract_fixture(evidence=("M1: check exercised.",))
5615
+ .replace("- [ ] 1.1", "- [x] 1.1")
5616
+ .replace(" - Status: pending\n", " - Status: pass\n")
5617
+ .replace(
5618
+ " - Acceptance check: pending\n",
5619
+ " - Acceptance check: behavior proven through the public CLI.\n",
5620
+ )
5621
+ .replace(
5622
+ " - Scope check: pending\n",
5623
+ " - Scope check: writes stayed inside Touch.\n",
5624
+ )
5625
+ .replace(" - Findings: pending\n", f" - Findings: {findings}\n")
5626
+ )
5627
+ record_contract_anchor(repo, "demo")
5628
+ result = run_keel(
5629
+ repo, "gate", "task-complete", "--change", "demo", "--task", "1.1",
5630
+ "--json",
5631
+ )
5632
+ return json.loads(result.stdout)
5633
+
5634
+ def messages(payload: dict) -> str:
5635
+ return " ".join(
5636
+ item.get("message", "") for item in payload.get("problems", [])
5637
+ )
5638
+
5639
+ accepted = (
5640
+ ("a root file as a durable owner", "still open. Durable owner: AGENTS.md"),
5641
+ ("a root file as resolution evidence", "repaired. Resolved here: AGENTS.md"),
5642
+ ("the same file spelled with ./", "still open. Durable owner: ./AGENTS.md"),
5643
+ (
5644
+ "a root file ending a sentence",
5645
+ "still open. Durable owner: AGENTS.md.",
5646
+ ),
5647
+ )
5648
+ for description, findings in accepted:
5649
+ payload = completion(findings)
5650
+ if payload.get("status") != "pass":
5651
+ report(
5652
+ f"{label}: {description} was refused. The file exists at the "
5653
+ "repository root; requiring a separator refuses a path the "
5654
+ "author may name and leaves them only a notation to change."
5655
+ )
5656
+ report(json.dumps(payload.get("problems", []), indent=2))
5657
+ return 1
5658
+
5659
+ # Existence still decides.
5660
+ payload = completion("still open. Durable owner: NOT-THERE.md")
5661
+ if payload.get("status") == "pass":
5662
+ report(f"{label}: a root file that does not exist was accepted.")
5663
+ return 1
5664
+
5665
+ # And where the gate names a missing path, it names a root file the
5666
+ # same way. `Findings` reports the generic owner refusal for every
5667
+ # unusable owner and always has; `## Invalidates` is the reader that
5668
+ # names the file, so the naming is asserted there rather than claimed
5669
+ # of a reader that never did it.
5670
+ def invalidates(closure: str) -> dict:
5671
+ write_text(
5672
+ tasks_path,
5673
+ task_contract_fixture().replace(
5674
+ "## Invalidates\n\n- None.\n\n",
5675
+ '## Invalidates\n\n- I1: "the wording that is now wrong" '
5676
+ f"— somewhere in the repo. {closure}\n\n",
5677
+ ),
5678
+ )
5679
+ result = run_keel(
5680
+ repo, "gate", "task-start", "--change", "demo", "--task", "1.1",
5681
+ "--json",
5682
+ )
5683
+ return json.loads(result.stdout)
5684
+
5685
+ payload = invalidates("Durable owner: AGENTS.md")
5686
+ if payload.get("status") != "pass":
5687
+ report(
5688
+ f"{label}: a root file was refused as an invalidation owner. "
5689
+ "One extractor serves every reader, so a form accepted in one "
5690
+ "must be accepted in all."
5691
+ )
5692
+ report(json.dumps(payload.get("problems", []), indent=2))
5693
+ return 1
5694
+
5695
+ payload = invalidates("Durable owner: NOT-THERE.md")
5696
+ text = messages(payload)
5697
+ if payload.get("status") == "pass":
5698
+ report(f"{label}: a missing root file closed an invalidation entry.")
5699
+ return 1
5700
+ if "NOT-THERE.md" not in text:
5701
+ report(
5702
+ f"{label}: the refusal for a missing root file did not name it. "
5703
+ "A path is the one form a gate can check, and naming what it "
5704
+ f"looked for is what makes the refusal repairable. Got: {text}"
5705
+ )
5706
+ return 1
5707
+
5708
+ # The boundary: a value with no path shape stays unrecognized, so the
5709
+ # author is not sent to create a file named `pending`.
5710
+ for description, findings in (
5711
+ ("a bare word", "still open. Durable owner: pending"),
5712
+ ("a version string", "still open. Durable owner: 5.44.0"),
5713
+ ):
5714
+ payload = completion(findings)
5715
+ text = messages(payload)
5716
+ if payload.get("status") == "pass":
5717
+ report(f"{label}: {description} was accepted as a durable owner.")
5718
+ return 1
5719
+ if "does not exist" in text:
5720
+ report(
5721
+ f"{label}: {description} was reported as a file that does "
5722
+ "not exist. It is not a path, and saying it is missing "
5723
+ "sends the author to create it. Got: " + text
5724
+ )
5725
+ return 1
5726
+
5727
+ if label not in {name for name, _ in SCENARIOS}:
5728
+ report(f"{label}: the scenario registry does not include it.")
5729
+ return 1
5730
+ report(f"{label} scenario passed.")
5731
+ return 0
5732
+
5733
+
5734
+ def validate_an_owner_outlives_the_change_scenario() -> int:
5735
+ """Issue #100: the existence check expires at archive.
5736
+
5737
+ `openspec archive` moves `openspec/changes/<name>/` under
5738
+ `openspec/changes/archive/`, so a `Durable owner:` naming the change's own
5739
+ `design.md` is true when the gate reads it and false one workflow step
5740
+ later. Measured in this repository: 10 declarations name a path inside a
5741
+ live change directory, all 10 are dead, and all 10 point at the change that
5742
+ wrote them. A pointer that must break is worse than none — the Review reads
5743
+ as closed while the trail ends halfway.
5744
+
5745
+ A path into a *different* live change stays accepted: the protocol names a
5746
+ new OpenSpec change as a legitimate owner of deferred work, and no measured
5747
+ pointer has that shape.
5748
+ """
5749
+ label = "an-owner-outlives-the-change"
5750
+
5751
+ with tempfile.TemporaryDirectory(prefix="keel-owner-outlives-") as raw_tmp:
5752
+ repo = Path(raw_tmp) / "repo"
5753
+ repo.mkdir()
5754
+ tasks_path = repo / "openspec/changes/demo/tasks.md"
5755
+ write_text(repo / "openspec/changes/demo/proposal.md", "# Proposal\n")
5756
+ write_text(repo / "openspec/changes/demo/design.md", "## Context\n\nfixture\n")
5757
+ write_text(
5758
+ repo / "openspec/changes/demo/specs/demo/spec.md",
5759
+ "## ADDED Requirements\n",
5760
+ )
5761
+ # The other live change, and an archived path. Both exist; only one of
5762
+ # them is the directory the selected change is about to move.
5763
+ write_text(repo / "openspec/changes/other/design.md", "## Context\n\nother\n")
5764
+ write_text(repo / "keel/archive/notes/2026-09-05-example.md", "note\n")
5765
+
5766
+ SELF = "openspec/changes/demo/design.md"
5767
+ OTHER = "openspec/changes/other/design.md"
5768
+ ARCHIVED = "keel/archive/notes/2026-09-05-example.md"
5769
+ TRACKER = "https://github.com/TanglmChris/keel/issues/100"
5770
+
5771
+ def start(section: str) -> dict:
5772
+ fixture = task_contract_fixture()
5773
+ if section.startswith("## Invalidates"):
5774
+ fixture = fixture.replace("## Invalidates\n\n- None.\n\n", section)
5775
+ else:
5776
+ fixture = fixture + "\n" + section
5777
+ write_text(tasks_path, fixture)
5778
+ result = run_keel(
5779
+ repo, "gate", "task-start", "--change", "demo", "--task", "1.1",
5780
+ "--json",
5781
+ )
5782
+ return json.loads(result.stdout)
5783
+
5784
+ def invalidates(closure: str) -> dict:
5785
+ return start(
5786
+ '## Invalidates\n\n- I1: "the wording that is now wrong" '
5787
+ f"— somewhere in the repo. {closure}\n\n"
5788
+ )
5789
+
5790
+ # `## Expectation Coverage` is read by change-close, not task-start, so
5791
+ # this fixture closes the change rather than starting a task. Mirrors
5792
+ # the `durable-owner-vocabulary` scenario's own closing fixture.
5793
+ def expectation(closure: str) -> dict:
5794
+ write_text(
5795
+ tasks_path,
5796
+ task_contract_fixture(evidence=("M1: check exercised.",))
5797
+ .replace("- [ ] 1.1", "- [x] 1.1")
5798
+ .replace(" - Status: pending\n", " - Status: pass\n")
5799
+ .replace(
5800
+ " - Acceptance check: pending\n",
5801
+ " - Acceptance check: proven.\n",
5802
+ )
5803
+ .replace(
5804
+ " - Scope check: pending\n",
5805
+ " - Scope check: inside Touch.\n",
5806
+ )
5807
+ .replace(" - Findings: pending\n", " - Findings: none\n")
5808
+ + f"\n## Expectation Coverage\n\n- E1: the expectation. {closure}\n",
5809
+ )
5810
+ record_contract_anchor(repo, "demo")
5811
+ result = run_keel(
5812
+ repo, "gate", "change-close", "--change", "demo", "--action",
5813
+ "sync", "--json",
4843
5814
  )
4844
- return 1
4845
- if "HANDOFF" not in text:
4846
- report(f"{label} {surface} does not state that HANDOFF is refused.")
4847
- return 1
5815
+ return json.loads(result.stdout)
4848
5816
 
4849
- for needle in (
4850
- "regression-only-strategy",
4851
- "in addition to the bare",
4852
- "any repo-relative path that exists",
4853
- ):
4854
- if needle not in resident:
4855
- report(f"{label} resident protocol does not state: {needle}")
4856
- return 1
5817
+ def completion(findings: str) -> dict:
5818
+ fixture = (
5819
+ task_contract_fixture(evidence=("M1: check exercised.",))
5820
+ .replace("- [ ] 1.1", "- [x] 1.1")
5821
+ .replace(" - Status: pending\n", " - Status: pass\n")
5822
+ .replace(
5823
+ " - Acceptance check: pending\n",
5824
+ " - Acceptance check: behavior proven through the public CLI.\n",
5825
+ )
5826
+ .replace(
5827
+ " - Scope check: pending\n",
5828
+ " - Scope check: writes stayed inside Touch.\n",
5829
+ )
5830
+ .replace(" - Findings: pending\n", f" - Findings: {findings}\n")
5831
+ )
5832
+ write_text(tasks_path, fixture)
5833
+ record_contract_anchor(repo, "demo")
5834
+ result = run_keel(
5835
+ repo, "gate", "task-complete", "--change", "demo", "--task", "1.1",
5836
+ "--json",
5837
+ )
5838
+ return json.loads(result.stdout)
4857
5839
 
4858
- for local, packaged in SCHEMA_COPY_PAIRS:
4859
- if (ROOT / local).read_text(encoding="utf-8") != (
4860
- ROOT / packaged
4861
- ).read_text(encoding="utf-8"):
4862
- report(f"{label} schema copies diverge: {local} vs {packaged}")
4863
- return 1
5840
+ def messages(payload: dict) -> str:
5841
+ return " ".join(
5842
+ item.get("message", "") for item in payload.get("problems", [])
5843
+ )
5844
+
5845
+ # The self-pointer is refused wherever an owner closes an entry, and
5846
+ # the refusal says why rather than reporting a spelling problem.
5847
+ refusals = (
5848
+ ("an Invalidates entry", invalidates(f"Durable owner: {SELF}")),
5849
+ ("an Expectation Coverage entry", expectation(f"Durable owner: {SELF}")),
5850
+ (
5851
+ "a Review finding",
5852
+ completion(f"the rule needs a second look. Durable owner: {SELF}"),
5853
+ ),
5854
+ (
5855
+ "a Resolved here claim",
5856
+ completion(f"the rule was repaired. Resolved here: {SELF}"),
5857
+ ),
5858
+ )
5859
+ for description, payload in refusals:
5860
+ if payload.get("status") == "pass":
5861
+ report(
5862
+ f"{label}: a path inside the selected change's own directory "
5863
+ f"closed {description}. That directory moves when the change "
5864
+ "is archived, so the check was true only until the next step "
5865
+ "of the workflow that accepted it."
5866
+ )
5867
+ return 1
5868
+ text = messages(payload)
5869
+ if "archiv" not in text.lower():
5870
+ report(
5871
+ f"{label}: the refusal for {description} does not say the "
5872
+ "directory moves when the change is archived, so it reads as "
5873
+ "a spelling problem the author cannot repair. Got: "
5874
+ f"{text or '(none)'}"
5875
+ )
5876
+ return 1
5877
+
5878
+ # A different live change is a legitimate owner and stays one, as do
5879
+ # the forms that already worked.
5880
+ accepted = (
5881
+ ("another live change closing an Invalidates entry",
5882
+ invalidates(f"Durable owner: {OTHER}")),
5883
+ ("another live change closing an Expectation Coverage entry",
5884
+ expectation(f"Durable owner: {OTHER}")),
5885
+ ("another live change owning a finding",
5886
+ completion(f"still open. Durable owner: {OTHER}")),
5887
+ ("resolution evidence in another live change",
5888
+ completion(f"repaired. Resolved here: {OTHER}")),
5889
+ ("an archived path owning a finding",
5890
+ completion(f"still open. Durable owner: {ARCHIVED}")),
5891
+ ("a tracker reference owning a finding",
5892
+ completion(f"still open. Durable owner: {TRACKER}")),
5893
+ )
5894
+ for description, payload in accepted:
5895
+ if payload.get("status") != "pass":
5896
+ report(
5897
+ f"{label}: {description} was refused. The rule is about the "
5898
+ "directory this change is about to move, not about change "
5899
+ "directories in general."
5900
+ )
5901
+ report(json.dumps(payload.get("problems", []), indent=2))
5902
+ return 1
5903
+
5904
+ # Every refusal that lists the forms says what checking each is worth.
5905
+ for description, payload in (
5906
+ ("an Invalidates refusal", invalidates("no closure at all")),
5907
+ ("an Expectation Coverage refusal", expectation("no closure at all")),
5908
+ ("a Findings refusal", completion("a narrative finding with no marker")),
5909
+ ):
5910
+ text = messages(payload)
5911
+ for expected in (
5912
+ "checked for existence when it is cited",
5913
+ "never fetches",
5914
+ ):
5915
+ if expected not in text:
5916
+ report(
5917
+ f"{label}: {description} does not say what the accepted "
5918
+ f"forms are worth — missing: {expected}. An author who "
5919
+ "cannot see what was checked infers that it was."
5920
+ )
5921
+ report(text or "(none)")
5922
+ return 1
4864
5923
 
5924
+ if label not in {name for name, _ in SCENARIOS}:
5925
+ report(f"{label}: the scenario registry does not include it.")
5926
+ return 1
4865
5927
  report(f"{label} scenario passed.")
4866
5928
  return 0
4867
5929
 
@@ -5003,14 +6065,18 @@ def validate_durable_owner_vocabulary_scenario() -> int:
5003
6065
  report(messages)
5004
6066
  return 1
5005
6067
 
5006
- # M3 — every previously accepted form still closes.
6068
+ # M3 — every previously accepted form still closes. The change artifact
6069
+ # names another live change: a path inside `demo`'s own directory is
6070
+ # refused now, because that directory moves when `demo` is archived.
5007
6071
  for closure in (
5008
- "Durable owner: openspec/changes/demo/proposal.md",
6072
+ "Durable owner: openspec/changes/other-change/proposal.md",
5009
6073
  "Durable owner: keel/archive/notes/2026-07-28-example.md",
5010
6074
  "Durable owner: https://github.com/TanglmChris/keel/issues/20",
5011
6075
  "Discard reason: it stands as written.",
5012
6076
  ):
5013
- write_text(repo / "openspec/changes/demo/proposal.md", "# Proposal\n")
6077
+ write_text(
6078
+ repo / "openspec/changes/other-change/proposal.md", "# Proposal\n"
6079
+ )
5014
6080
  payload = invalidation_start(closure)
5015
6081
  if payload.get("status") != "pass":
5016
6082
  report(f"{label} dropped a previously accepted form: {closure}")
@@ -8752,7 +9818,10 @@ def validate_spec_template_validates_scenario() -> int:
8752
9818
  )
8753
9819
  return 1
8754
9820
  if run_openspec(ROOT, "--version") is None:
8755
- report("spec-template-validates skipped: the openspec CLI is not on PATH.")
9821
+ report(
9822
+ "spec-template-validates skipped: the openspec CLI could not be "
9823
+ "found. Searched " + ", then ".join(OPENSPEC_SEARCH_ORDER) + "."
9824
+ )
8756
9825
  return 0
8757
9826
 
8758
9827
  filled = fill_template_slots(shipped.read_text(encoding="utf-8"))
@@ -10031,7 +11100,11 @@ def validate_review_entry_extent_scenario() -> int:
10031
11100
  one entry cannot be satisfied by another's text.
10032
11101
  """
10033
11102
  label = "review-entry-extent"
10034
- owner_path = "openspec/changes/demo/tasks.md"
11103
+ # Outside the selected change's own directory on purpose: a path under
11104
+ # `openspec/changes/demo/` is refused as a durable owner because that
11105
+ # directory moves at archive, and this scenario is about how far a wrapped
11106
+ # Findings entry extends, not about which owner forms are accepted.
11107
+ owner_path = "openspec/FOLLOWUP.md"
10035
11108
 
10036
11109
  with tempfile.TemporaryDirectory(
10037
11110
  prefix="keel-review-extent-", ignore_cleanup_errors=True
@@ -10039,6 +11112,7 @@ def validate_review_entry_extent_scenario() -> int:
10039
11112
  repo = Path(raw)
10040
11113
  tasks = repo / "openspec/changes/demo/tasks.md"
10041
11114
  write_text(repo / "openspec/changes/demo/proposal.md", "# Proposal\n")
11115
+ write_text(repo / owner_path, "# Follow-ups\n")
10042
11116
  write_text(
10043
11117
  repo / "openspec/changes/demo/specs/demo/spec.md",
10044
11118
  "## ADDED Requirements\n",
@@ -12701,13 +13775,42 @@ SUPPORTED_VERIFICATION_STRATEGIES = (
12701
13775
  )
12702
13776
 
12703
13777
 
12704
- def run_openspec(cwd: Path, *args: str) -> subprocess.CompletedProcess[str] | None:
12705
- executable = shutil.which("openspec")
13778
+ # `@fission-ai/openspec` is this package's own declared dependency, and
13779
+ # `npm install` puts an executable at `node_modules/.bin/openspec`. npm scripts
13780
+ # see that directory on PATH; a Python subprocess started by
13781
+ # `node scripts/run_python.js` does not — so a checkout that had installed
13782
+ # everything it declares reported `validation --all failed for:
13783
+ # compact-task-authoring` while the schema it could supposedly not resolve
13784
+ # resolved perfectly (issue #105).
13785
+ #
13786
+ # The declared dependency wins over PATH. It is the version this repository is
13787
+ # tested against; a global install that happens to be on PATH is a different
13788
+ # version answering for it.
13789
+ OPENSPEC_SEARCH_ORDER = (
13790
+ "the package's own node_modules/.bin",
13791
+ "PATH",
13792
+ )
13793
+
13794
+
13795
+ def resolve_openspec(env: dict[str, str] | None = None) -> str | None:
13796
+ local = ROOT / "node_modules" / ".bin" / "openspec"
13797
+ if local.is_file():
13798
+ return str(local)
13799
+ return shutil.which("openspec", path=(env or os.environ).get("PATH"))
13800
+
13801
+
13802
+ def run_openspec(
13803
+ cwd: Path,
13804
+ *args: str,
13805
+ env: dict[str, str] | None = None,
13806
+ ) -> subprocess.CompletedProcess[str] | None:
13807
+ executable = resolve_openspec(env)
12706
13808
  if executable is None:
12707
13809
  return None
12708
13810
  return subprocess.run(
12709
13811
  [executable, *args],
12710
13812
  cwd=cwd,
13813
+ env=env,
12711
13814
  text=True,
12712
13815
  encoding="utf-8",
12713
13816
  errors="replace",
@@ -14204,10 +15307,22 @@ def validate_compact_task_authoring_scenario() -> int:
14204
15307
  local_root = ROOT / "openspec" / "schemas" / OPENSPEC_SCHEMA_NAME
14205
15308
 
14206
15309
  which = run_openspec(ROOT, "schema", "which", OPENSPEC_SCHEMA_NAME, "--json")
14207
- if which is None or which.returncode != 0:
14208
- report("compact-task-authoring could not resolve the schema through OpenSpec.")
14209
- if which is not None:
14210
- report((which.stderr or which.stdout).strip())
15310
+ # Two distinct facts, kept apart. A CLI that was never found says nothing
15311
+ # about the schema, and reporting the schema as unresolvable sends the
15312
+ # reader to a subject with nothing wrong with it (issue #105).
15313
+ if which is None:
15314
+ report(
15315
+ "compact-task-authoring skipped: the openspec CLI could not be "
15316
+ "found. Searched " + ", then ".join(OPENSPEC_SEARCH_ORDER)
15317
+ + ". Run `npm install` to provide the declared dependency."
15318
+ )
15319
+ return 3
15320
+ if which.returncode != 0:
15321
+ report(
15322
+ "compact-task-authoring could not resolve the schema through "
15323
+ "OpenSpec. The CLI ran and refused:"
15324
+ )
15325
+ report((which.stderr or which.stdout).strip())
14211
15326
  return 1
14212
15327
  which_payload = json.loads(which.stdout[which.stdout.index("{"):])
14213
15328
  resolved = Path(which_payload.get("path", ""))
@@ -15556,7 +16671,13 @@ def validate_verification_layering_docs_scenario() -> int:
15556
16671
  return 0
15557
16672
 
15558
16673
 
15559
- STANDING_AUTHORIZATION_ACTIONS = ("commit", "push", "release", "archive")
16674
+ STANDING_AUTHORIZATION_ACTIONS = (
16675
+ "commit",
16676
+ "push",
16677
+ "release",
16678
+ "archive",
16679
+ "continuation",
16680
+ )
15560
16681
 
15561
16682
 
15562
16683
  def write_authorize_config(repo: Path, body: str) -> None:
@@ -15588,7 +16709,11 @@ def validate_standing_authorization_declaration_scenario() -> int:
15588
16709
  report(f"standing-authorization: declared action not reported: {needle}")
15589
16710
  report(out)
15590
16711
  return 1
15591
- for needle in ("release: not authorized", "archive: not authorized"):
16712
+ for needle in (
16713
+ "release: not authorized",
16714
+ "archive: not authorized",
16715
+ "continuation: not authorized",
16716
+ ):
15592
16717
  if needle not in out:
15593
16718
  report(f"standing-authorization: undeclared action not reported: {needle}")
15594
16719
  report(out)
@@ -18030,84 +19155,264 @@ def validate_standing_authorization_never_weakens_scenario() -> int:
18030
19155
  ),
18031
19156
  }
18032
19157
 
18033
- def pair(root: Path, name: str, tasks: str) -> tuple[Path, Path]:
18034
- authorizing = root / f"{name}-authorizing"
18035
- authorizing.mkdir()
18036
- write_gate_fixture(authorizing, tasks)
19158
+ def pair(root: Path, name: str, tasks: str) -> tuple[Path, Path]:
19159
+ authorizing = root / f"{name}-authorizing"
19160
+ authorizing.mkdir()
19161
+ write_gate_fixture(authorizing, tasks)
19162
+ write_authorize_config(
19163
+ authorizing,
19164
+ "authorize:\n - commit\n - push\n - release\n - archive\n"
19165
+ " - continuation\n",
19166
+ )
19167
+ silent = root / f"{name}-silent"
19168
+ silent.mkdir()
19169
+ write_gate_fixture(silent, tasks)
19170
+ # Positive control. Every check below compares these two repositories
19171
+ # and passes when they agree, so a declaration that silently failed to
19172
+ # reach the capsule would make each comparison trivially true and prove
19173
+ # nothing. Assert the difference exists before asserting it is inert.
19174
+ live = standing_authorization_autonomy(authorizing) or []
19175
+ inert = standing_authorization_autonomy(silent) or []
19176
+ if not any("keel/config.yaml" in entry for entry in live):
19177
+ report(
19178
+ f"standing-authorization-inert: the {name} authorizing fixture "
19179
+ f"never actually authorized anything: {live}"
19180
+ )
19181
+ raise AssertionError("authorizing fixture is not authorizing")
19182
+ if any("keel/config.yaml" in entry for entry in inert):
19183
+ report(
19184
+ f"standing-authorization-inert: the {name} silent fixture "
19185
+ f"declared something: {inert}"
19186
+ )
19187
+ raise AssertionError("silent fixture is not silent")
19188
+ return authorizing, silent
19189
+
19190
+ with tempfile.TemporaryDirectory(prefix="keel-authinert-") as raw_tmp:
19191
+ root = Path(raw_tmp)
19192
+
19193
+ # M1 — completion returns the same status and problem set either way.
19194
+ authorizing, silent = pair(root, "complete", complete_task)
19195
+ for repo in (authorizing, silent):
19196
+ if gate_result(repo, "task-start") is None:
19197
+ report("standing-authorization-inert: task-start produced no JSON.")
19198
+ return 1
19199
+ authorized_result = gate_result(authorizing, "task-complete")
19200
+ silent_result = gate_result(silent, "task-complete")
19201
+ if authorized_result is None or silent_result is None:
19202
+ report("standing-authorization-inert: task-complete produced no JSON.")
19203
+ return 1
19204
+ if authorized_result != silent_result:
19205
+ report(
19206
+ "standing-authorization-inert: a declaration changed the "
19207
+ f"completion gate result: {authorized_result} != {silent_result}"
19208
+ )
19209
+ return 1
19210
+
19211
+ # M2 — a repo authorizing every action still fails for missing evidence,
19212
+ # with unchanged failure text.
19213
+ authorizing, silent = pair(root, "missing", missing_evidence_task)
19214
+ for repo in (authorizing, silent):
19215
+ if gate_result(repo, "task-start") is None:
19216
+ report("standing-authorization-inert: task-start produced no JSON.")
19217
+ return 1
19218
+ authorized_result = gate_result(authorizing, "task-complete")
19219
+ silent_result = gate_result(silent, "task-complete")
19220
+ if authorized_result is None or silent_result is None:
19221
+ report("standing-authorization-inert: task-complete produced no JSON.")
19222
+ return 1
19223
+ if authorized_result.get("status") == "pass":
19224
+ report(
19225
+ "standing-authorization-inert: authorizing every action let a "
19226
+ "task with missing evidence pass completion."
19227
+ )
19228
+ return 1
19229
+ if authorized_result != silent_result:
19230
+ report(
19231
+ "standing-authorization-inert: a declaration changed the failure "
19232
+ f"text: {authorized_result} != {silent_result}"
19233
+ )
19234
+ return 1
19235
+
19236
+ # M3 — a declaration selects nothing and starts nothing.
19237
+ def continuity(repo: Path) -> dict | None:
19238
+ result = run_keel(repo, "context", "--json")
19239
+ try:
19240
+ payload = json.loads(result.stdout)
19241
+ except json.JSONDecodeError:
19242
+ return None
19243
+ return {
19244
+ "status": payload.get("status"),
19245
+ "selection": payload.get("selection"),
19246
+ "nextAction": payload.get("nextAction"),
19247
+ }
19248
+
19249
+ authorizing, silent = pair(root, "context", complete_task)
19250
+ authorized_context = continuity(authorizing)
19251
+ silent_context = continuity(silent)
19252
+ if authorized_context is None or silent_context is None:
19253
+ report("standing-authorization-inert: keel context produced no JSON.")
19254
+ return 1
19255
+ if authorized_context != silent_context:
19256
+ report(
19257
+ "standing-authorization-inert: a declaration changed continuity "
19258
+ f"selection: {authorized_context} != {silent_context}"
19259
+ )
19260
+ return 1
19261
+
19262
+ report("standing-authorization-never-weakens scenario passed.")
19263
+ return 0
19264
+
19265
+
19266
+ def validate_continuation_authorization_scenario() -> int:
19267
+ """`continuation` gives the between-task approval a durable home (#94).
19268
+
19269
+ The fifth vocabulary name has the same nature as the four before it:
19270
+ declared, it authorizes and an inheriting capsule names its source;
19271
+ undeclared, it authorizes nothing; and the declaration stays inert to
19272
+ every gate result and to continuity selection — it removes a
19273
+ confirmation, never a proof, and it never selects the next task itself.
19274
+ """
19275
+ label = "continuation-authorization"
19276
+
19277
+ complete_task = (
19278
+ "- [ ] 1.1 Behavior\n"
19279
+ " - Covers:\n"
19280
+ " - E1: public behavior\n"
19281
+ " - Touch:\n"
19282
+ " - src/feature.js\n"
19283
+ " - Verify:\n"
19284
+ " - Strategy: evidence-first\n"
19285
+ " - M1: node test.js proves the public behavior\n"
19286
+ " - Evidence:\n"
19287
+ " - Contract: pending\n"
19288
+ " - M1: node test.js printed ok\n"
19289
+ " - Review:\n"
19290
+ " - Status: pass\n"
19291
+ " - Acceptance check: reviewed\n"
19292
+ " - Scope check: reviewed\n"
19293
+ " - Findings: none\n"
19294
+ " - Blocker: none\n"
19295
+ )
19296
+
19297
+ def gate_result(repo: Path, stage: str) -> dict | None:
19298
+ result = run_keel(
19299
+ repo, "gate", stage, "--change", "demo", "--task", "1.1", "--json"
19300
+ )
19301
+ try:
19302
+ payload = json.loads(result.stdout)
19303
+ except json.JSONDecodeError:
19304
+ return None
19305
+ return {
19306
+ "status": payload.get("status"),
19307
+ "problems": sorted(
19308
+ (problem.get("code", ""), problem.get("message", ""))
19309
+ for problem in payload.get("problems") or []
19310
+ ),
19311
+ }
19312
+
19313
+ with tempfile.TemporaryDirectory(prefix="keel-continuation-") as raw_tmp:
19314
+ root = Path(raw_tmp)
19315
+
19316
+ # M1 — declared alone, `continuation` is authorized and no repository
19317
+ # action rides along with it: commit, push, release, and archive each
19318
+ # still require their own name.
19319
+ declared = root / "declared"
19320
+ declared.mkdir()
19321
+ write_authorize_config(declared, "authorize:\n - continuation\n")
19322
+ result = run_keel(declared, "--doctor")
19323
+ if result.returncode != 0:
19324
+ report(f"{label}: a continuation-only declaration was refused.")
19325
+ report(result.stdout + result.stderr)
19326
+ return 1
19327
+ if "continuation: authorized" not in result.stdout:
19328
+ report(f"{label}: a declared continuation was not reported authorized.")
19329
+ report(result.stdout)
19330
+ return 1
19331
+ for needle in (
19332
+ "commit: not authorized",
19333
+ "push: not authorized",
19334
+ "release: not authorized",
19335
+ "archive: not authorized",
19336
+ ):
19337
+ if needle not in result.stdout:
19338
+ report(f"{label}: undeclared action not reported: {needle}")
19339
+ report(result.stdout)
19340
+ return 1
19341
+
19342
+ # M1 — undeclared, it is reported beside the four exactly as any
19343
+ # unlisted action is.
19344
+ four = root / "four"
19345
+ four.mkdir()
18037
19346
  write_authorize_config(
18038
- authorizing,
18039
- "authorize:\n - commit\n - push\n - release\n - archive\n",
19347
+ four, "authorize:\n - commit\n - push\n - release\n - archive\n"
18040
19348
  )
18041
- silent = root / f"{name}-silent"
18042
- silent.mkdir()
18043
- write_gate_fixture(silent, tasks)
18044
- # Positive control. Every check below compares these two repositories
18045
- # and passes when they agree, so a declaration that silently failed to
18046
- # reach the capsule would make each comparison trivially true and prove
18047
- # nothing. Assert the difference exists before asserting it is inert.
18048
- live = standing_authorization_autonomy(authorizing) or []
18049
- inert = standing_authorization_autonomy(silent) or []
18050
- if not any("keel/config.yaml" in entry for entry in live):
19349
+ out = run_keel(four, "--doctor").stdout
19350
+ if "continuation: not authorized" not in out:
19351
+ report(f"{label}: an undeclared continuation was not reported.")
19352
+ report(out)
19353
+ return 1
19354
+
19355
+ # M1 a task that authored no boundary inherits it, the capsule names
19356
+ # the declaration as its source, and undeclared actions keep the
19357
+ # hard-stop default.
19358
+ write_gate_fixture(declared, standing_authorization_task())
19359
+ autonomy = standing_authorization_autonomy(declared)
19360
+ if autonomy is None:
19361
+ report(f"{label}: task-start returned no capsule autonomy.")
19362
+ return 1
19363
+ inherited = [entry for entry in autonomy if "continuation" in entry]
19364
+ if not inherited:
18051
19365
  report(
18052
- f"standing-authorization-inert: the {name} authorizing fixture "
18053
- f"never actually authorized anything: {live}"
19366
+ f"{label}: continuation did not reach the capsule autonomy: "
19367
+ f"{autonomy}"
18054
19368
  )
18055
- raise AssertionError("authorizing fixture is not authorizing")
18056
- if any("keel/config.yaml" in entry for entry in inert):
19369
+ return 1
19370
+ if not any("keel/config.yaml" in entry for entry in inherited):
18057
19371
  report(
18058
- f"standing-authorization-inert: the {name} silent fixture "
18059
- f"declared something: {inert}"
19372
+ f"{label}: the inherited entry does not name its source: "
19373
+ f"{autonomy}"
18060
19374
  )
18061
- raise AssertionError("silent fixture is not silent")
18062
- return authorizing, silent
18063
-
18064
- with tempfile.TemporaryDirectory(prefix="keel-authinert-") as raw_tmp:
18065
- root = Path(raw_tmp)
18066
-
18067
- # M1 — completion returns the same status and problem set either way.
18068
- authorizing, silent = pair(root, "complete", complete_task)
18069
- for repo in (authorizing, silent):
18070
- if gate_result(repo, "task-start") is None:
18071
- report("standing-authorization-inert: task-start produced no JSON.")
18072
- return 1
18073
- authorized_result = gate_result(authorizing, "task-complete")
18074
- silent_result = gate_result(silent, "task-complete")
18075
- if authorized_result is None or silent_result is None:
18076
- report("standing-authorization-inert: task-complete produced no JSON.")
18077
19375
  return 1
18078
- if authorized_result != silent_result:
19376
+ if not any(entry.startswith("Default: hard-stop") for entry in autonomy):
18079
19377
  report(
18080
- "standing-authorization-inert: a declaration changed the "
18081
- f"completion gate result: {authorized_result} != {silent_result}"
19378
+ f"{label}: undeclared actions lost the hard-stop default: "
19379
+ f"{autonomy}"
18082
19380
  )
18083
19381
  return 1
18084
19382
 
18085
- # M2a repo authorizing every action still fails for missing evidence,
18086
- # with unchanged failure text.
18087
- authorizing, silent = pair(root, "missing", missing_evidence_task)
18088
- for repo in (authorizing, silent):
19383
+ # M1inert to gates and selection: against an otherwise identical
19384
+ # silent repository, gate results and continuity are equal. The
19385
+ # capsule check above is the positive control — the declaration
19386
+ # demonstrably reached the capsule, so equality is not trivially true.
19387
+ inert_declared = root / "inert-declared"
19388
+ inert_declared.mkdir()
19389
+ write_gate_fixture(inert_declared, complete_task)
19390
+ write_authorize_config(inert_declared, "authorize:\n - continuation\n")
19391
+ inert_silent = root / "inert-silent"
19392
+ inert_silent.mkdir()
19393
+ write_gate_fixture(inert_silent, complete_task)
19394
+ if any(
19395
+ "keel/config.yaml" in entry
19396
+ for entry in standing_authorization_autonomy(inert_silent) or []
19397
+ ):
19398
+ report(f"{label}: the silent fixture declared something.")
19399
+ return 1
19400
+ for repo in (inert_declared, inert_silent):
18089
19401
  if gate_result(repo, "task-start") is None:
18090
- report("standing-authorization-inert: task-start produced no JSON.")
19402
+ report(f"{label}: task-start produced no JSON.")
18091
19403
  return 1
18092
- authorized_result = gate_result(authorizing, "task-complete")
18093
- silent_result = gate_result(silent, "task-complete")
18094
- if authorized_result is None or silent_result is None:
18095
- report("standing-authorization-inert: task-complete produced no JSON.")
18096
- return 1
18097
- if authorized_result.get("status") == "pass":
18098
- report(
18099
- "standing-authorization-inert: authorizing every action let a "
18100
- "task with missing evidence pass completion."
18101
- )
19404
+ declared_result = gate_result(inert_declared, "task-complete")
19405
+ silent_result = gate_result(inert_silent, "task-complete")
19406
+ if declared_result is None or silent_result is None:
19407
+ report(f"{label}: task-complete produced no JSON.")
18102
19408
  return 1
18103
- if authorized_result != silent_result:
19409
+ if declared_result != silent_result:
18104
19410
  report(
18105
- "standing-authorization-inert: a declaration changed the failure "
18106
- f"text: {authorized_result} != {silent_result}"
19411
+ f"{label}: the declaration changed a gate result: "
19412
+ f"{declared_result} != {silent_result}"
18107
19413
  )
18108
19414
  return 1
18109
19415
 
18110
- # M3 — a declaration selects nothing and starts nothing.
18111
19416
  def continuity(repo: Path) -> dict | None:
18112
19417
  result = run_keel(repo, "context", "--json")
18113
19418
  try:
@@ -18120,20 +19425,95 @@ def validate_standing_authorization_never_weakens_scenario() -> int:
18120
19425
  "nextAction": payload.get("nextAction"),
18121
19426
  }
18122
19427
 
18123
- authorizing, silent = pair(root, "context", complete_task)
18124
- authorized_context = continuity(authorizing)
18125
- silent_context = continuity(silent)
18126
- if authorized_context is None or silent_context is None:
18127
- report("standing-authorization-inert: keel context produced no JSON.")
19428
+ declared_context = continuity(inert_declared)
19429
+ silent_context = continuity(inert_silent)
19430
+ if declared_context is None or silent_context is None:
19431
+ report(f"{label}: keel context produced no JSON.")
18128
19432
  return 1
18129
- if authorized_context != silent_context:
19433
+ if declared_context != silent_context:
18130
19434
  report(
18131
- "standing-authorization-inert: a declaration changed continuity "
18132
- f"selection: {authorized_context} != {silent_context}"
19435
+ f"{label}: the declaration changed continuity selection: "
19436
+ f"{declared_context} != {silent_context}"
18133
19437
  )
18134
19438
  return 1
18135
19439
 
18136
- report("standing-authorization-never-weakens scenario passed.")
19440
+ report("continuation-authorization scenario passed.")
19441
+ return 0
19442
+
19443
+
19444
+ def validate_continuation_docs_scenario() -> int:
19445
+ """Every text an agent reads at the between-task boundary names the word (#94).
19446
+
19447
+ The declaration only works if the boundary's readers know it exists: the
19448
+ goal skill's stop rule is what an agent obeys at the boundary, the README
19449
+ and the config comment are where an owner learns the vocabulary, and the
19450
+ resident protocol is what the enforcing agent holds in context.
19451
+ """
19452
+ label = "continuation-docs"
19453
+
19454
+ src_skill = (
19455
+ ROOT / "src/skills/keel-run-single-task-goal/SKILL.md"
19456
+ ).read_text(encoding="utf-8")
19457
+ plugin_skill = (
19458
+ ROOT / "plugins/keel/skills/keel-run-single-task-goal/SKILL.md"
19459
+ ).read_text(encoding="utf-8")
19460
+ if src_skill != plugin_skill:
19461
+ report(f"{label}: the src/ and plugins/ skill copies diverge.")
19462
+ return 1
19463
+
19464
+ step7 = next(
19465
+ (line for line in plugin_skill.splitlines() if line.startswith("7. ")), ""
19466
+ )
19467
+ if not step7.startswith("7. Stop."):
19468
+ report(f"{label}: the goal skill lost its stop step: {step7!r}")
19469
+ return 1
19470
+ for needle in (
19471
+ "standing `continuation` authorization",
19472
+ "keel/config.yaml",
19473
+ "next unchecked task of the same change",
19474
+ "its own recorded fingerprint",
19475
+ "no hidden scheduler",
19476
+ ):
19477
+ if needle not in step7:
19478
+ report(f"{label}: the stop rule lacks: {needle}")
19479
+ report(step7)
19480
+ return 1
19481
+
19482
+ readme = (ROOT / "README.md").read_text(encoding="utf-8")
19483
+ for needle in (
19484
+ "accepted names: commit, push, release, archive, continuation",
19485
+ "next unchecked task of the same change",
19486
+ "the stop that re-asks for an approval already given",
19487
+ "The five names above are the whole vocabulary.",
19488
+ ):
19489
+ if needle not in readme:
19490
+ report(f"{label}: README.md lacks: {needle}")
19491
+ return 1
19492
+
19493
+ config_text = (ROOT / "keel/config.yaml").read_text(encoding="utf-8")
19494
+ if "commit, push, release, archive,\n# continuation" not in config_text:
19495
+ report(
19496
+ f"{label}: keel/config.yaml's comment does not name the five-name "
19497
+ "vocabulary."
19498
+ )
19499
+ return 1
19500
+
19501
+ agents = (ROOT / "AGENTS.md").read_text(encoding="utf-8")
19502
+ parts = agents.split("## Execution boundary", 1)
19503
+ if len(parts) != 2:
19504
+ report(f"{label}: AGENTS.md lost its Execution boundary section.")
19505
+ return 1
19506
+ section = parts[1].split("\n## ", 1)[0]
19507
+ for needle in (
19508
+ "standing `continuation` authorization",
19509
+ "next unchecked task of the same change",
19510
+ "its own recorded fingerprint",
19511
+ ):
19512
+ if needle not in section:
19513
+ report(f"{label}: AGENTS.md Execution boundary lacks: {needle}")
19514
+ return 1
19515
+
19516
+ report("continuation-docs scenario passed.")
18137
19517
  return 0
18138
19518
 
18139
19519
 
@@ -22270,8 +23650,11 @@ def validate_decimal_runs_are_not_hash_shaped_scenario() -> int:
22270
23650
  )
22271
23651
  # The other half of the requirement: what the rule exists to refuse.
22272
23652
  refused_token = "- M4: pass —— 合入前的 commit a1b2c3d4e5f6 已验证。\n"
22273
- # Wording alone, carrying no hash-shaped token at all.
22274
- refused_wording = "- M5: 该任务**未提交**,等待评审。\n"
23653
+ # Wording alone, carrying no hash-shaped token at all. The line names 分支
23654
+ # because 提交 on its own is an ordinary verb and no longer refused (#103);
23655
+ # what this fixture is for is the absence of a token, not the absence of
23656
+ # context.
23657
+ refused_wording = "- M5: 该分支**未提交**,等待评审。\n"
22275
23658
 
22276
23659
  def state_of(check) -> str:
22277
23660
  if "keel state: ok" in check.stdout:
@@ -22494,12 +23877,17 @@ def validate_a_context_word_is_a_word_scenario() -> int:
22494
23877
 
22495
23878
  # The Chinese words carry no boundary, because none is definable
22496
23879
  # between two word characters. Asserted here rather than reasoned
22497
- # about: `\b提交\b` matches none of these.
22498
- for recorded in ("已提交", "未提交", "该任务尚未提交,等待评审"):
23880
+ # about: `\b提交\b` matches none of these. What the absence buys is
23881
+ # the context supply below — each line holds a hash-shaped token and
23882
+ # no other context word, so the Chinese word is the only thing that
23883
+ # can make the token an identifier. The wording rule no longer rests
23884
+ # on this: 提交 alone is an ordinary verb and needs a git word of its
23885
+ # own, which `a-submission-is-not-a-commit` covers.
23886
+ for recorded in ("已提交", "未提交", "该任务尚未提交"):
22499
23887
  state, _ = check(
22500
23888
  "- [x] A1 implementation\n"
22501
23889
  " - Evidence:\n"
22502
- f" - M1: {recorded}。\n"
23890
+ f" - M1: {recorded} a1b2c3d4e5f6。\n"
22503
23891
  )
22504
23892
  if state == "unreported":
22505
23893
  report(
@@ -22510,12 +23898,240 @@ def validate_a_context_word_is_a_word_scenario() -> int:
22510
23898
  return 1
22511
23899
  if state != "failed":
22512
23900
  report(
22513
- f"{label}: recorded state written as `{recorded}` was "
22514
- "accepted. A word boundary around a Chinese context word "
22515
- "disables it silently, which is why it does not carry one."
23901
+ f"{label}: an identifier beside `{recorded}` was accepted. "
23902
+ "A word boundary around a Chinese context word disables it "
23903
+ "silently, which is why it does not carry one."
23904
+ )
23905
+ return 1
23906
+
23907
+ if label not in {name for name, _ in SCENARIOS}:
23908
+ report(f"{label}: the scenario registry does not include it.")
23909
+ return 1
23910
+ report(f"{label} scenario passed.")
23911
+ return 0
23912
+
23913
+
23914
+ def validate_a_submission_is_not_a_commit_scenario() -> int:
23915
+ """Issue #103: a general Chinese verb was read as a git word.
23916
+
23917
+ \u63d0\u4ea4 is an ordinary transitive verb — \u63d0\u4ea4\u8d44\u6599, \u63d0\u4ea4\u5ba1\u6838, \u63d0\u4ea4\u7533\u8bf7. Matched bare, it
23918
+ refused an Evidence line recording that a user had submitted paperwork to
23919
+ a third-party review queue, while the same fact written in English passed
23920
+ untouched. A check whose verdict depends on which language the author
23921
+ wrote in is not checking what it claims to.
23922
+
23923
+ \u5408\u5165 is the other half and stays bare: it names the git act and has no
23924
+ ordinary-prose reading, so requiring context for it would buy nothing.
23925
+ """
23926
+ label = "a-submission-is-not-a-commit"
23927
+
23928
+ with tempfile.TemporaryDirectory(prefix="keel-submission-") as raw:
23929
+ check, failure = _tasks_semantics_probe(raw)
23930
+ if failure is not None:
23931
+ report(f"{label}: keel --install failed while building the fixture.")
23932
+ report(failure)
23933
+ return 1
23934
+
23935
+ def evidence(text: str) -> str:
23936
+ return (
23937
+ "- [x] A1 implementation\n"
23938
+ " - Evidence:\n"
23939
+ f" - M1: {text}\n"
23940
+ )
23941
+
23942
+ # The report's own line, and its English translation. Neither says
23943
+ # anything about this repository's git state, and the check must not
23944
+ # split them.
23945
+ accepted = (
23946
+ (
23947
+ "the reported line",
23948
+ "\u6536\u5de5\u72b6\u6001\u4e00\u5e76\u767b\u8bb0\uff1a\u7528\u6237\u5f53\u5929**\u5df2\u63d0\u4ea4**\u80fd\u586b\u7684\u5546\u6237\u4e0e\u7ed3\u7b97\u8d44\u6599\u3002",
23949
+ ),
23950
+ (
23951
+ "its English translation",
23952
+ "the user has submitted the merchant paperwork for review.",
23953
+ ),
23954
+ (
23955
+ "a submission to a review queue",
23956
+ "\u8868\u5355**\u5df2\u63d0\u4ea4**\uff0c\u5f00\u53d1\u8005\u8d26\u6237\u5904\u4e8e\u5ba1\u6838\u4e2d\u3002",
23957
+ ),
23958
+ )
23959
+ for description, text in accepted:
23960
+ state, errors = check(evidence(text))
23961
+ if state == "unreported":
23962
+ report(
23963
+ f"{label}: keel --check reported no state at all on "
23964
+ f"{description}."
23965
+ )
23966
+ return 1
23967
+ if state != "ok":
23968
+ report(
23969
+ f"{label}: {description} was refused as recorded commit "
23970
+ "state. Nothing on that line names git, and the same "
23971
+ "sentence in English is accepted — so what the check "
23972
+ "measured was the language, not the claim."
23973
+ )
23974
+ for error in errors:
23975
+ report(f" {error}")
23976
+ return 1
23977
+
23978
+ # What the rule exists to catch, kept refused. Each of these carries a
23979
+ # git word, so the subject of the verb is not in doubt.
23980
+ refused = (
23981
+ ("beside a branch name", "1.2 \u7684\u6539\u52a8**\u5df2\u63d0\u4ea4**\u5230 main\u3002"),
23982
+ ("beside \u5206\u652f", "\u8be5\u5206\u652f\u7684\u6539\u52a8**\u5c1a\u672a\u63d0\u4ea4**\uff0c\u7b49\u5f85\u8bc4\u5ba1\u3002"),
23983
+ ("beside \u4ee3\u7801", "\u4ee3\u7801**\u5df2\u63d0\u4ea4**\uff0c\u6587\u6863\u672a\u52a8\u3002"),
23984
+ ("\u5408\u5165 alone", "\u8fd9\u4e00\u6ce2**\u672a\u5408\u5165**\uff0c\u4e0b\u5468\u518d\u8bf4\u3002"),
23985
+ ("\u5df2\u5408\u5165 alone", "\u8be5\u6539\u52a8**\u5df2\u5408\u5165**\u3002"),
23986
+ )
23987
+ for description, text in refused:
23988
+ state, errors = check(evidence(text))
23989
+ if state == "unreported":
23990
+ report(
23991
+ f"{label}: keel --check reported no state at all on a "
23992
+ f"line {description}."
23993
+ )
23994
+ return 1
23995
+ if state != "failed":
23996
+ report(
23997
+ f"{label}: recorded state {description} was accepted. "
23998
+ "Requiring context must narrow which lines are read, not "
23999
+ "which words the rule knows."
24000
+ )
24001
+ return 1
24002
+ if not [error for error in errors if "tasks.md:" in error]:
24003
+ report(f"{label}: the refusal {description} named no line.")
24004
+ return 1
24005
+
24006
+ # The contextual-identifier rule is untouched: a Chinese state word
24007
+ # still supplies context to a hash-shaped token, with no other context
24008
+ # word on the line.
24009
+ state, _ = check(evidence("\u5df2\u63d0\u4ea4 a1b2c3d4e5f6 \u5f85\u9a8c\u3002"))
24010
+ if state != "failed":
24011
+ report(
24012
+ f"{label}: a hash-shaped token beside a Chinese state word "
24013
+ "was accepted. This change bounds the wording rule, not the "
24014
+ "contextual-identifier rule."
24015
+ )
24016
+ return 1
24017
+
24018
+ if label not in {name for name, _ in SCENARIOS}:
24019
+ report(f"{label}: the scenario registry does not include it.")
24020
+ return 1
24021
+ report(f"{label} scenario passed.")
24022
+ return 0
24023
+
24024
+
24025
+ def validate_a_quoted_span_is_not_a_claim_scenario() -> int:
24026
+ """Issue #65 §2 and §4: quoted material was read as an assertion.
24027
+
24028
+ Evidence prose quotes what it is evidence of — the command that ran, the
24029
+ output it printed, the branch base it ran against, the name of the
24030
+ requirement under change. `withoutInlineCode()` in
24031
+ `src/core/task-contract.js` already settled that shape for the field
24032
+ reader; this check never had it, so a Scope check recording that it ran
24033
+ `git status --short` was refused for the word inside the backticks.
24034
+
24035
+ The boundary matters as much as the exemption: quoting one token must not
24036
+ exempt the sentence around it, or the rule becomes optional.
24037
+ """
24038
+ label = "a-quoted-span-is-not-a-claim"
24039
+
24040
+ with tempfile.TemporaryDirectory(prefix="keel-quoted-span-") as raw:
24041
+ check, failure = _tasks_semantics_probe(raw)
24042
+ if failure is not None:
24043
+ report(f"{label}: keel --install failed while building the fixture.")
24044
+ report(failure)
24045
+ return 1
24046
+
24047
+ # Each of these carries its whole match inside a quoted span. Nothing
24048
+ # outside the quotes on any of these lines is a claim about this
24049
+ # repository's git state.
24050
+ quoted = (
24051
+ (
24052
+ "inline code holding quoted output",
24053
+ "- [x] A1 implementation\n"
24054
+ " - Evidence:\n"
24055
+ " - M1.red: fail, as required. The runner printed "
24056
+ "`fatal: you have uncommitted changes`.\n",
24057
+ ),
24058
+ (
24059
+ "inline code holding an identifier and its context word",
24060
+ "- [x] A1 implementation\n"
24061
+ " - Evidence:\n"
24062
+ " - M1: pass. The fixture repository reported "
24063
+ "`HEAD is at 3f2a9bc` before the run.\n",
24064
+ ),
24065
+ (
24066
+ "a fenced block holding output",
24067
+ "- [x] A1 implementation\n"
24068
+ " - Evidence:\n"
24069
+ " - M1: pass. The runner printed:\n"
24070
+ " ```\n"
24071
+ " HEAD is at 3f2a9bc, working tree uncommitted\n"
24072
+ " ```\n",
24073
+ ),
24074
+ (
24075
+ "a quotation span holding a requirement name",
24076
+ "- [x] A1 implementation\n"
24077
+ " - Evidence:\n"
24078
+ " - M1: pass. This task renames \u201cA recorded commit "
24079
+ "hash is recognized by what makes it one\u201d.\n",
24080
+ ),
24081
+ )
24082
+ for description, body in quoted:
24083
+ state, errors = check(body)
24084
+ if state == "unreported":
24085
+ report(
24086
+ f"{label}: keel --check reported no state at all on "
24087
+ f"{description}. This is not a verdict about the fixture — "
24088
+ "the check did not reach the point of having one."
24089
+ )
24090
+ return 1
24091
+ if state != "ok":
24092
+ report(
24093
+ f"{label}: {description} was read as a claim. A quoted "
24094
+ "span is content the author cites, and the only repair "
24095
+ "open to them is to stop quoting it accurately."
22516
24096
  )
24097
+ for error in errors:
24098
+ report(f" {error}")
22517
24099
  return 1
22518
24100
 
24101
+ # The boundary. Quoting one token exempts that token and nothing else.
24102
+ state, errors = check(
24103
+ "- [x] A1 implementation\n"
24104
+ " - Evidence:\n"
24105
+ " - M1: verified against `3f2a9bc`, but the work is still "
24106
+ "uncommitted.\n"
24107
+ )
24108
+ if state != "failed":
24109
+ report(
24110
+ f"{label}: wording outside a quoted span was exempted. The "
24111
+ "span is what stops being read, not the line holding it."
24112
+ )
24113
+ return 1
24114
+ if not [error for error in errors if "tasks.md:" in error]:
24115
+ report(f"{label}: the refusal outside the span named no line.")
24116
+ return 1
24117
+
24118
+ # An apostrophe is not a quotation delimiter. Treating it as one would
24119
+ # silence the remainder of any line carrying a contraction, which is
24120
+ # the failure mode that is invisible rather than wrong.
24121
+ state, errors = check(
24122
+ "- [x] A1 implementation\n"
24123
+ " - Evidence:\n"
24124
+ " - M1: the guard doesn't fire here, and the worktree is "
24125
+ "uncommitted.\n"
24126
+ )
24127
+ if state != "failed":
24128
+ report(
24129
+ f"{label}: a contraction opened a quotation span and silenced "
24130
+ "the rest of the line. The ASCII single quote is an "
24131
+ "apostrophe far more often than it is a quotation mark."
24132
+ )
24133
+ return 1
24134
+
22519
24135
  if label not in {name for name, _ in SCENARIOS}:
22520
24136
  report(f"{label}: the scenario registry does not include it.")
22521
24137
  return 1
@@ -22622,7 +24238,7 @@ def validate_a_covers_citation_is_not_a_record_scenario() -> int:
22622
24238
  " - Covers:\n"
22623
24239
  f" - {cited[0]}\n"
22624
24240
  " - Verify:\n"
22625
- " - M1: the change is 已提交 and needs no further work.\n"
24241
+ " - M1: the change is 已合入 and needs no further work.\n"
22626
24242
  )
22627
24243
  if state == "unreported":
22628
24244
  report(
@@ -22989,6 +24605,14 @@ SCENARIOS: tuple = (
22989
24605
  "unparsed-covers-critical-statement",
22990
24606
  validate_unparsed_covers_critical_statement_scenario,
22991
24607
  ),
24608
+ (
24609
+ "widened-critical-statement-shapes",
24610
+ validate_widened_critical_statement_shapes_scenario,
24611
+ ),
24612
+ (
24613
+ "covers-annotation-entry",
24614
+ validate_covers_annotation_entry_scenario,
24615
+ ),
22992
24616
  ("expectation-completion-gates", validate_expectation_completion_gates_scenario),
22993
24617
  ("authoring-continuity", validate_authoring_continuity_scenario),
22994
24618
  ("domain-lenses", validate_domain_lenses_scenario),
@@ -23026,6 +24650,11 @@ SCENARIOS: tuple = (
23026
24650
  "standing-authorization-never-weakens",
23027
24651
  validate_standing_authorization_never_weakens_scenario,
23028
24652
  ),
24653
+ (
24654
+ "continuation-authorization",
24655
+ validate_continuation_authorization_scenario,
24656
+ ),
24657
+ ("continuation-docs", validate_continuation_docs_scenario),
23029
24658
  (
23030
24659
  "precedent-store-declaration",
23031
24660
  validate_precedent_store_declaration_scenario,
@@ -23236,7 +24865,36 @@ SCENARIOS: tuple = (
23236
24865
  "decimal-runs-are-not-hash-shaped",
23237
24866
  validate_decimal_runs_are_not_hash_shaped_scenario,
23238
24867
  ),
24868
+ (
24869
+ "an-owner-outlives-the-change",
24870
+ validate_an_owner_outlives_the_change_scenario,
24871
+ ),
24872
+ ("a-root-file-is-a-path", validate_a_root_file_is_a_path_scenario),
24873
+ (
24874
+ "a-declared-dependency-is-resolved",
24875
+ validate_a_declared_dependency_is_resolved_scenario,
24876
+ ),
24877
+ (
24878
+ "the-tarball-is-the-repository",
24879
+ validate_the_tarball_is_the_repository_scenario,
24880
+ ),
24881
+ (
24882
+ "an-invalidates-phrase-may-wrap",
24883
+ validate_an_invalidates_phrase_may_wrap_scenario,
24884
+ ),
24885
+ (
24886
+ "the-spec-names-the-managed-set",
24887
+ validate_the_spec_names_the_managed_set_scenario,
24888
+ ),
23239
24889
  ("a-context-word-is-a-word", validate_a_context_word_is_a_word_scenario),
24890
+ (
24891
+ "a-quoted-span-is-not-a-claim",
24892
+ validate_a_quoted_span_is_not_a_claim_scenario,
24893
+ ),
24894
+ (
24895
+ "a-submission-is-not-a-commit",
24896
+ validate_a_submission_is_not_a_commit_scenario,
24897
+ ),
23240
24898
  (
23241
24899
  "a-covers-citation-is-not-a-record",
23242
24900
  validate_a_covers_citation_is_not_a_record_scenario,