@christang/keel 5.3.6 → 5.3.8

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.3.6"
41
- PROTOCOL_VERSION = "5.3.6"
40
+ PACKAGE_VERSION = "5.3.8"
41
+ PROTOCOL_VERSION = "5.3.8"
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
@@ -1307,7 +1307,11 @@ def validate_expectation_slice_gates_scenario() -> int:
1307
1307
  "Covers:",
1308
1308
  "source expectation",
1309
1309
  "D<n>/F<n>/A<n>/Q<n>",
1310
- "unresolved Q<n>",
1310
+ # The rule is no longer "an unresolved Q<n> requires a fallback"
1311
+ # unconditionally: the identifier blocks only where it opens the entry,
1312
+ # so a resolved question can be cited beside the fact that closed it.
1313
+ "OPENS an entry",
1314
+ "requires an authorized fallback",
1311
1315
  ]
1312
1316
  design_template_snippets = [
1313
1317
  "D<n>",
@@ -3505,6 +3509,11 @@ def task_contract_fixture(
3505
3509
  " - Stop Rules:\n"
3506
3510
  " - Stop on failure.\n"
3507
3511
  " - Evidence:\n"
3512
+ # Emitted unconditionally rather than through `evidence`, which callers
3513
+ # override to control the M-entries. Completion requires a recorded
3514
+ # fingerprint, and `--record` rewrites this line in place, so a caller
3515
+ # that customizes its evidence must not be able to drop the anchor.
3516
+ " - Contract: pending\n"
3508
3517
  f"{evidence_lines}"
3509
3518
  " - Review:\n"
3510
3519
  " - Status: pending\n"
@@ -3541,7 +3550,7 @@ def invalidation_repo(root: Path, name: str, section: str | None) -> Path:
3541
3550
  # A real Contract anchor, so the missing-section case can prove that a
3542
3551
  # failing authoring gate leaves the anchor untouched rather than merely
3543
3552
  # failing earlier for want of one.
3544
- body = task_contract_fixture(evidence=("Contract: pending", "M1: pending"))
3553
+ body = task_contract_fixture()
3545
3554
  body = body.replace(INVALIDATES_NONE, "" if section is None else section)
3546
3555
  write_text(repo / "openspec/changes/demo/tasks.md", body)
3547
3556
  return repo
@@ -3777,7 +3786,7 @@ def validate_anchor_reverification_bound_scenario() -> int:
3777
3786
  repo = Path(raw_tmp) / "repo"
3778
3787
  repo.mkdir()
3779
3788
  live = repo / "openspec/changes/demo/tasks.md"
3780
- write_text(live, task_contract_fixture(evidence=("Contract: pending", "M1: pending")))
3789
+ write_text(live, task_contract_fixture())
3781
3790
 
3782
3791
  recorded = run_keel(
3783
3792
  repo, "gate", "task-start", "--change", "demo", "--task", "1.1",
@@ -3989,6 +3998,7 @@ def validate_durable_owner_vocabulary_scenario() -> int:
3989
3998
  )
3990
3999
  )
3991
4000
  write_text(tasks_path, fixture)
4001
+ record_contract_anchor(repo, "demo")
3992
4002
  result = run_keel(
3993
4003
  repo, "gate", "task-complete", "--change", "demo", "--task", "1.1", "--json"
3994
4004
  )
@@ -4134,6 +4144,7 @@ def validate_regression_check_tag_scenario() -> int:
4134
4144
 
4135
4145
  def complete(fixture: str) -> dict:
4136
4146
  write_text(tasks_path, fixture)
4147
+ record_contract_anchor(repo, "demo")
4137
4148
  result = run_keel(
4138
4149
  repo, "gate", "task-complete", "--change", "demo", "--task", "1.1", "--json"
4139
4150
  )
@@ -4650,12 +4661,39 @@ def task_capsule_compact_fixture() -> str:
4650
4661
  " - Stop Rules:\n"
4651
4662
  " - Stop on failure.\n"
4652
4663
  " - Evidence:\n"
4664
+ # Completion requires a recorded fingerprint here, so the anchor line
4665
+ # must exist for `--record` to rewrite in place. See
4666
+ # record_contract_anchor.
4667
+ " - Contract: pending\n"
4653
4668
  " - M1: pending\n"
4654
4669
  " - Stop if:\n"
4655
4670
  " - Requires files outside Touch.\n"
4656
4671
  )
4657
4672
 
4658
4673
 
4674
+ def record_contract_anchor(repo: Path, change: str, task: str = "1.1") -> bool:
4675
+ """Run `task-start --record` so a fixture can reach completion.
4676
+
4677
+ Completion refuses a task whose `Contract` anchor holds no compiled
4678
+ fingerprint (issue #30), so a scenario that only wanted to exercise
4679
+ task-complete still has to start the task first. That is the real loop, not
4680
+ a workaround: a task that was never started is not a task being completed.
4681
+ """
4682
+ result = run_keel(
4683
+ repo,
4684
+ "gate",
4685
+ "task-start",
4686
+ "--change",
4687
+ change,
4688
+ "--task",
4689
+ task,
4690
+ "--record",
4691
+ "--no-guard",
4692
+ "--json",
4693
+ )
4694
+ return result.returncode == 0
4695
+
4696
+
4659
4697
  def validate_non_concrete_verify_diagnostic_scenario() -> int:
