@christang/keel 5.3.7 → 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.
@@ -1,8 +1,8 @@
1
- <!-- keel:start version=5.3.7 -->
1
+ <!-- keel:start version=5.3.8 -->
2
2
  ## Keel Bootstrap
3
3
 
4
4
  - Start every session with `keel context`; OpenSpec artifacts and Git are the only durable authority — never native memory, goals, or transcripts.
5
- - Obey the selected task capsule: `keel gate task-start` before implementing, record its fingerprint in Evidence `Contract`, and pass `keel gate task-complete` before checking complete. Touch bounds product writes; the change's own dir is exempt. On Claude a passing `task-start` guards it by default (`--no-guard` opts out).
5
+ - Obey the selected task capsule: `keel gate task-start` before implementing, `--record` its fingerprint to Evidence `Contract`, which `task-complete` requires, before checking complete. Touch bounds product writes; the change's own dir is exempt. On Claude a passing `task-start` guards it by default (`--no-guard` opts out).
6
6
  - One current agent owns writes; helpers return read-only report/evidence only. No commit, sync, or archive without explicit authorization.
7
7
  - Native plugin projections (SessionStart context) are disposable views, never authority; without the plugin or hook, run the commands manually.
8
8
  - Keel skills and hooks come from the `keel` native plugin (`codex plugin add` / `claude plugin install`); `keel --init` owns only the OpenSpec schema, overlays, and this bootstrap.
@@ -75,7 +75,10 @@ artifacts:
75
75
  Every executable task MUST be a checkbox authored in the compact v4 form
76
76
  from the template: Covers, Touch, Verify (Strategy plus ordered M<n>
77
77
  public checks), and an Evidence anchor with its Contract line, per-check
78
- results, semantic Review, and Blocker state. Omitted fields inherit
78
+ results, semantic Review, and Blocker state. `task-start --record` writes
79
+ the compiled fingerprint into that Contract line, and task-complete
80
+ refuses a task that recorded none: without an anchor there is nothing for
81
+ completion to compare against, so the task has no drift detection. Omitted fields inherit
79
82
  versioned keel-task-capsule/v1 defaults: Owner is the current Keel
80
83
  agent, Mode is implementation, Read is the change artifacts plus
81
84
  discovered repository context, Acceptance derives from the resolved
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@christang/keel",
3
3
  "displayName": "Keel",
4
4
  "description": "Keel OpenSpec execution discipline CLI for Claude Code, Codex, and OpenCode.",
5
- "version": "5.3.7",
5
+ "version": "5.3.8",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keel",
3
- "version": "5.3.7",
3
+ "version": "5.3.8",
4
4
  "description": "Keel OpenSpec execution discipline: stateless continuity, task capsules, deterministic gates, and expectation alignment for Codex and Claude Code.",
