@christang/keel 5.3.7 → 5.3.9

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.9 -->
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.9",
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.9",
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.9",
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",
@@ -5,8 +5,10 @@
5
5
  // file edits while an explicit keel/guard.json manifest is active. Absence of
6
6
  // the manifest allows everything silently; a present-but-untrusted manifest
7
7
  // fails closed. The hook never writes state, never spawns the keel CLI, and
8
- // always exits 0 — denial is expressed only through hook output. Paths that
9
- // resolve outside the repository root are not product writes and pass through.
8
+ // always exits 0 — denial is expressed only through hook output. The
9
+ // repository is the guard's scope: a path resolving outside it is not a product
10
+ // write and passes through, decided before the manifest is read so that no
11
+ // manifest state can reach it.
10
12
 
11
13
  const crypto = require("crypto");
12
14
  const fs = require("fs");
@@ -116,12 +118,27 @@ function main() {
116
118
  }
117
119
  const repo =
118
120
  typeof event.cwd === "string" && event.cwd ? event.cwd : process.cwd();
119
- const manifestPath = path.join(repo, "keel", "guard.json");
120
- if (!fs.existsSync(manifestPath)) return 0;
121
121
 
122
122
  const pathField = FILE_EDIT_TOOLS.get(event.tool_name);
123
123
  if (!pathField) return 0;
124
124
 
125
+ // The repository is the guard's scope, so this boundary is settled before the
126
+ // manifest is read at all. It needs only the event's cwd and target, so no
127
+ // manifest state — absent, invalid, drifted, or completed — has anything to
128
+ // say about a path outside it. Deciding it here rather than further down is
129
+ // what stops a branch added to the manifest section from denying a file the
130
+ // guard never protected; that ordering has already failed twice.
131
+ const target = event.tool_input ? event.tool_input[pathField] : null;
132
+ if (typeof target !== "string" || !target) return 0;
133
+ const relative = path.relative(repo, path.resolve(repo, target));
134
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
135
+ return 0;
136
+ }
137
+ const candidate = relative.replace(/\\/g, "/");
138
+
139
+ const manifestPath = path.join(repo, "keel", "guard.json");
140
+ if (!fs.existsSync(manifestPath)) return 0;
141
+
125
142
  let manifest = null;