4660
4698
  """A compact v4 task whose Verify carries an unfilled token must be told so.
4661
4699
 
@@ -4729,8 +4767,10 @@ def validate_non_concrete_verify_diagnostic_scenario() -> int:
4729
4767
  )
4730
4768
  report(started.stdout.strip())
4731
4769
  return 1
4732
- # A task with no Verify at all is a genuine expanded v3 task and must
4733
- # keep its existing required-field diagnostics.
4770
+ # A task with no Verify at all must not be reported as carrying a token
4771
+ # it never wrote. What it is reported as instead — one missing
4772
+ # verification form rather than the expanded v3 set — belongs to the
4773
+ # absent-verification-form-is-one-problem scenario.
4734
4774
  bare = task_capsule_compact_fixture()
4735
4775
  for block in (
4736
4776
  " - Verify:\n - Strategy: evidence-first\n - M1: node test.js\n",
@@ -5049,6 +5089,957 @@ def validate_unresolved_authority_names_field_scenario() -> int:
5049
5089
  return 0
5050
5090
 
5051
5091
 
5092
+ SPEC_TEMPLATE_RELATIVE = "schemas/keel-spec-driven/templates/spec.md"
5093
+
5094
+
5095
+ SLOT_FILLER = "the recorded feed status"
5096
+ SLOT_VOCABULARY = {"<strategy>": "evidence-first"}
5097
+
5098
+
5099
+ def fill_template_slots(text: str, comments: str = "strip") -> str:
5100
+ """Fill a shipped template's author-facing slots the way an author would.
5101
+
5102
+ Deliberately mechanical, so a slot added to the template later is handled
5103
+ without touching the scenario: what is asserted is the template's structure
5104
+ rather than a hand-maintained copy of it.
5105
+
5106
+ The two templates use HTML comments for different jobs, so the caller says
5107
+ which. In the spec template a comment *is* the slot — the requirement name
5108
+ and body are both comments — so it is replaced. In the tasks template
5109
+ comments are instructions to the author sitting on their own lines, and the
5110
+ slots are angle-bracket runs, so the comments are stripped.
5111
+ """
5112
+ if comments == "replace":
5113
+ text = re.sub(r"<!--[\s\S]*?-->", SLOT_FILLER, text)
5114
+ else:
5115
+ # An own-line comment is an instruction to the author, so it goes. A
5116
+ # comment with text before it on the line is that line's slot — a task
5117
+ # title, a group name — and stripping it leaves a task line the parser
5118
+ # cannot read, so it is filled like any other slot.
5119
+ text = re.sub(r"^[ \t]*<!--[\s\S]*?-->[ \t]*\r?\n", "", text, flags=re.M)
5120
+ text = re.sub(r"<!--[\s\S]*?-->", SLOT_FILLER, text)
5121
+ # A slot whose value comes from a fixed vocabulary needs a member of it, so
5122
+ # those are named. Everything else takes the generic filler.
5123
+ for slot, value in SLOT_VOCABULARY.items():
5124
+ text = text.replace(slot, value)
5125
+ # Innermost-first to a fixed point: a slot may quote an identifier shape such
5126
+ # as `Q<n>`, and the inner brackets would otherwise block the outer match and
5127
+ # leave the whole slot unfilled.
5128
+ while True:
5129
+ collapsed = re.sub(r"<[^<>\n]*>", SLOT_FILLER, text)
5130
+ if collapsed == text:
5131
+ return text
5132
+ text = collapsed
5133
+
5134
+
5135
+ def validate_completion_requires_a_recorded_anchor_scenario() -> int:
5136
+ """Issue #30: an unrecorded anchor made the drift guarantee conditional.
5137
+
5138
+ `anchoredFingerprint` returns null for a non-digest value and completion
5139
+ then skipped the comparison entirely, so a task with `Contract: pending`
5140
+ passed with zero problems. It could be implemented against one contract,
5141
+ have its Touch or Verify rewritten mid-flight, and complete clean — purely
5142
+ by never running `task-start --record`. 5.3.7 closed the inference path;
5143
+ this closes the explicitly named one.
5144
+ """
5145
+ header = "# Tasks\n\n## Invalidates\n\n- None.\n\n"
5146
+
5147
+ def task(contract: str) -> str:
5148
+ return (
5149
+ "- [ ] 1.1 Exercise task contract\n"
5150
+ " - Covers:\n"
5151
+ " - E1: Public behavior passes.\n"
5152
+ " - Touch:\n"
5153
+ " - src/feature.js\n"
5154
+ " - Verify:\n"
5155
+ " - Strategy: evidence-first\n"
5156
+ " - M1: node test.js asserts the recorded feed status\n"
5157
+ " - Evidence:\n"
5158
+ f" - Contract: {contract}\n"
5159
+ " - M1: the suite passed\n"
5160
+ " - Review:\n"
5161
+ " - Status: pass\n"
5162
+ " - Acceptance check: behavior asserted at the interface\n"
5163
+ " - Scope check: only Touch files changed\n"
5164
+ " - Findings: none\n"
5165
+ " - Blocker: none\n"
5166
+ )
5167
+
5168
+ with tempfile.TemporaryDirectory(prefix="keel-anchor-required-") as raw:
5169
+ repo = Path(raw)
5170
+ write_text(repo / "openspec/changes/unrecorded/tasks.md", header + task("pending"))
5171
+
5172
+ def gate(change: str, stage: str) -> dict:
5173
+ return json.loads(
5174
+ run_keel(
5175
+ repo,
5176
+ "gate",
5177
+ stage,
5178
+ "--change",
5179
+ change,
5180
+ "--task",
5181
+ "1.1",
5182
+ "--json",
5183
+ ).stdout
5184
+ )
5185
+
5186
+ payload = gate("unrecorded", "task-complete")
5187
+ if payload.get("status") == "pass":
5188
+ report(
5189
+ "completion-requires-a-recorded-anchor: an explicitly named task "
5190
+ "with `Contract: pending` passed task-complete, so the "
5191
+ "fingerprint comparison still compares nothing."
5192
+ )
5193
+ return 1
5194
+ named = [
5195
+ problem.get("message", "")
5196
+ for problem in payload.get("problems", [])
5197
+ if problem.get("code") == "missing-contract-anchor"
5198
+ ]
5199
+ if not named:
5200
+ report(
5201
+ "completion-requires-a-recorded-anchor: the task did not pass, "
5202
+ "but no missing-contract-anchor diagnostic explained why."
5203
+ )
5204
+ for problem in payload.get("problems", []):
5205
+ report(f" {problem.get('code')}: {problem.get('message')}")
5206
+ return 1
5207
+ for needle in ("Contract", "--record"):
5208
+ if needle not in named[0]:
5209
+ report(
5210
+ "completion-requires-a-recorded-anchor: the diagnostic did "
5211
+ f"not name {needle}."
5212
+ )
5213
+ report(named[0])
5214
+ return 1
5215
+ # Doing what the diagnostic asks must clear it. The fingerprint is the
5216
+ # one this task actually compiles to, so the anchor comparison that
5217
+ # follows is a real comparison rather than a shape check.
5218
+ started = gate("unrecorded", "task-start")
5219
+ fingerprint = started.get("contract", {}).get("fingerprint", {}).get("value")
5220
+ if not fingerprint:
5221
+ report(
5222
+ "completion-requires-a-recorded-anchor: task-start returned no "
5223
+ "fingerprint to record."
5224
+ )
5225
+ return 1
5226
+ if any(
5227
+ problem.get("code") == "missing-contract-anchor"
5228
+ for problem in started.get("problems", [])
5229
+ ):
5230
+ report(
5231
+ "completion-requires-a-recorded-anchor: task-start reported the "
5232
+ "missing anchor, but it runs before one can exist."
5233
+ )
5234
+ return 1
5235
+ write_text(
5236
+ repo / "openspec/changes/recorded/tasks.md",
5237
+ header + task(f"keel-task-capsule/v1 sha256:{fingerprint}"),
5238
+ )
5239
+ recorded = gate("recorded", "task-complete")
5240
+ if recorded.get("status") != "pass":
5241
+ report(
5242
+ "completion-requires-a-recorded-anchor: recording the anchor did "
5243
+ "not clear the refusal."
5244
+ )
5245
+ for problem in recorded.get("problems", []):
5246
+ report(f" {problem.get('code')}: {problem.get('message')}")
5247
+ return 1
5248
+ if "completion-requires-a-recorded-anchor" not in {name for name, _ in SCENARIOS}:
5249
+ report(
5250
+ "completion-requires-a-recorded-anchor: the scenario registry does "
5251
+ "not include it."
5252
+ )
5253
+ return 1
5254
+ report("completion-requires-a-recorded-anchor scenario passed.")
5255
+ return 0
5256
+
5257
+
5258
+ def validate_task_body_ends_at_heading_scenario() -> int:
5259
+ """Issue #29: a change-level section was read as the last task's Evidence.
5260
+
5261
+ parseTasks gave a task every line up to the next task or EOF, and a `##`
5262
+ heading did not stop it, so `## Invalidates` and `## Expectation Coverage`
5263
+ landed in whichever field was open last. That made the two checks look
5264
+ contradictory: `invalidation-phrase` requires the searchable wording in
5265
+ double quotes, while the concreteness test rejects an angle-bracket slot
5266
+ outside inline code, so an entry quoting wording that carries one could
5267
+ satisfy neither. They were never in conflict — the parser only made them
5268
+ appear so.
5269
+ """
5270
+ slot = "<" + "n" + ">"
5271
+ header = "# Tasks\n\n"
5272
+
5273
+ def task(number: str, contract: str) -> str:
5274
+ return (
5275
+ f"- [ ] {number} Exercise task contract\n"
5276
+ " - Covers:\n"
5277
+ " - E1: Public behavior passes.\n"
5278
+ " - Touch:\n"
5279
+ " - src/feature.js\n"
5280
+ " - Verify:\n"
5281
+ " - Strategy: evidence-first\n"
5282
+ " - M1: node test.js asserts the recorded feed status\n"
5283
+ " - Evidence:\n"
5284
+ f" - Contract: {contract}\n"
5285
+ " - M1: the suite passed\n"
5286
+ " - Review:\n"
5287
+ " - Status: pass\n"
5288
+ " - Acceptance check: behavior asserted at the interface\n"
5289
+ " - Scope check: only Touch files changed\n"
5290
+ " - Findings: none\n"
5291
+ " - Blocker: none\n"
5292
+ )
5293
+
5294
+ # The entry quotes stale wording carrying an unfilled slot, which is exactly
5295
+ # what issue #16 asks an invalidation to quote. A stray Contract line sits
5296
+ # in the trailing section to prove the anchor search stops at the heading.
5297
+ sections = (
5298
+ "\n## Invalidates\n\n"
5299
+ f'- I1: "an unresolved Q{slot} without an authorized fallback blocks '
5300
+ 'implementation" — the schema prose. Updated by: 1.1\n'
5301
+ " - Contract: keel-task-capsule/v1 sha256:" + "b" * 64 + "\n"
5302
+ "\n## Expectation Coverage\n\n"
5303
+ f"- E1: Every Q{slot} reference resolves Covered by: 1.1\n"
5304
+ )
5305
+ body = header + task("1.1", "pending") + "\n## 2. Second group\n\n" + task(
5306
+ "2.1", "pending"
5307
+ )
5308
+
5309
+ with tempfile.TemporaryDirectory(prefix="keel-task-extent-") as raw:
5310
+ repo = Path(raw)
5311
+ write_text(repo / "openspec/changes/demo/tasks.md", body + sections)
5312
+ started = run_keel(
5313
+ repo, "gate", "task-start", "--change", "demo", "--task", "2.1", "--json"
5314
+ )
5315
+ payload = json.loads(started.stdout)
5316
+ problems = payload.get("problems", [])
5317
+ if payload.get("status") != "pass":
5318
+ report(
5319
+ "task-body-ends-at-heading: the last task did not pass task-start "
5320
+ "with a trailing section quoting an unfilled slot."
5321
+ )
5322
+ for problem in problems:
5323
+ report(f" {problem.get('code')}: {problem.get('message')}")
5324
+ return 1
5325
+ # The same run proves the phrase check is satisfied: an entry the phrase
5326
+ # check rejected would have produced invalidation-phrase above.
5327
+ malformed = body + (
5328
+ "\n## Invalidates\n\n"
5329
+ "- I1: the schema prose is stale. Updated by: 1.1\n"
5330
+ )
5331
+ write_text(repo / "openspec/changes/unquoted/tasks.md", malformed)
5332
+ unquoted = json.loads(
5333
+ run_keel(
5334
+ repo,
5335
+ "gate",
5336
+ "task-start",
5337
+ "--change",
5338
+ "unquoted",
5339
+ "--task",
5340
+ "2.1",
5341
+ "--json",
5342
+ ).stdout
5343
+ )
5344
+ if not any(
5345
+ problem.get("code") == "invalidation-phrase"
5346
+ for problem in unquoted.get("problems", [])
5347
+ ):
5348
+ report(
5349
+ "task-body-ends-at-heading: an unquoted invalidation entry was "
5350
+ "accepted, so the phrase check is no longer being satisfied by "
5351
+ "the quoted one."
5352
+ )
5353
+ return 1
5354
+ # A group heading must not be appended to the preceding task's field.
5355
+ first = json.loads(
5356
+ run_keel(
5357
+ repo, "gate", "task-start", "--change", "demo", "--task", "1.1", "--json"
5358
+ ).stdout
5359
+ )
5360
+ evidence = json.dumps(first)
5361
+ if "Second group" in evidence:
5362
+ report(
5363
+ "task-body-ends-at-heading: the group heading leaked into the "
5364
+ "preceding task's fields."
5365
+ )
5366
+ return 1
5367
+ # --record must anchor the last task's own Contract line, not the stray
5368
+ # one planted in the trailing section.
5369
+ recorded = run_keel(
5370
+ repo,
5371
+ "gate",
5372
+ "task-start",
5373
+ "--change",
5374
+ "demo",
5375
+ "--task",
5376
+ "2.1",
5377
+ "--record",
5378
+ "--json",
5379
+ )
5380
+ if recorded.returncode != 0:
5381
+ report("task-body-ends-at-heading: --record failed on the last task.")
5382
+ report((recorded.stdout or recorded.stderr).strip())
5383
+ return 1
5384
+ written = (repo / "openspec/changes/demo/tasks.md").read_text(
5385
+ encoding="utf-8"
5386
+ )
5387
+ after_heading = written.split("## Invalidates", 1)[1]
5388
+ if "b" * 64 not in after_heading:
5389
+ report(
5390
+ "task-body-ends-at-heading: --record overwrote the Contract line "
5391
+ "planted inside the trailing section."
5392
+ )
5393
+ return 1
5394
+ # The extent change must move no fingerprint: an anchor that shifted
5395
+ # would drift every live change in every consumer repo at once. Pinned
5396
+ # rather than merely measured, so a future extent change cannot move one
5397
+ # silently. A deliberate capsule-shape change will fail here too — that
5398
+ # is the point; it should be looked at, not absorbed.
5399
+ pinned = {
5400
+ "1.1": "2f723a8778160a2d51cd91e34255bf19f2c654fa23cdcd7b013915727a541d17",
5401
+ "2.1": "5e0481362b06992d0317c91d34bbd5d6746fb9c1fee47566060f61fbba7cbf05",
5402
+ }
5403
+ plain = (
5404
+ "# Tasks\n\n"
5405
+ + task("1.1", "pending")
5406
+ + "\n## 2. Second group\n\n"
5407
+ + task("2.1", "pending")
5408
+ + '\n## Invalidates\n\n- I1: "the schema prose is stale here" — the '
5409
+ "schema. Updated by: 1.1\n\n## Expectation Coverage\n\n"
5410
+ "- E1: Covered by: 1.1\n"
5411
+ )
5412
+ write_text(repo / "openspec/changes/pinned/tasks.md", plain)
5413
+ for task_id, expected in pinned.items():
5414
+ payload = json.loads(
5415
+ run_keel(
5416
+ repo,
5417
+ "gate",
5418
+ "task-start",
5419
+ "--change",
5420
+ "pinned",
5421
+ "--task",
5422
+ task_id,
5423
+ "--json",
5424
+ ).stdout
5425
+ )
5426
+ actual = (
5427
+ payload.get("contract", {}).get("fingerprint", {}).get("value")
5428
+ )
5429
+ if actual != expected:
5430
+ report(
5431
+ "task-body-ends-at-heading: the compiled fingerprint for "
5432
+ f"task {task_id} moved. Expected {expected}, got {actual}. "
5433
+ "An extent or capsule-shape change that moves an anchor "
5434
+ "drifts every live change in every consumer repo."
5435
+ )
5436
+ return 1
5437
+ if "task-body-ends-at-heading" not in {name for name, _ in SCENARIOS}:
5438
+ report(
5439
+ "task-body-ends-at-heading: the scenario registry does not include it."
5440
+ )
5441
+ return 1
5442
+ report("task-body-ends-at-heading scenario passed.")
5443
+ return 0
5444
+
5445
+
5446
+ TASKS_TEMPLATE_RELATIVE = "schemas/keel-spec-driven/templates/tasks.md"
5447
+
5448
+
5449
+ def validate_tasks_template_red_green_example_scenario() -> int:
5450
+ """Issue #28 items 2 and 3: the red-green shape was described, never shown.
5451
+
5452
+ The template's prose has said since 5.3.4 that the `.red`/`.green` entries
5453
+ come in addition to the bare `M<n>` entry, but it showed only the flat form.
5454
+ The reporter tried annotated labels, was refused, landed on the flat form,
5455
+ and was refused again for the missing bare entry. One worked example closes
5456
+ both attempts — and it is asserted by gating it, so it cannot drift from the
5457
+ rule it illustrates.
5458
+ """
5459
+ shipped = ROOT / "openspec" / TASKS_TEMPLATE_RELATIVE
5460
+ packaged = ROOT / "assets" / "openspec" / TASKS_TEMPLATE_RELATIVE
5461
+ for path in (shipped, packaged):
5462
+ if not path.is_file():
5463
+ report(f"tasks-template-red-green-example: missing template {path}.")
5464
+ return 1
5465
+ if shipped.read_bytes() != packaged.read_bytes():
5466
+ report(
5467
+ "tasks-template-red-green-example: the two shipped copies of the "
5468
+ "tasks template have diverged."
5469
+ )
5470
+ return 1
5471
+
5472
+ source = shipped.read_text(encoding="utf-8")
5473
+ red_green = [
5474
+ block
5475
+ for block in re.split(r"^## ", source, flags=re.MULTILINE)
5476
+ if re.search(r"^\s*-\s*Strategy:\s*(vertical-tdd|regression-first)", block, re.M)
5477
+ ]
5478
+ if not red_green:
5479
+ report(
5480
+ "tasks-template-red-green-example: the template defines no task "
5481
+ "group with a red-green strategy, so it still only describes one."
5482
+ )
5483
+ return 1
5484
+ group = red_green[0]
5485
+ untagged = re.findall(r"^\s*-\s*(M[1-9]\d*):\s*(?!pending)", group, re.M)
5486
+ tagged = re.findall(r"^\s*-\s*(M[1-9]\d*)\s*\([^)]*regression[^)]*\):", group, re.M)
5487
+ if not untagged:
5488
+ report(
5489
+ "tasks-template-red-green-example: the red-green group has no "
5490
+ "untagged check, which task-start refuses as regression-only."
5491
+ )
5492
+ return 1
5493
+ if not tagged:
5494
+ report(
5495
+ "tasks-template-red-green-example: the red-green group shows no "
5496
+ "(regression)-tagged check, so the exemption is still unillustrated."
5497
+ )
5498
+ return 1
5499
+ label = untagged[0]
5500
+ for suffix in ("", ".red", ".green"):
5501
+ if not re.search(rf"^\s*-\s*{label}{re.escape(suffix)}:", group, re.M):
5502
+ report(
5503
+ "tasks-template-red-green-example: the untagged check is missing "
5504
+ f"its `{label}{suffix}` Evidence entry."
5505
+ )
5506
+ return 1
5507
+ for suffix in (".red", ".green"):
5508
+ if re.search(rf"^\s*-\s*{tagged[0]}{re.escape(suffix)}:", group, re.M):
5509
+ report(
5510
+ "tasks-template-red-green-example: the (regression)-tagged check "
5511
+ f"carries a `{suffix}` entry it is exempt from."
5512
+ )
5513
+ return 1
5514
+
5515
+ # Gating the filled template is what keeps the example from drifting from
5516
+ # the rule it illustrates.
5517
+ filled = fill_template_slots(source)
5518
+ with tempfile.TemporaryDirectory(prefix="keel-tasks-template-") as raw:
5519
+ repo = Path(raw)
5520
+ write_text(repo / "openspec/changes/from-template/tasks.md", filled)
5521
+ ids = re.findall(r"^\s*-\s*\[[ xX]\]\s+(\d+(?:\.\d+)+)\s", filled, re.M)
5522
+ if not ids:
5523
+ report(
5524
+ "tasks-template-red-green-example: the filled template defines "
5525
+ "no task the gate can read."
5526
+ )
5527
+ return 1
5528
+ for task_id in ids:
5529
+ result = run_keel(
5530
+ repo,
5531
+ "gate",
5532
+ "task-start",
5533
+ "--change",
5534
+ "from-template",
5535
+ "--task",
5536
+ task_id,
5537
+ "--json",
5538
+ )
5539
+ payload = json.loads(result.stdout)
5540
+ if payload.get("status") != "pass":
5541
+ report(
5542
+ "tasks-template-red-green-example: task "
5543
+ f"{task_id} written from the shipped template did not pass "
5544
+ "task-start."
5545
+ )
5546
+ for problem in payload.get("problems", []):
5547
+ report(f" {problem.get('code')}: {problem.get('message')}")
5548
+ return 1
5549
+ if "tasks-template-red-green-example" not in {name for name, _ in SCENARIOS}:
5550
+ report(
5551
+ "tasks-template-red-green-example: the scenario registry does not "
5552
+ "include it."
5553
+ )
5554
+ return 1
5555
+ report("tasks-template-red-green-example scenario passed.")
5556
+ return 0
5557
+
5558
+
5559
+ def validate_spec_template_validates_scenario() -> int:
5560
+ """Issue #28 item 7: following the spec template guaranteed a first failure.
5561
+
5562
+ The template's requirement body is a comment with no modal verb, and
5563
+ `openspec validate` requires SHALL or MUST, so the reporter's 16 requirements
5564
+ produced 16 errors. Asserted by running the filled template through the
5565
+ validator rather than by matching the template's prose, because a template
5566
+ that only mentions the requirement in a comment would satisfy the latter.
5567
+ """
5568
+ shipped = ROOT / "openspec" / SPEC_TEMPLATE_RELATIVE
5569
+ packaged = ROOT / "assets" / "openspec" / SPEC_TEMPLATE_RELATIVE
5570
+ for path in (shipped, packaged):
5571
+ if not path.is_file():
5572
+ report(f"spec-template-validates: missing shipped template {path}.")
5573
+ return 1
5574
+ if shipped.read_bytes() != packaged.read_bytes():
5575
+ report(
5576
+ "spec-template-validates: the two shipped copies of the spec "
5577
+ "template have diverged."
5578
+ )
5579
+ return 1
5580
+ if run_openspec(ROOT, "--version") is None:
5581
+ report("spec-template-validates skipped: the openspec CLI is not on PATH.")
5582
+ return 0
5583
+
5584
+ filled = fill_template_slots(
5585
+ shipped.read_text(encoding="utf-8"), comments="replace"
5586
+ )
5587
+ with tempfile.TemporaryDirectory(prefix="keel-spec-template-") as raw:
5588
+ repo = Path(raw)
5589
+ write_text(repo / "openspec/project.md", "# Project\n\nA fixture.\n")
5590
+ change = repo / "openspec/changes/from-template"
5591
+ write_text(
5592
+ change / "proposal.md",
5593
+ "# from-template\n\n## Why\n\nExercise the shipped spec template.\n"
5594
+ "\n## What Changes\n\n- One requirement written from the template.\n",
5595
+ )
5596
+ write_text(change / "specs/demo-capability/spec.md", filled)
5597
+ result = run_openspec(repo, "validate", "from-template")
5598
+ if result is None:
5599
+ report(
5600
+ "spec-template-validates skipped: the openspec CLI vanished "
5601
+ "mid-scenario."
5602
+ )
5603
+ return 0
5604
+ if result.returncode != 0:
5605
+ report(
5606
+ "spec-template-validates: a requirement written from the "
5607
+ "shipped template did not validate."
5608
+ )
5609
+ report((result.stdout or result.stderr).strip())
5610
+ return 1
5611
+ if "spec-template-validates" not in {name for name, _ in SCENARIOS}:
5612
+ report("spec-template-validates: the scenario registry does not include it.")
5613
+ return 1
5614
+ report("spec-template-validates scenario passed.")
5615
+ return 0
5616
+
5617
+
5618
+ def validate_task_complete_selection_requires_a_started_task_scenario() -> int:
5619
+ """Issue #28 item 6: the no-arg default reported another task's problems.
5620
+
5621
+ The documented order is gate-then-checkbox, so first-unchecked is the right
5622
+ inference and stays. The hazard is narrower: inferring a task that never
5623
+ started, then printing its readiness problems under a selection heading the
5624
+ author reads as their own task's failure. A task that has started records a
5625
+ fingerprint in its Evidence `Contract` anchor, so that anchor is what makes
5626
+ the inference safe.
5627
+ """
5628
+ header = "# Tasks\n\n## Invalidates\n\n- None.\n\n"
5629
+
5630
+ def task(number: str, checked: bool, contract: str) -> str:
5631
+ box = "x" if checked else " "
5632
+ return (
5633
+ f"- [{box}] {number} Exercise task contract\n"
5634
+ " - Covers:\n"
5635
+ " - E1: Public behavior passes.\n"
5636
+ " - Touch:\n"
5637
+ " - src/feature.js\n"
5638
+ " - Verify:\n"
5639
+ " - Strategy: evidence-first\n"
5640
+ " - M1: node test.js\n"
5641
+ " - Evidence:\n"
5642
+ f" - Contract: {contract}\n"
5643
+ " - M1: the suite passed\n"
5644
+ " - Review:\n"
5645
+ " - Status: pass\n"
5646
+ " - Acceptance check: behavior asserted at the interface\n"
5647
+ " - Scope check: only Touch files changed\n"
5648
+ " - Findings: none\n"
5649
+ " - Blocker: none\n"
5650
+ )
5651
+
5652
+ digest = "a" * 64
5653
+ anchored = f"keel-task-capsule/v1 sha256:{digest}"
5654
+ with tempfile.TemporaryDirectory(prefix="keel-complete-selection-") as raw:
5655
+ repo = Path(raw)
5656
+
5657
+ def gate(change: str, stage: str) -> dict:
5658
+ result = run_keel(
5659
+ repo, "gate", stage, "--change", change, "--json"
5660
+ )
5661
+ return json.loads(result.stdout)
5662
+
5663
+ # 1.1 is finished; 1.2 has never started, so its anchor is still pending.
5664
+ write_text(
5665
+ repo / "openspec/changes/unstarted/tasks.md",
5666
+ header
5667
+ + task("1.1", True, anchored)
5668
+ + "\n"
5669
+ + task("1.2", False, "pending"),
5670
+ )
5671
+ payload = gate("unstarted", "task-complete")
5672
+ codes = {problem.get("code") for problem in payload.get("problems", [])}
5673
+ if "ambiguous-completion-selection" not in codes:
5674
+ report(
5675
+ "task-complete-selection-requires-a-started-task: no-arg "
5676
+ "task-complete did not refuse on selection for a task that "
5677
+ "records no start fingerprint."
5678
+ )
5679
+ report(json.dumps(payload.get("problems", []), indent=2))
5680
+ return 1
5681
+ message = next(
5682
+ problem.get("message", "")
5683
+ for problem in payload.get("problems", [])
5684
+ if problem.get("code") == "ambiguous-completion-selection"
5685
+ )
5686
+ for needle in ("1.2", "1.1", "--task"):
5687
+ if needle not in message:
5688
+ report(
5689
+ "task-complete-selection-requires-a-started-task: the "
5690
+ f"refusal did not name {needle}."
5691
+ )
5692
+ report(message)
5693
+ return 1
5694
+ # The same shape, once 1.2 has recorded its start fingerprint.
5695
+ write_text(
5696
+ repo / "openspec/changes/started/tasks.md",
5697
+ header
5698
+ + task("1.1", True, anchored)
5699
+ + "\n"
5700
+ + task("1.2", False, anchored),
5701
+ )
5702
+ started = gate("started", "task-complete")
5703
+ started_codes = {
5704
+ problem.get("code") for problem in started.get("problems", [])
5705
+ }
5706
+ if "ambiguous-completion-selection" in started_codes:
5707
+ report(
5708
+ "task-complete-selection-requires-a-started-task: a task that "
5709
+ "recorded its start fingerprint was still refused on selection."
5710
+ )
5711
+ return 1
5712
+ if started.get("selection", {}).get("tasks") != ["1.2"]:
5713
+ report(
5714
+ "task-complete-selection-requires-a-started-task: the started "
5715
+ "task was not the inferred selection."
5716
+ )
5717
+ report(json.dumps(started.get("selection", {}), indent=2))
5718
+ return 1
5719
+ # task-start keeps the plain first-unchecked default: selecting a task
5720
+ # that has not started is exactly its job.
5721
+ start = gate("unstarted", "task-start")
5722
+ if start.get("selection", {}).get("tasks") != ["1.2"]:
5723
+ report(
5724
+ "task-complete-selection-requires-a-started-task: no-arg "
5725
+ "task-start no longer selects the first unchecked task."
5726
+ )
5727
+ report(json.dumps(start.get("selection", {}), indent=2))
5728
+ return 1
5729
+ start_codes = {problem.get("code") for problem in start.get("problems", [])}
5730
+ if "ambiguous-completion-selection" in start_codes:
5731
+ report(
5732
+ "task-complete-selection-requires-a-started-task: the selection "
5733
+ "refusal leaked into task-start."
5734
+ )
5735
+ return 1
5736
+ if "task-complete-selection-requires-a-started-task" not in {
5737
+ name for name, _ in SCENARIOS
5738
+ }:
5739
+ report(
5740
+ "task-complete-selection-requires-a-started-task: the scenario "
5741
+ "registry does not include it."
5742
+ )
5743
+ return 1
5744
+ report("task-complete-selection-requires-a-started-task scenario passed.")
5745
+ return 0
5746
+
5747
+
5748
+ def validate_absent_verification_form_is_one_problem_scenario() -> int:
5749
+ """Issue #28 item 4: the cascade reported a schema the author never chose.
5750
+
5751
+ Compact detection read only `isConcrete(Verify)`, so a task that simply had
5752
+ no verification field was reported as an expanded v3 task missing nine
5753
+ fields. Seven of them either resolve to a documented default, derive from
5754
+ other authority, are consumed nowhere, or belong to the coupling contract —
5755
+ leaving the one actionable line last.
5756
+ """
5757
+ defaulted = (
5758
+ "Owner",
5759
+ "Mode",
5760
+ "Read",
5761
+ "Acceptance",
5762
+ "Report",
5763
+ "Candidate Boundary",
5764
+ "Stop Rules",
5765
+ )
5766
+ header = "# Tasks\n\n## Invalidates\n\n- None.\n\n"
5767
+ body = (
5768
+ "- [ ] 1.1 Exercise task contract\n"
5769
+ " - Covers:\n"
5770
+ " - E1: Public behavior passes.\n"
5771
+ " - Touch:\n"
5772
+ " - src/feature.js\n"
5773
+ )
5774
+ evidence = " - Evidence:\n - M1: pending\n"
5775
+ commands = " - Commands:\n - M1: node test.js\n"
5776
+
5777
+ with tempfile.TemporaryDirectory(prefix="keel-absent-verification-") as raw:
5778
+ repo = Path(raw)
5779
+
5780
+ def problems_for(change: str, content: str) -> list:
5781
+ write_text(repo / f"openspec/changes/{change}/tasks.md", content)
5782
+ result = run_keel(
5783
+ repo,
5784
+ "gate",
5785
+ "task-start",
5786
+ "--change",
5787
+ change,
5788
+ "--task",
5789
+ "1.1",
5790
+ "--json",
5791
+ )
5792
+ return json.loads(result.stdout).get("problems", [])
5793
+
5794
+ # M1 — neither verification form declared.
5795
+ none_declared = problems_for("noform", header + body + evidence)
5796
+ naming = [
5797
+ problem
5798
+ for problem in none_declared
5799
+ if "Verify" in problem.get("message", "")
5800
+ ]
5801
+ if len(naming) != 1:
5802
+ report(
5803
+ "absent-verification-form-is-one-problem: expected exactly one "
5804
+ f"diagnostic naming Verify, found {len(naming)}."
5805
+ )
5806
+ for problem in none_declared:
5807
+ report(f" {problem.get('code')}: {problem.get('message')}")
5808
+ return 1
5809
+ leaked = sorted(
5810
+ name
5811
+ for problem in none_declared
5812
+ if problem.get("code") == "missing-field"
5813
+ for name in defaulted
5814
+ if problem.get("message", "").startswith(f"{name} must be concrete")
5815
+ )
5816
+ if leaked:
5817
+ report(
5818
+ "absent-verification-form-is-one-problem: fields with documented "
5819
+ f"defaults were still required: {', '.join(leaked)}."
5820
+ )
5821
+ return 1
5822
+
5823
+ # M2 — a genuine expanded v3 task that omits every defaulted field.
5824
+ expanded = problems_for("expanded", header + body + commands + evidence)
5825
+ if expanded:
5826
+ report(
5827
+ "absent-verification-form-is-one-problem: an expanded task "
5828
+ "declaring Commands, Covers, Touch and Evidence did not pass."
5829
+ )
5830
+ for problem in expanded:
5831
+ report(f" {problem.get('code')}: {problem.get('message')}")
5832
+ return 1
5833
+ # Removing Commands from that same task must still fail.
5834
+ if not problems_for("expanded-no-commands", header + body + evidence):
5835
+ report(
5836
+ "absent-verification-form-is-one-problem: removing Commands "
5837
+ "from the expanded task left it passing."
5838
+ )
5839
+ return 1
5840
+ # Candidate Boundary is the coupling contract's to require.
5841
+ coupled = problems_for(
5842
+ "coupled",
5843
+ header
5844
+ + body
5845
+ + " - Coupling: required\n"
5846
+ + commands
5847
+ + evidence,
5848
+ )
5849
+ if not any(
5850
+ "Candidate Boundary" in problem.get("message", "") for problem in coupled
5851
+ ):
5852
+ report(
5853
+ "absent-verification-form-is-one-problem: Coupling required did "
5854
+ "not require a Candidate Boundary."
5855
+ )
5856
+ for problem in coupled:
5857
+ report(f" {problem.get('code')}: {problem.get('message')}")
5858
+ return 1
5859
+ if "absent-verification-form-is-one-problem" not in {
5860
+ name for name, _ in SCENARIOS
5861
+ }:
5862
+ report(
5863
+ "absent-verification-form-is-one-problem: the scenario registry does "
5864
+ "not include it."
5865
+ )
5866
+ return 1
5867
+ report("absent-verification-form-is-one-problem scenario passed.")
5868
+ return 0
5869
+
5870
+
5871
+ def validate_non_concrete_check_names_token_scenario() -> int:
5872
+ """Issue #28 item 5: the check diagnostic must name the slot it matched.
5873
+
5874
+ `unfilledToken` already identifies the matched token and `Verify` already
5875
+ reports it. The per-check message said only that the check "must define a
5876
+ concrete public check", which describes the consequence rather than the
5877
+ cause, so the reporter had to guess which of several inline slots was the
5878
+ problem.
5879
+ """
5880
+ slot = "<" + "url" + ">"
5881
+ with tempfile.TemporaryDirectory(prefix="keel-check-token-") as raw:
5882
+ repo = Path(raw)
5883
+ base = task_capsule_compact_fixture().replace(
5884
+ " - M1: node test.js\n",
5885
+ " - M1: node test.js\n"
5886
+ f" - M2: run the fetch script against {slot} and assert the "
5887
+ "recorded status\n",
5888
+ ).replace(" - M1: pending\n", " - M1: pending\n - M2: pending\n")
5889
+ write_text(repo / "openspec/changes/tokened/tasks.md", base)
5890
+
5891
+ def check_messages(change: str) -> list:
5892
+ result = run_keel(
5893
+ repo,
5894
+ "gate",
5895
+ "task-start",
5896
+ "--change",
5897
+ change,
5898
+ "--task",
5899
+ "1.1",
5900
+ "--json",
5901
+ )
5902
+ payload = json.loads(result.stdout)
5903
+ return [
5904
+ problem.get("message", "")
5905
+ for problem in payload.get("problems", [])
5906
+ if problem.get("code") == "missing-command-check"
5907
+ ]
5908
+
5909
+ tokened = check_messages("tokened")
5910
+ if not tokened:
5911
+ report(
5912
+ "non-concrete-check-names-token: a check carrying a bare "
5913
+ "unfilled slot produced no missing-command-check diagnostic."
5914
+ )
5915
+ return 1
5916
+ if slot not in tokened[0]:
5917
+ report(
5918
+ "non-concrete-check-names-token: the diagnostic did not name "
5919
+ f"the {slot} slot it matched."
5920
+ )
5921
+ report(tokened[0])
5922
+ return 1
5923
+ # Replacing exactly what the diagnostic names must clear it.
5924
+ fixed = base.replace(slot, "`https://example.test/feed`")
5925
+ write_text(repo / "openspec/changes/fixed/tasks.md", fixed)
5926
+ if check_messages("fixed"):
5927
+ report(
5928
+ "non-concrete-check-names-token: replacing the named slot did "
5929
+ "not clear the diagnostic."
5930
+ )
5931
+ return 1
5932
+ # An empty check has no token to name, so the unqualified wording is
5933
+ # still the honest one there.
5934
+ empty = base.replace(
5935
+ f" - M2: run the fetch script against {slot} and assert the "
5936
+ "recorded status\n",
5937
+ " - M2: pending\n",
5938
+ )
5939
+ write_text(repo / "openspec/changes/empty/tasks.md", empty)
5940
+ bare = check_messages("empty")
5941
+ if not bare:
5942
+ report(
5943
+ "non-concrete-check-names-token: a pending check produced no "
5944
+ "missing-command-check diagnostic."
5945
+ )
5946
+ return 1
5947
+ if "must define a concrete public check" not in bare[0]:
5948
+ report(
5949
+ "non-concrete-check-names-token: a pending check lost the "
5950
+ "unqualified wording."
5951
+ )
5952
+ report(bare[0])
5953
+ return 1
5954
+ if "non-concrete-check-names-token" not in {name for name, _ in SCENARIOS}:
5955
+ report(
5956
+ "non-concrete-check-names-token: the scenario registry does not "
5957
+ "include it."
5958
+ )
5959
+ return 1
5960
+ report("non-concrete-check-names-token scenario passed.")
5961
+ return 0
5962
+
5963
+
5964
+ def validate_covers_question_reference_scope_scenario() -> int:
5965
+ """Issue #28 item 9: citing a resolved question must not re-open it.
5966
+
5967
+ The question scan used to run over the whole Covers field, so a task that
5968
+ named `Q1` beside the fact that closed it was told to declare a fallback for
5969
+ a question it does not carry. The reporter's only available fix was to
5970
+ delete the reference, which makes traceability worse.
5971
+
5972
+ Both sides are asserted. A scenario that only checked the newly passing
5973
+ shape would also be satisfied by deleting the check outright.
5974
+ """
5975
+ with tempfile.TemporaryDirectory(prefix="keel-covers-question-") as raw:
5976
+ repo = Path(raw)
5977
+ base = task_capsule_compact_fixture()
5978
+ # Still in scope: the question is the subject of its entry.
5979
+ subject = base.replace(
5980
+ " - E1: Public behavior passes.\n",
5981
+ " - Q1: Should the widget retry on timeout?\n",
5982
+ )
5983
+ write_text(repo / "openspec/changes/subject/tasks.md", subject)
5984
+ # Out of scope: the entry's subject is the fact, and the resolved
5985
+ # question is named as the supporting detail that points at it.
5986
+ detail = base.replace(
5987
+ " - E1: Public behavior passes.\n",
5988
+ " - F13 (Q1 resolved: the widget retries twice, then stops)\n",
5989
+ )
5990
+ write_text(repo / "openspec/changes/detail/tasks.md", detail)
5991
+
5992
+ def authority_messages(change: str) -> list:
5993
+ result = run_keel(
5994
+ repo,
5995
+ "gate",
5996
+ "task-start",
5997
+ "--change",
5998
+ change,
5999
+ "--task",
6000
+ "1.1",
6001
+ "--json",
6002
+ )
6003
+ payload = json.loads(result.stdout)
6004
+ return [
6005
+ problem.get("message", "")
6006
+ for problem in payload.get("problems", [])
6007
+ if problem.get("code") == "unresolved-authority"
6008
+ ]
6009
+
6010
+ blocking = authority_messages("subject")
6011
+ if not blocking:
6012
+ report(
6013
+ "covers-question-reference-scope: a question that opens its "
6014
+ "Covers entry produced no unresolved-authority diagnostic, so "
6015
+ "the check no longer refuses anything."
6016
+ )
6017
+ return 1
6018
+ if "Q1" not in blocking[0]:
6019
+ report(
6020
+ "covers-question-reference-scope: the diagnostic did not name "
6021
+ "the question it read."
6022
+ )
6023
+ report(blocking[0])
6024
+ return 1
6025
+ citing = authority_messages("detail")
6026
+ if citing:
6027
+ report(
6028
+ "covers-question-reference-scope: naming a resolved question as "
6029
+ "supporting detail still demands a fallback for it."
6030
+ )
6031
+ report(citing[0])
6032
+ return 1
6033
+ if "covers-question-reference-scope" not in {name for name, _ in SCENARIOS}:
6034
+ report(
6035
+ "covers-question-reference-scope: the scenario registry does not "
6036
+ "include it."
6037
+ )
6038
+ return 1
6039
+ report("covers-question-reference-scope scenario passed.")
6040
+ return 0
6041
+
6042
+
5052
6043
  def validate_dev_only_plugin_source_scoping_scenario() -> int:
5053
6044
  """Issue #6: plugins/keel/ exists only in Keel's own repository.