5
5
  "author": {
6
6
  "name": "TanglmChris",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keel",
3
- "version": "5.3.7",
3
+ "version": "5.3.8",
4
4
  "description": "Keel OpenSpec execution discipline: stateless continuity, task capsules, deterministic gates, and expectation alignment for Codex and Claude Code.",
5
5
  "author": {
6
6
  "name": "TanglmChris",
@@ -37,8 +37,8 @@ REQUIRED_SCRIPTS = [
37
37
  "scripts/validate_plugin.py",
38
38
  ]
39
39
 
40
- PACKAGE_VERSION = "5.3.7"
41
- PROTOCOL_VERSION = "5.3.7"
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
@@ -3509,6 +3509,11 @@ def task_contract_fixture(
3509
3509
  " - Stop Rules:\n"
3510
3510
  " - Stop on failure.\n"
3511
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"
3512
3517
  f"{evidence_lines}"
3513
3518
  " - Review:\n"
3514
3519
  " - Status: pending\n"
@@ -3545,7 +3550,7 @@ def invalidation_repo(root: Path, name: str, section: str | None) -> Path:
3545
3550
  # A real Contract anchor, so the missing-section case can prove that a
3546
3551
  # failing authoring gate leaves the anchor untouched rather than merely
3547
3552
  # failing earlier for want of one.
3548
- body = task_contract_fixture(evidence=("Contract: pending", "M1: pending"))
3553
+ body = task_contract_fixture()
3549
3554
  body = body.replace(INVALIDATES_NONE, "" if section is None else section)
3550
3555
  write_text(repo / "openspec/changes/demo/tasks.md", body)
3551
3556
  return repo
@@ -3781,7 +3786,7 @@ def validate_anchor_reverification_bound_scenario() -> int:
3781
3786
  repo = Path(raw_tmp) / "repo"
3782
3787
  repo.mkdir()
3783
3788
  live = repo / "openspec/changes/demo/tasks.md"
3784
- write_text(live, task_contract_fixture(evidence=("Contract: pending", "M1: pending")))
3789
+ write_text(live, task_contract_fixture())
3785
3790
 
3786
3791
  recorded = run_keel(
3787
3792
  repo, "gate", "task-start", "--change", "demo", "--task", "1.1",
@@ -3993,6 +3998,7 @@ def validate_durable_owner_vocabulary_scenario() -> int:
3993
3998
  )
3994
3999
  )
3995
4000
  write_text(tasks_path, fixture)
4001
+ record_contract_anchor(repo, "demo")
3996
4002
  result = run_keel(
3997
4003
  repo, "gate", "task-complete", "--change", "demo", "--task", "1.1", "--json"
3998
4004
  )
@@ -4138,6 +4144,7 @@ def validate_regression_check_tag_scenario() -> int:
4138
4144
 
4139
4145
  def complete(fixture: str) -> dict:
4140
4146
  write_text(tasks_path, fixture)
4147
+ record_contract_anchor(repo, "demo")
4141
4148
  result = run_keel(
4142
4149
  repo, "gate", "task-complete", "--change", "demo", "--task", "1.1", "--json"
4143
4150
  )
@@ -4654,12 +4661,39 @@ def task_capsule_compact_fixture() -> str:
4654
4661
  " - Stop Rules:\n"
4655
4662
  " - Stop on failure.\n"
4656
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"
4657
4668
  " - M1: pending\n"
4658
4669
  " - Stop if:\n"
4659
4670
  " - Requires files outside Touch.\n"
4660
4671
  )
4661
4672
 
4662
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
+
4663
4697
  def validate_non_concrete_verify_diagnostic_scenario() -> int:
4664
4698
  """A compact v4 task whose Verify carries an unfilled token must be told so.
4665
4699
 
@@ -5098,6 +5132,317 @@ def fill_template_slots(text: str, comments: str = "strip") -> str:
5098
5132
  text = collapsed
5099
5133
 
5100
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
+
5101
5446
  TASKS_TEMPLATE_RELATIVE = "schemas/keel-spec-driven/templates/tasks.md"
5102
5447
 
5103
5448
 
@@ -5876,6 +6221,7 @@ def tracker_owner_tasks(findings: str, closure: str) -> str:
5876
6221
  " - Stop Rules:\n"
5877
6222
  " - Stop on failure.\n"
5878
6223
  " - Evidence:\n"
6224
+ " - Contract: pending\n"
5879
6225
  " - M1: passed\n"
5880
6226
  " - Review:\n"
5881
6227
  " - Status: pass\n"
@@ -5910,6 +6256,7 @@ def validate_tracker_durable_owner_scenario() -> int:
5910
6256
 
5911
6257
  def complete(findings: str, closure: str = "Covered by: 1.1"):
5912
6258
  write_text(tasks, tracker_owner_tasks(findings, closure))
6259
+ record_contract_anchor(repo, "demo")
5913
6260
  return run_keel(
5914
6261
  repo, "gate", "task-complete",
5915
6262
  "--change", "demo", "--task", "1.1", "--json",
@@ -6331,6 +6678,7 @@ def validate_task_capsule_scenario() -> int:
6331
6678
  repo / "openspec/changes/demo/tasks.md",
6332
6679
  completion_task,
6333
6680
  )
6681
+ record_contract_anchor(repo, "demo")
6334
6682
  completed = run_keel(
6335
6683
  repo,
6336
6684
  "gate",
@@ -7025,6 +7373,10 @@ def validate_core_gates_scenario() -> int:
7025
7373
  " - Stop Rules:\n"
7026
7374
  " - Stop on final assertion failure.\n"
7027
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"
7028
7380
  f" - M1: {evidence}\n"
7029
7381
  " - Review:\n"
7030
7382
  f" - Status: {review_status}\n"
@@ -7038,6 +7390,19 @@ def validate_core_gates_scenario() -> int:
7038
7390
  " - Summary\n"
7039
7391
  )
7040
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
+
7041
7406
  write_text(completion_tasks, completion_task("pending", "pass"))
7042
7407
  missing_evidence = run_keel(
7043
7408
  completion_repo,
@@ -7840,6 +8205,7 @@ def validate_scope_rename_attribution_scenario() -> int:
7840
8205
  " - Stop Rules:\n"
7841
8206
  " - Stop on final assertion failure.\n"
7842
8207
  " - Evidence:\n"
8208
+ " - Contract: pending\n"
7843
8209
  " - M1: passed\n"
7844
8210
  " - Review:\n"
7845
8211
  " - Status: pass\n"
@@ -7883,6 +8249,7 @@ def validate_scope_rename_attribution_scenario() -> int:
7883
8249
  report("scope-rename scenario git mv failed:")
7884
8250
  report((moved.stderr or moved.stdout).strip())
7885
8251
  return 1
8252
+ record_contract_anchor(repo, "demo")
7886
8253
  completed = run_keel(
7887
8254
  repo,
7888
8255
  "gate",
@@ -9244,6 +9611,7 @@ def validate_task_verification_strategies_scenario() -> int:
9244
9611
 
9245
9612
  def run_completion(fixture: str) -> subprocess.CompletedProcess[str]:
9246
9613
  write_text(tasks_path, fixture)
9614
+ record_contract_anchor(repo, "demo")
9247
9615
  return run_keel(
9248
9616
  repo,
9249
9617
  "gate",
@@ -9983,6 +10351,7 @@ def _goal_task_block(
9983
10351
  " - Stop Rules:",
9984
10352
  " - stop on failure",
9985
10353
  " - Evidence:",
10354
+ " - Contract: pending",
9986
10355
  ]
9987
10356
  )
9988
10357
  if filled:
@@ -10686,6 +11055,7 @@ def validate_native_goal_gate_order_scenario() -> int:
10686
11055
 
10687
11056
  # 4. With evidence and a passing Review recorded, task-complete passes.
10688
11057
  write_text(tasks_path, _goal_tasks_file([_goal_task_block(filled=True)]))
11058
+ record_contract_anchor(repo, "sample-change")
10689
11059
  completed = run_keel(
10690
11060
  repo, "gate", "task-complete",
10691
11061
  "--change", "sample-change", "--task", "1.1", "--json",
@@ -11249,6 +11619,7 @@ def validate_single_task_goal_real_tasks_scenario() -> int:
11249
11619
  [_goal_task_block(strategy=strategy, filled=True, redgreen=redgreen)]
11250
11620
  ),
11251
11621
  )
11622
+ record_contract_anchor(repo, "sample-change")
11252
11623
  done = run_keel(
11253
11624
  repo, "gate", "task-complete",
11254
11625
  "--change", "sample-change", "--task", "1.1", "--json", env=env,
@@ -11544,6 +11915,7 @@ def _single_task_matrix_target(target: str, root: Path) -> int:
11544
11915
  ]
11545
11916
  ),
11546
11917
  )
11918
+ record_contract_anchor(repo, "sample-change")
11547
11919
  done = run_keel(repo, "gate", "task-complete", "--change", "sample-change", "--task", "1.1", "--json", env=env)
11548
11920
  if done.returncode != 0:
11549
11921
  report("native-single-task-matrix %s could not complete with evidence." % target)
@@ -13768,6 +14140,11 @@ SCENARIOS: tuple = (
13768
14140
  "task-complete-selection-requires-a-started-task",
13769
14141
  validate_task_complete_selection_requires_a_started_task_scenario,
13770
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
+ ),
13771
14148
  ("spec-template-validates", validate_spec_template_validates_scenario),
13772
14149
  (
13773
14150
  "tasks-template-red-green-example",
package/src/core/gates.js CHANGED
@@ -110,10 +110,29 @@ function loadSelection(repo, options, requireTask = true) {
110
110
  // of the task they just finished. A task that has started records a fingerprint
111
111
  // in its Evidence `Contract` anchor, so that anchor is what makes the inference
112
112
  // safe — and without one there is nothing for completion to compare against.
113
+ function hasRecordedAnchor(selection, task) {
114
+ const plan = contractAnchorPlan(selection, task);
115
+ return Boolean(plan && anchoredFingerprint(plan.previous));
116
+ }
117
+
118
+ // A task that recorded no anchor has no drift detection at all while presenting
119
+ // as fully gated: completion skipped the comparison rather than reporting that
120
+ // it had nothing to compare. Recording is already the documented step; this is
121
+ // what makes the guarantee unconditional instead of aspirational.
122
+ function missingAnchorProblem(selection, task) {
123
+ if (hasRecordedAnchor(selection, task)) return null;
124
+ return problem(
125
+ "missing-contract-anchor",
126
+ `${selection.change}#${task.id} records no compiled fingerprint in its `
127
+ + "Evidence `Contract` anchor, so completion has nothing to compare and "
128
+ + "the task has no drift detection. Run `keel gate task-start --record` "
129
+ + "for this task, which rewrites the anchor in place, then complete it."
130
+ );
131
+ }
132
+
113
133
  function unstartedInferenceProblem(selection) {
114
134
  const task = selection.selected[0];
115
- const plan = contractAnchorPlan(selection, task);
116
- if (plan && anchoredFingerprint(plan.previous)) return null;
135
+ if (hasRecordedAnchor(selection, task)) return null;
117
136
  const checked = [...selection.tasks].filter((item) => item.checked).pop();
118
137
  return problem(
119
138
  "ambiguous-completion-selection",
@@ -131,11 +150,9 @@ function unstartedInferenceProblem(selection) {
131
150
 
132
151
  function contractAnchorPlan(selection, task) {
133
152
  const lines = selection.content.split("\n");
134
- const index = selection.tasks.findIndex((item) => item.id === task.id);
135
- const end =
136
- index + 1 < selection.tasks.length
137
- ? selection.tasks[index + 1].line
138
- : lines.length;
153
+ // The task carries its own extent, so the anchor search cannot reach a
154
+ // `- Contract:` line sitting in a trailing change-level section.
155
+ const end = task.endLine !== undefined ? task.endLine : lines.length;
139
156
  for (let cursor = task.line; cursor < end; cursor += 1) {
140
157
  const match = lines[cursor].match(/^(\s*)-\s*Contract:\s*(.*?)(\r?)$/);
141
158
  if (match) {
@@ -585,6 +602,10 @@ function completionChecks(repo, task, contract = null) {
585
602
  function taskComplete(repo, options) {
586
603
  const selection = loadSelection(repo, options);
587
604
  const task = selection.selected[0];
605
+ // Selection ambiguity short-circuits, because the gate does not know which
606
+ // task the caller meant and evaluating the wrong one is the defect. A named
607
+ // task is not ambiguous: its missing anchor is one problem among however many
608
+ // else it has, so it joins the list rather than hiding the rest.
588
609
  if (!options.task) {
589
610
  const ambiguous = unstartedInferenceProblem(selection);
590
611
  if (ambiguous) {
@@ -603,6 +624,8 @@ function taskComplete(repo, options) {
603
624
  const usableContract = contract.diagnostics.length === 0 ? contract : null;
604
625
  const checks = completionChecks(repo, task, usableContract);
605
626
  checks.problems.push(...contract.diagnostics);
627
+ const missingAnchor = missingAnchorProblem(selection, task);
628
+ if (missingAnchor) checks.problems.push(missingAnchor);
606
629
  const scope = scopeEvidence(
607
630
  repo,
608
631
  task,
@@ -68,7 +68,21 @@ function parseTasks(content) {
68
68
  });
69
69
  }
70
70
  for (let index = 0; index < tasks.length; index += 1) {
71
- const end = index + 1 < tasks.length ? tasks[index + 1].line : lines.length;
71
+ // A task body ends at the next task or the next `##` heading, whichever
72
+ // comes first. Without the heading bound a change-level section such as
73
+ // `## Invalidates` was appended to whichever field was open last — the
74
+ // Evidence, in every shipped template — so a token quoted there made the
75
+ // Evidence non-concrete and the gate blamed a task that was fine.
76
+ const nextTask =
77
+ index + 1 < tasks.length ? tasks[index + 1].line : lines.length;
78
+ let end = nextTask;
79
+ for (let cursor = tasks[index].line + 1; cursor < nextTask; cursor += 1) {
80
+ if (/^\s*##\s/.test(lines[cursor])) {
81
+ end = cursor;
82
+ break;
83
+ }
84
+ }
85
+ tasks[index].endLine = end;
72
86
  const bodyLines = lines.slice(tasks[index].line, end);
73
87
  tasks[index].body = bodyLines.join("\n");
74
88
  tasks[index].fields = new Map();