126
143
  try {
127
144
  manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
@@ -144,14 +161,6 @@ function main() {
144
161
  // stop the one thing the completion gate is waiting for. Derived from the
145
162
  // manifest's existing `change` field, so no manifest shape changes.
146
163
  const recordPrefix = `openspec/changes/${manifest.change}/`;
147
-
148
- const target = event.tool_input ? event.tool_input[pathField] : null;
149
- if (typeof target !== "string" || !target) return 0;
150
- const relative = path.relative(repo, path.resolve(repo, target));
151
- if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
152
- return 0;
153
- }
154
- const candidate = relative.replace(/\\/g, "/");
155
164
  if (candidate.startsWith(recordPrefix)) return 0;
156
165
 
157
166
  for (const entry of manifest.authority) {
@@ -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.9"
41
+ PROTOCOL_VERSION = "5.3.9"
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,475 @@ def fill_template_slots(text: str, comments: str = "strip") -> str:
5098
5132
  text = collapsed
5099
5133
 
5100
5134
 
5135
+ def validate_guard_scope_is_the_repository_scenario() -> int:
5136
+ """Issue #31: a decision needing no manifest sat downstream of reading one.
5137
+
5138
+ Whether a target lies outside the repository is computable from the event's
5139
+ cwd and target path alone, but the invalid-manifest denial ran first, so a
5140
+ corrupt `keel/guard.json` denied writes to files the guard never protected.
5141
+ The precedence is what is asserted here, not the passthrough: a scenario
5142
+ checking only that an out-of-repo path passes under a valid manifest would
5143
+ have passed before this change too.
5144
+ """
5145
+ hook = ROOT / "plugins/keel/scripts/pretooluse-guard.js"
5146
+ if not hook.is_file():
5147
+ report(f"guard-scope-is-the-repository: missing hook {hook}.")
5148
+ return 1
5149
+
5150
+ with tempfile.TemporaryDirectory(prefix="keel-guard-scope-") as raw:
5151
+ root = Path(raw)
5152
+ repo = root / "repo"
5153
+ outside = root / "scratch"
5154
+ outside.mkdir(parents=True)
5155
+ write_text(repo / "src/feature.js", "// product\n")
5156
+ # A live spec, so the Covers source lands outside the record layer and
5157
+ # authority drift can actually fire. Drifting the change's own tasks.md
5158
+ # produces no drift at all, because the record layer exempts it.
5159
+ live = repo / "openspec/specs/demo-cap/spec.md"
5160
+ write_text(
5161
+ live,
5162
+ "# demo-cap\n\n## Purpose\n\nFixture.\n\n"
5163
+ "### Requirement: The system emits a feed status\n"
5164
+ "The system SHALL emit the recorded feed status.\n\n"
5165
+ "#### Scenario: A status is emitted\n"
5166
+ "- **WHEN** the feed runs\n- **THEN** the status is recorded\n",
5167
+ )
5168
+ write_text(
5169
+ repo / "openspec/changes/demo/tasks.md",
5170
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
5171
+ "- [ ] 1.1 Exercise the guard\n"
5172
+ " - Covers:\n - demo-cap / The system emits a feed status\n"
5173
+ " - Touch:\n - src/feature.js\n"
5174
+ " - Verify:\n - Strategy: evidence-first\n - M1: node test.js\n"
5175
+ " - Evidence:\n - Contract: pending\n - M1: pending\n"
5176
+ " - Review:\n - Status: pending\n"
5177
+ " - Acceptance check: pending\n - Scope check: pending\n"
5178
+ " - Findings: pending\n - Blocker: none\n",
5179
+ )
5180
+ started = run_keel(
5181
+ repo, "gate", "task-start", "--change", "demo", "--task", "1.1",
5182
+ "--record", "--json",
5183
+ )
5184
+ if started.returncode != 0:
5185
+ report("guard-scope-is-the-repository: the fixture did not start.")
5186
+ report((started.stdout or started.stderr).strip())
5187
+ return 1
5188
+ manifest_path = repo / "keel/guard.json"
5189
+ manifest_text = manifest_path.read_text(encoding="utf-8")
5190
+ hashed = [
5191
+ item["path"] for item in json.loads(manifest_text).get("authority", [])
5192
+ ]
5193
+ if "openspec/specs/demo-cap/spec.md" not in hashed:
5194
+ report(
5195
+ "guard-scope-is-the-repository: the fixture hashed no authority "
5196
+ f"outside the change directory, so drift cannot fire: {hashed}."
5197
+ )
5198
+ return 1
5199
+
5200
+ def decide(target: Path) -> str:
5201
+ event = json.dumps({
5202
+ "cwd": str(repo),
5203
+ "tool_name": "Edit",
5204
+ "tool_input": {"file_path": str(target)},
5205
+ })
5206
+ result = subprocess.run(
5207
+ ["node", str(hook)],
5208
+ input=event,
5209
+ capture_output=True,
5210
+ text=True,
5211
+ encoding="utf-8",
5212
+ errors="replace",
5213
+ check=False,
5214
+ )
5215
+ out = (result.stdout or "").strip()
5216
+ if not out:
5217
+ return "allow"
5218
+ payload = json.loads(out)["hookSpecificOutput"]
5219
+ return payload.get("permissionDecisionReason", "deny")
5220
+
5221
+ def expect(label: str, target: Path, allow: bool, needle: str = "") -> bool:
5222
+ verdict = decide(target)
5223
+ if allow:
5224
+ if verdict != "allow":
5225
+ report(f"guard-scope-is-the-repository: {label} was denied.")
5226
+ report(f" {verdict}")
5227
+ return False
5228
+ return True
5229
+ if verdict == "allow":
5230
+ report(f"guard-scope-is-the-repository: {label} was allowed.")
5231
+ return False
5232
+ if needle and needle not in verdict:
5233
+ report(
5234
+ f"guard-scope-is-the-repository: {label} was denied for the "
5235
+ f"wrong reason; expected {needle!r}."
5236
+ )
5237
+ report(f" {verdict}")
5238
+ return False
5239
+ return True
5240
+
5241
+ scratch = outside / "notes.md"
5242
+ # M2 — the in-repository denials must survive the reordering.
5243
+ checks = [
5244
+ expect("an in-Touch write", repo / "src/feature.js", True),
5245
+ expect(
5246
+ "an in-repository path outside Touch",
5247
+ repo / "other.js",
5248
+ False,
5249
+ "outside Touch",
5250
+ ),
5251
+ expect(
5252
+ "the guarded change's own records",
5253
+ repo / "openspec/changes/demo/notes.md",
5254
+ True,
5255
+ ),
5256
+ expect("an out-of-repository write", scratch, True),
5257
+ ]
5258
+
5259
+ # M1, first half — genuine authority drift.
5260
+ write_text(live, live.read_text(encoding="utf-8") + "\nDRIFTED\n")
5261
+ checks.append(
5262
+ expect("an in-Touch write under drift", repo / "src/feature.js", False, "drift")
5263
+ )
5264
+ checks.append(
5265
+ expect("an out-of-repository write under drift", scratch, True)
5266
+ )
5267
+
5268
+ # M1, second half — the corrupt manifest, which is the reported defect.
5269
+ manifest_path.write_text("{ not json", encoding="utf-8")
5270
+ checks.append(
5271
+ expect("an out-of-repository write under a corrupt manifest", scratch, True)
5272
+ )
5273
+ checks.append(
5274
+ expect(
5275
+ "an in-repository write under a corrupt manifest",
5276
+ repo / "src/feature.js",
5277
+ False,
5278
+ "invalid",
5279
+ )
5280
+ )
5281
+ if not all(checks):
5282
+ return 1
5283
+ if "guard-scope-is-the-repository" not in {name for name, _ in SCENARIOS}:
5284
+ report(
5285
+ "guard-scope-is-the-repository: the scenario registry does not "
5286
+ "include it."
5287
+ )
5288
+ return 1
5289
+ report("guard-scope-is-the-repository scenario passed.")
5290
+ return 0
5291
+
5292
+
5293
+ def validate_completion_requires_a_recorded_anchor_scenario() -> int:
5294
+ """Issue #30: an unrecorded anchor made the drift guarantee conditional.
5295
+
5296
+ `anchoredFingerprint` returns null for a non-digest value and completion
5297
+ then skipped the comparison entirely, so a task with `Contract: pending`
5298
+ passed with zero problems. It could be implemented against one contract,
5299
+ have its Touch or Verify rewritten mid-flight, and complete clean — purely
5300
+ by never running `task-start --record`. 5.3.7 closed the inference path;
5301
+ this closes the explicitly named one.
5302
+ """
5303
+ header = "# Tasks\n\n## Invalidates\n\n- None.\n\n"
5304
+
5305
+ def task(contract: str) -> str:
5306
+ return (
5307
+ "- [ ] 1.1 Exercise task contract\n"
5308
+ " - Covers:\n"
5309
+ " - E1: Public behavior passes.\n"
5310
+ " - Touch:\n"
5311
+ " - src/feature.js\n"
5312
+ " - Verify:\n"
5313
+ " - Strategy: evidence-first\n"
5314
+ " - M1: node test.js asserts the recorded feed status\n"
5315
+ " - Evidence:\n"
5316
+ f" - Contract: {contract}\n"
5317
+ " - M1: the suite passed\n"
5318
+ " - Review:\n"
5319
+ " - Status: pass\n"
5320
+ " - Acceptance check: behavior asserted at the interface\n"
5321
+ " - Scope check: only Touch files changed\n"
5322
+ " - Findings: none\n"
5323
+ " - Blocker: none\n"
5324
+ )
5325
+
5326
+ with tempfile.TemporaryDirectory(prefix="keel-anchor-required-") as raw:
5327
+ repo = Path(raw)
5328
+ write_text(repo / "openspec/changes/unrecorded/tasks.md", header + task("pending"))
5329
+
5330
+ def gate(change: str, stage: str) -> dict:
5331
+ return json.loads(
5332
+ run_keel(
5333
+ repo,
5334
+ "gate",
5335
+ stage,
5336
+ "--change",
5337
+ change,
5338
+ "--task",
5339
+ "1.1",
5340
+ "--json",
5341
+ ).stdout
5342
+ )
5343
+
5344
+ payload = gate("unrecorded", "task-complete")
5345
+ if payload.get("status") == "pass":
5346
+ report(
5347
+ "completion-requires-a-recorded-anchor: an explicitly named task "
5348
+ "with `Contract: pending` passed task-complete, so the "
5349
+ "fingerprint comparison still compares nothing."
5350
+ )
5351
+ return 1
5352
+ named = [
5353
+ problem.get("message", "")
5354
+ for problem in payload.get("problems", [])
5355
+ if problem.get("code") == "missing-contract-anchor"
5356
+ ]
5357
+ if not named:
5358
+ report(
5359
+ "completion-requires-a-recorded-anchor: the task did not pass, "
5360
+ "but no missing-contract-anchor diagnostic explained why."
5361
+ )
5362
+ for problem in payload.get("problems", []):
5363
+ report(f" {problem.get('code')}: {problem.get('message')}")
5364
+ return 1
5365
+ for needle in ("Contract", "--record"):
5366
+ if needle not in named[0]:
5367
+ report(
5368
+ "completion-requires-a-recorded-anchor: the diagnostic did "
5369
+ f"not name {needle}."
5370
+ )
5371
+ report(named[0])
5372
+ return 1
5373
+ # Doing what the diagnostic asks must clear it. The fingerprint is the
5374
+ # one this task actually compiles to, so the anchor comparison that
5375
+ # follows is a real comparison rather than a shape check.
5376
+ started = gate("unrecorded", "task-start")
5377
+ fingerprint = started.get("contract", {}).get("fingerprint", {}).get("value")
5378
+ if not fingerprint:
5379
+ report(
5380
+ "completion-requires-a-recorded-anchor: task-start returned no "
5381
+ "fingerprint to record."
5382
+ )
5383
+ return 1
5384
+ if any(
5385
+ problem.get("code") == "missing-contract-anchor"
5386
+ for problem in started.get("problems", [])
5387
+ ):
5388
+ report(
5389
+ "completion-requires-a-recorded-anchor: task-start reported the "
5390
+ "missing anchor, but it runs before one can exist."
5391
+ )
5392
+ return 1
5393
+ write_text(
5394
+ repo / "openspec/changes/recorded/tasks.md",
5395
+ header + task(f"keel-task-capsule/v1 sha256:{fingerprint}"),
5396
+ )
5397
+ recorded = gate("recorded", "task-complete")
5398
+ if recorded.get("status") != "pass":
5399
+ report(
5400
+ "completion-requires-a-recorded-anchor: recording the anchor did "
5401
+ "not clear the refusal."
5402
+ )
5403
+ for problem in recorded.get("problems", []):
5404
+ report(f" {problem.get('code')}: {problem.get('message')}")
5405
+ return 1
5406
+ if "completion-requires-a-recorded-anchor" not in {name for name, _ in SCENARIOS}:
5407
+ report(
5408
+ "completion-requires-a-recorded-anchor: the scenario registry does "
5409
+ "not include it."
5410
+ )
5411
+ return 1
5412
+ report("completion-requires-a-recorded-anchor scenario passed.")
5413
+ return 0
5414
+
5415
+
5416
+ def validate_task_body_ends_at_heading_scenario() -> int:
5417
+ """Issue #29: a change-level section was read as the last task's Evidence.
5418
+
5419
+ parseTasks gave a task every line up to the next task or EOF, and a `##`
5420
+ heading did not stop it, so `## Invalidates` and `## Expectation Coverage`
5421
+ landed in whichever field was open last. That made the two checks look
5422
+ contradictory: `invalidation-phrase` requires the searchable wording in
5423
+ double quotes, while the concreteness test rejects an angle-bracket slot
5424
+ outside inline code, so an entry quoting wording that carries one could
5425
+ satisfy neither. They were never in conflict — the parser only made them
5426
+ appear so.
5427
+ """
5428
+ slot = "<" + "n" + ">"
5429
+ header = "# Tasks\n\n"
5430
+
5431
+ def task(number: str, contract: str) -> str:
5432
+ return (
5433
+ f"- [ ] {number} Exercise task contract\n"
5434
+ " - Covers:\n"
5435
+ " - E1: Public behavior passes.\n"
5436
+ " - Touch:\n"
5437
+ " - src/feature.js\n"
5438
+ " - Verify:\n"
5439
+ " - Strategy: evidence-first\n"
5440
+ " - M1: node test.js asserts the recorded feed status\n"
5441
+ " - Evidence:\n"
5442
+ f" - Contract: {contract}\n"
5443
+ " - M1: the suite passed\n"
5444
+ " - Review:\n"
5445
+ " - Status: pass\n"
5446
+ " - Acceptance check: behavior asserted at the interface\n"
5447
+ " - Scope check: only Touch files changed\n"
5448
+ " - Findings: none\n"
5449
+ " - Blocker: none\n"
5450
+ )
5451
+
5452
+ # The entry quotes stale wording carrying an unfilled slot, which is exactly
5453
+ # what issue #16 asks an invalidation to quote. A stray Contract line sits
5454
+ # in the trailing section to prove the anchor search stops at the heading.
5455
+ sections = (
5456
+ "\n## Invalidates\n\n"
5457
+ f'- I1: "an unresolved Q{slot} without an authorized fallback blocks '
5458
+ 'implementation" — the schema prose. Updated by: 1.1\n'
5459
+ " - Contract: keel-task-capsule/v1 sha256:" + "b" * 64 + "\n"
5460
+ "\n## Expectation Coverage\n\n"
5461
+ f"- E1: Every Q{slot} reference resolves Covered by: 1.1\n"
5462
+ )
5463
+ body = header + task("1.1", "pending") + "\n## 2. Second group\n\n" + task(
5464
+ "2.1", "pending"
5465
+ )
5466
+
5467
+ with tempfile.TemporaryDirectory(prefix="keel-task-extent-") as raw:
5468
+ repo = Path(raw)
5469
+ write_text(repo / "openspec/changes/demo/tasks.md", body + sections)
5470
+ started = run_keel(
5471
+ repo, "gate", "task-start", "--change", "demo", "--task", "2.1", "--json"
5472
+ )
5473
+ payload = json.loads(started.stdout)
5474
+ problems = payload.get("problems", [])
5475
+ if payload.get("status") != "pass":
5476
+ report(
5477
+ "task-body-ends-at-heading: the last task did not pass task-start "
5478
+ "with a trailing section quoting an unfilled slot."
5479
+ )
5480
+ for problem in problems:
5481
+ report(f" {problem.get('code')}: {problem.get('message')}")
5482
+ return 1
5483
+ # The same run proves the phrase check is satisfied: an entry the phrase
5484
+ # check rejected would have produced invalidation-phrase above.
5485
+ malformed = body + (
5486
+ "\n## Invalidates\n\n"
5487
+ "- I1: the schema prose is stale. Updated by: 1.1\n"
5488
+ )
5489
+ write_text(repo / "openspec/changes/unquoted/tasks.md", malformed)
5490
+ unquoted = json.loads(
5491
+ run_keel(
5492
+ repo,
5493
+ "gate",
5494
+ "task-start",
5495
+ "--change",
5496
+ "unquoted",
5497
+ "--task",
5498
+ "2.1",
5499
+ "--json",
5500
+ ).stdout
5501
+ )
5502
+ if not any(
5503
+ problem.get("code") == "invalidation-phrase"
5504
+ for problem in unquoted.get("problems", [])
5505
+ ):
5506
+ report(
5507
+ "task-body-ends-at-heading: an unquoted invalidation entry was "
5508
+ "accepted, so the phrase check is no longer being satisfied by "
5509
+ "the quoted one."
5510
+ )
5511
+ return 1
5512
+ # A group heading must not be appended to the preceding task's field.
5513
+ first = json.loads(
5514
+ run_keel(
5515
+ repo, "gate", "task-start", "--change", "demo", "--task", "1.1", "--json"
5516
+ ).stdout
5517
+ )
5518
+ evidence = json.dumps(first)
5519
+ if "Second group" in evidence:
5520
+ report(
5521
+ "task-body-ends-at-heading: the group heading leaked into the "
5522
+ "preceding task's fields."
5523
+ )
5524
+ return 1
5525
+ # --record must anchor the last task's own Contract line, not the stray
5526
+ # one planted in the trailing section.
5527
+ recorded = run_keel(
5528
+ repo,
5529
+ "gate",
5530
+ "task-start",
5531
+ "--change",
5532
+ "demo",
5533
+ "--task",
5534
+ "2.1",
5535
+ "--record",
5536
+ "--json",
5537
+ )
5538
+ if recorded.returncode != 0:
5539
+ report("task-body-ends-at-heading: --record failed on the last task.")
5540
+ report((recorded.stdout or recorded.stderr).strip())
5541
+ return 1
5542
+ written = (repo / "openspec/changes/demo/tasks.md").read_text(
5543
+ encoding="utf-8"
5544
+ )
5545
+ after_heading = written.split("## Invalidates", 1)[1]
5546
+ if "b" * 64 not in after_heading:
5547
+ report(
5548
+ "task-body-ends-at-heading: --record overwrote the Contract line "
5549
+ "planted inside the trailing section."
5550
+ )
5551
+ return 1
5552
+ # The extent change must move no fingerprint: an anchor that shifted
5553
+ # would drift every live change in every consumer repo at once. Pinned
5554
+ # rather than merely measured, so a future extent change cannot move one
5555
+ # silently. A deliberate capsule-shape change will fail here too — that
5556
+ # is the point; it should be looked at, not absorbed.
5557
+ pinned = {
5558
+ "1.1": "2f723a8778160a2d51cd91e34255bf19f2c654fa23cdcd7b013915727a541d17",
5559
+ "2.1": "5e0481362b06992d0317c91d34bbd5d6746fb9c1fee47566060f61fbba7cbf05",
5560
+ }
5561
+ plain = (
5562
+ "# Tasks\n\n"
5563
+ + task("1.1", "pending")
5564
+ + "\n## 2. Second group\n\n"
5565
+ + task("2.1", "pending")
5566
+ + '\n## Invalidates\n\n- I1: "the schema prose is stale here" — the '
5567
+ "schema. Updated by: 1.1\n\n## Expectation Coverage\n\n"
5568
+ "- E1: Covered by: 1.1\n"
5569
+ )
5570
+ write_text(repo / "openspec/changes/pinned/tasks.md", plain)
5571
+ for task_id, expected in pinned.items():
5572
+ payload = json.loads(
5573
+ run_keel(
5574
+ repo,
5575
+ "gate",
5576
+ "task-start",
5577
+ "--change",
5578
+ "pinned",
5579
+ "--task",
5580
+ task_id,
5581
+ "--json",
5582
+ ).stdout
5583
+ )
5584
+ actual = (
5585
+ payload.get("contract", {}).get("fingerprint", {}).get("value")
5586
+ )
5587
+ if actual != expected:
5588
+ report(
5589
+ "task-body-ends-at-heading: the compiled fingerprint for "
5590
+ f"task {task_id} moved. Expected {expected}, got {actual}. "
5591
+ "An extent or capsule-shape change that moves an anchor "
5592
+ "drifts every live change in every consumer repo."
5593
+ )
5594
+ return 1
5595
+ if "task-body-ends-at-heading" not in {name for name, _ in SCENARIOS}:
5596
+ report(
5597
+ "task-body-ends-at-heading: the scenario registry does not include it."
5598
+ )
5599
+ return 1
5600
+ report("task-body-ends-at-heading scenario passed.")
5601
+ return 0
5602
+
5603
+
5101
5604
  TASKS_TEMPLATE_RELATIVE = "schemas/keel-spec-driven/templates/tasks.md"
5102
5605
 
5103
5606
 
@@ -5876,6 +6379,7 @@ def tracker_owner_tasks(findings: str, closure: str) -> str:
5876
6379
  " - Stop Rules:\n"
5877
6380
  " - Stop on failure.\n"
5878
6381
  " - Evidence:\n"
6382
+ " - Contract: pending\n"
5879
6383
  " - M1: passed\n"
5880
6384
  " - Review:\n"
5881
6385
  " - Status: pass\n"
@@ -5910,6 +6414,7 @@ def validate_tracker_durable_owner_scenario() -> int:
5910
6414
 
5911
6415
  def complete(findings: str, closure: str = "Covered by: 1.1"):
5912
6416
  write_text(tasks, tracker_owner_tasks(findings, closure))
6417
+ record_contract_anchor(repo, "demo")
5913
6418
  return run_keel(
5914
6419
  repo, "gate", "task-complete",
5915
6420
  "--change", "demo", "--task", "1.1", "--json",
@@ -6331,6 +6836,7 @@ def validate_task_capsule_scenario() -> int:
6331
6836
  repo / "openspec/changes/demo/tasks.md",
6332
6837
  completion_task,
6333
6838
  )
6839
+ record_contract_anchor(repo, "demo")
6334
6840
  completed = run_keel(
6335
6841
  repo,
6336
6842
  "gate",
@@ -7025,6 +7531,10 @@ def validate_core_gates_scenario() -> int:
7025
7531
  " - Stop Rules:\n"
7026
7532
  " - Stop on final assertion failure.\n"
7027
7533
  " - Evidence:\n"
7534
+ # Read at call time, so every variant below carries the anchor
7535
+ # recorded once for this fixture. Evidence is not in the capsule,
7536
+ # so one fingerprint is correct for all of them.
7537
+ f" - Contract: {completion_anchor}\n"
7028
7538
  f" - M1: {evidence}\n"
7029
7539
  " - Review:\n"
7030
7540
  f" - Status: {review_status}\n"
@@ -7038,6 +7548,19 @@ def validate_core_gates_scenario() -> int:
7038
7548
  " - Summary\n"
7039
7549
  )
7040
7550
 
7551
+ completion_anchor = "pending"
7552
+ write_text(completion_tasks, completion_task("pending", "pass"))
7553
+ if not record_contract_anchor(completion_repo, "demo"):
7554
+ report("core-gates scenario could not record the completion anchor.")
7555
+ return 1
7556
+ recorded_line = re.search(
7557
+ r"-\s*Contract:\s*(.+)", completion_tasks.read_text(encoding="utf-8")
7558
+ )
7559
+ if not recorded_line:
7560
+ report("core-gates scenario found no recorded Contract anchor.")
7561
+ return 1
7562
+ completion_anchor = recorded_line.group(1).strip()
7563
+
7041
7564
  write_text(completion_tasks, completion_task("pending", "pass"))
7042
7565
  missing_evidence = run_keel(
7043
7566
  completion_repo,
@@ -7840,6 +8363,7 @@ def validate_scope_rename_attribution_scenario() -> int:
7840
8363
  " - Stop Rules:\n"
7841
8364
  " - Stop on final assertion failure.\n"
7842
8365
  " - Evidence:\n"
8366
+ " - Contract: pending\n"
7843
8367
  " - M1: passed\n"
7844
8368
  " - Review:\n"
7845
8369
  " - Status: pass\n"
@@ -7883,6 +8407,7 @@ def validate_scope_rename_attribution_scenario() -> int:
7883
8407
  report("scope-rename scenario git mv failed:")
7884
8408
  report((moved.stderr or moved.stdout).strip())
7885
8409
  return 1
8410
+ record_contract_anchor(repo, "demo")
7886
8411
  completed = run_keel(
7887
8412
  repo,
7888
8413
  "gate",
@@ -9244,6 +9769,7 @@ def validate_task_verification_strategies_scenario() -> int:
9244
9769
 
9245
9770
  def run_completion(fixture: str) -> subprocess.CompletedProcess[str]:
9246
9771
  write_text(tasks_path, fixture)
9772
+ record_contract_anchor(repo, "demo")
9247
9773
  return run_keel(
9248
9774
  repo,
9249
9775
  "gate",
@@ -9983,6 +10509,7 @@ def _goal_task_block(
9983
10509
  " - Stop Rules:",
9984
10510
  " - stop on failure",
9985
10511
  " - Evidence:",
10512
+ " - Contract: pending",
9986
10513
  ]
9987
10514
  )
9988
10515
  if filled:
@@ -10686,6 +11213,7 @@ def validate_native_goal_gate_order_scenario() -> int:
10686
11213
 
10687
11214
  # 4. With evidence and a passing Review recorded, task-complete passes.
10688
11215
  write_text(tasks_path, _goal_tasks_file([_goal_task_block(filled=True)]))
11216
+ record_contract_anchor(repo, "sample-change")
10689
11217
  completed = run_keel(
10690
11218
  repo, "gate", "task-complete",
10691
11219
  "--change", "sample-change", "--task", "1.1", "--json",
@@ -11249,6 +11777,7 @@ def validate_single_task_goal_real_tasks_scenario() -> int:
11249
11777
  [_goal_task_block(strategy=strategy, filled=True, redgreen=redgreen)]
11250
11778
  ),
11251
11779
  )
11780
+ record_contract_anchor(repo, "sample-change")
11252
11781
  done = run_keel(
11253
11782
  repo, "gate", "task-complete",
11254
11783
  "--change", "sample-change", "--task", "1.1", "--json", env=env,
@@ -11544,6 +12073,7 @@ def _single_task_matrix_target(target: str, root: Path) -> int:
11544
12073
  ]
11545
12074
  ),
11546
12075
  )
12076
+ record_contract_anchor(repo, "sample-change")
11547
12077
  done = run_keel(repo, "gate", "task-complete", "--change", "sample-change", "--task", "1.1", "--json", env=env)
11548
12078
  if done.returncode != 0:
11549
12079
  report("native-single-task-matrix %s could not complete with evidence." % target)
@@ -13768,6 +14298,15 @@ SCENARIOS: tuple = (
13768
14298
  "task-complete-selection-requires-a-started-task",
13769
14299
  validate_task_complete_selection_requires_a_started_task_scenario,
13770
14300
  ),
14301
+ ("task-body-ends-at-heading", validate_task_body_ends_at_heading_scenario),
14302
+ (
14303
+ "completion-requires-a-recorded-anchor",
14304
+ validate_completion_requires_a_recorded_anchor_scenario,
14305
+ ),
14306
+ (
14307
+ "guard-scope-is-the-repository",
14308
+ validate_guard_scope_is_the_repository_scenario,
14309
+ ),
13771
14310
  ("spec-template-validates", validate_spec_template_validates_scenario),
13772
14311
  (
13773
14312
  "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();