5054
6045
 
@@ -5230,6 +6221,7 @@ def tracker_owner_tasks(findings: str, closure: str) -> str:
5230
6221
  " - Stop Rules:\n"
5231
6222
  " - Stop on failure.\n"
5232
6223
  " - Evidence:\n"
6224
+ " - Contract: pending\n"
5233
6225
  " - M1: passed\n"
5234
6226
  " - Review:\n"
5235
6227
  " - Status: pass\n"
@@ -5264,6 +6256,7 @@ def validate_tracker_durable_owner_scenario() -> int:
5264
6256
 
5265
6257
  def complete(findings: str, closure: str = "Covered by: 1.1"):
5266
6258
  write_text(tasks, tracker_owner_tasks(findings, closure))
6259
+ record_contract_anchor(repo, "demo")
5267
6260
  return run_keel(
5268
6261
  repo, "gate", "task-complete",
5269
6262
  "--change", "demo", "--task", "1.1", "--json",
@@ -5685,6 +6678,7 @@ def validate_task_capsule_scenario() -> int:
5685
6678
  repo / "openspec/changes/demo/tasks.md",
5686
6679
  completion_task,
5687
6680
  )
6681
+ record_contract_anchor(repo, "demo")
5688
6682
  completed = run_keel(
5689
6683
  repo,
5690
6684
  "gate",
@@ -6379,6 +7373,10 @@ def validate_core_gates_scenario() -> int:
6379
7373
  " - Stop Rules:\n"
6380
7374
  " - Stop on final assertion failure.\n"
6381
7375
  " - Evidence:\n"
7376
+ # Read at call time, so every variant below carries the anchor
7377
+ # recorded once for this fixture. Evidence is not in the capsule,
7378
+ # so one fingerprint is correct for all of them.
7379
+ f" - Contract: {completion_anchor}\n"
6382
7380
  f" - M1: {evidence}\n"
6383
7381
  " - Review:\n"
6384
7382
  f" - Status: {review_status}\n"
@@ -6392,6 +7390,19 @@ def validate_core_gates_scenario() -> int:
6392
7390
  " - Summary\n"
6393
7391
  )
6394
7392
 
7393
+ completion_anchor = "pending"
7394
+ write_text(completion_tasks, completion_task("pending", "pass"))
7395
+ if not record_contract_anchor(completion_repo, "demo"):
7396
+ report("core-gates scenario could not record the completion anchor.")
7397
+ return 1
7398
+ recorded_line = re.search(
7399
+ r"-\s*Contract:\s*(.+)", completion_tasks.read_text(encoding="utf-8")
7400
+ )
7401
+ if not recorded_line:
7402
+ report("core-gates scenario found no recorded Contract anchor.")
7403
+ return 1
7404
+ completion_anchor = recorded_line.group(1).strip()
7405
+
6395
7406
  write_text(completion_tasks, completion_task("pending", "pass"))
6396
7407
  missing_evidence = run_keel(
6397
7408
  completion_repo,
@@ -7194,6 +8205,7 @@ def validate_scope_rename_attribution_scenario() -> int:
7194
8205
  " - Stop Rules:\n"
7195
8206
  " - Stop on final assertion failure.\n"
7196
8207
  " - Evidence:\n"
8208
+ " - Contract: pending\n"
7197
8209
  " - M1: passed\n"
7198
8210
  " - Review:\n"
7199
8211
  " - Status: pass\n"
@@ -7237,6 +8249,7 @@ def validate_scope_rename_attribution_scenario() -> int:
7237
8249
  report("scope-rename scenario git mv failed:")
7238
8250
  report((moved.stderr or moved.stdout).strip())
7239
8251
  return 1
8252
+ record_contract_anchor(repo, "demo")
7240
8253
  completed = run_keel(
7241
8254
  repo,
7242
8255
  "gate",
@@ -8598,6 +9611,7 @@ def validate_task_verification_strategies_scenario() -> int:
8598
9611
 
8599
9612
  def run_completion(fixture: str) -> subprocess.CompletedProcess[str]:
8600
9613
  write_text(tasks_path, fixture)
9614
+ record_contract_anchor(repo, "demo")
8601
9615
  return run_keel(
8602
9616
  repo,
8603
9617
  "gate",
@@ -9337,6 +10351,7 @@ def _goal_task_block(
9337
10351
  " - Stop Rules:",
9338
10352
  " - stop on failure",
9339
10353
  " - Evidence:",
10354
+ " - Contract: pending",
9340
10355
  ]
9341
10356
  )
9342
10357
  if filled:
@@ -10040,6 +11055,7 @@ def validate_native_goal_gate_order_scenario() -> int:
10040
11055
 
10041
11056
  # 4. With evidence and a passing Review recorded, task-complete passes.
10042
11057
  write_text(tasks_path, _goal_tasks_file([_goal_task_block(filled=True)]))
11058
+ record_contract_anchor(repo, "sample-change")
10043
11059
  completed = run_keel(
10044
11060
  repo, "gate", "task-complete",
10045
11061
  "--change", "sample-change", "--task", "1.1", "--json",
@@ -10603,6 +11619,7 @@ def validate_single_task_goal_real_tasks_scenario() -> int:
10603
11619
  [_goal_task_block(strategy=strategy, filled=True, redgreen=redgreen)]
10604
11620
  ),
10605
11621
  )
11622
+ record_contract_anchor(repo, "sample-change")
10606
11623
  done = run_keel(
10607
11624
  repo, "gate", "task-complete",
10608
11625
  "--change", "sample-change", "--task", "1.1", "--json", env=env,
@@ -10898,6 +11915,7 @@ def _single_task_matrix_target(target: str, root: Path) -> int:
10898
11915
  ]
10899
11916
  ),
10900
11917
  )
11918
+ record_contract_anchor(repo, "sample-change")
10901
11919
  done = run_keel(repo, "gate", "task-complete", "--change", "sample-change", "--task", "1.1", "--json", env=env)
10902
11920
  if done.returncode != 0:
10903
11921
  report("native-single-task-matrix %s could not complete with evidence." % target)
@@ -13106,6 +14124,32 @@ SCENARIOS: tuple = (
13106
14124
  "unresolved-authority-names-field",
13107
14125
  validate_unresolved_authority_names_field_scenario,
13108
14126
  ),
14127
+ (
14128
+ "covers-question-reference-scope",
14129
+ validate_covers_question_reference_scope_scenario,
14130
+ ),
14131
+ (
14132
+ "non-concrete-check-names-token",
14133
+ validate_non_concrete_check_names_token_scenario,
14134
+ ),
14135
+ (
14136
+ "absent-verification-form-is-one-problem",
14137
+ validate_absent_verification_form_is_one_problem_scenario,
14138
+ ),
14139
+ (
14140
+ "task-complete-selection-requires-a-started-task",
14141
+ validate_task_complete_selection_requires_a_started_task_scenario,
14142
+ ),
14143
+ ("task-body-ends-at-heading", validate_task_body_ends_at_heading_scenario),
14144
+ (
14145
+ "completion-requires-a-recorded-anchor",
14146
+ validate_completion_requires_a_recorded_anchor_scenario,
14147
+ ),
14148
+ ("spec-template-validates", validate_spec_template_validates_scenario),
14149
+ (
14150
+ "tasks-template-red-green-example",
14151
+ validate_tasks_template_red_green_example_scenario,
14152
+ ),
13109
14153
  (
13110
14154
  "dev-only-plugin-source-scoping",
13111
14155
  validate_dev_only_plugin_source_scoping_scenario,