@christang/keel 5.63.0 → 5.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -375,6 +375,45 @@ set — fails `task-start` by name rather than sitting in the contract doing not
375
375
  `Fails with:` marker that names no literal. To *write about* the marker in a check without declaring
376
376
  one, put it in inline code.
377
377
 
378
+ #### A red can be honest and the check still immune
379
+
380
+ A signature predicts the red of an **absent** feature. It says nothing about the red of a **broken**
381
+ one, and the two can be unrelated. A consistency check asserting two tool outputs agree had a real
382
+ red, a correct signature, a real green — and stayed green through a 1000× unit error, the one thing
383
+ it existed to catch, because its tolerance carried a default absolute floor. The only way to find
384
+ that is to put the defect in and watch.
385
+
386
+ `Detects:` declares that injection — the mutation, and the failure it must produce:
387
+
388
+ ```
389
+ - M1: `pytest tests/test_fmax.py` asserts synth fmax == sta fmax. Fails with: `AttributeError` Detects: `sed -i s/0.0005/0.5/ run_sta.py` -> `assert 5e-16 == 5e-13`
390
+ ```
391
+
392
+ Completion requires that second literal in the check's `.detects` Evidence. Clauses chain, so a check
393
+ may carry both — the example above is one check, one line.
394
+
395
+ **Keel does not run the mutation and does not judge it.** It records the claim and puts it where
396
+ review can see it, the same standing every other check result has. You can declare an injection any
397
+ check would catch; what the clause buys is that you decided it before the run, and that editing it
398
+ afterwards moves the fingerprint.
399
+
400
+ A `(regression)` check **may** declare one, and is the best place for it: such a check has no honest
401
+ red by construction, so an injection is the only thing that can show it is not vacuous.
402
+
403
+ #### A number can claim to be a measurement
404
+
405
+ `Basis:`, Evidence prose and `Findings` are free text, and a number in them reads the same whether it
406
+ was measured, estimated, or remembered. `Measured:` binds one to the output behind it:
407
+
408
+ ```
409
+ - M1: `node bench.js` reports the fanout load. Measured: `1799.9`
410
+ ```
411
+
412
+ Completion requires that literal in the check's own `M<n>` Evidence — the entry holding the command
413
+ and its output. Opt-in, and deliberately not a rule over every number: measured against this
414
+ repository's archive, that rule would reach 847 inline-code spans, mostly version strings, counts,
415
+ and quoted references that appear in no command output, and each would be a false stop.
416
+
378
417
  ## Domain lenses
379
418
 
380
419
  Keel's core is pure process; it ships no domain knowledge and no decisions of its own. Alongside
@@ -1,4 +1,4 @@
1
- <!-- keel:start version=5.63.0 -->
1
+ <!-- keel:start version=5.65.0 -->
2
2
  ## Keel Bootstrap
3
3
 
4
4
  - Start every session with `keel context`; OpenSpec artifacts and Git are the only durable authority — never native memory, goals, or transcripts.
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@christang/keel",
3
3
  "displayName": "Keel",
4
4
  "description": "Keel OpenSpec execution discipline CLI for Claude Code, Codex, and OpenCode.",
5
- "version": "5.63.0",
5
+ "version": "5.65.0",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keel",
3
- "version": "5.63.0",
3
+ "version": "5.65.0",
4
4
  "description": "Keel OpenSpec execution discipline: stateless continuity, task capsules, deterministic gates, and expectation alignment for Codex and Claude Code.",
5
5
  "author": {
6
6
  "name": "TanglmChris",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keel",
3
- "version": "5.63.0",
3
+ "version": "5.65.0",
4
4
  "description": "Keel OpenSpec execution discipline: stateless continuity, task capsules, deterministic gates, and expectation alignment for Codex and Claude Code.",
5
5
  "author": {
6
6
  "name": "TanglmChris",
@@ -38,8 +38,8 @@ REQUIRED_SCRIPTS = [
38
38
  "scripts/validate_plugin.py",
39
39
  ]
40
40
 
41
- PACKAGE_VERSION = "5.63.0"
42
- PROTOCOL_VERSION = "5.63.0"
41
+ PACKAGE_VERSION = "5.65.0"
42
+ PROTOCOL_VERSION = "5.65.0"
43
43
  LEGACY_MANAGED_START = "<!-- keel:start version=2.1 -->"
44
44
  OPENSPEC_SCHEMA_NAME = "keel-spec-driven"
45
45
  # Mirrors KEEL_PACKAGE_NAME in scripts/install_to_repo.py, one of the two
@@ -28061,6 +28061,536 @@ def validate_the_routing_rule_reaches_the_decision_scenario() -> int:
28061
28061
  return 0
28062
28062
 
28063
28063
 
28064
+ def validate_a_claim_names_what_would_falsify_it_scenario() -> int:
28065
+ """Issue #132: an honest red does not mean the check can catch the defect.
28066
+
28067
+ A consistency check whose red was real, whose signature predicted it, and
28068
+ whose green was real stayed green through a 1000x unit error — the one thing
28069
+ it existed to catch — because `pytest.approx` carries a default absolute
28070
+ tolerance. `.red` proves the check failed before the implementation existed
28071
+ and `Fails with:` predicts the red of an *absent* feature; neither says
28072
+ anything about the red of a *broken* one. `Detects:` declares the injection
28073
+ that answers the third question, in the same shape: inside the check text, so
28074
+ it enters the fingerprint, and enforced by requiring its literal in Evidence.
28075
+ """
28076
+ label = "a-claim-names-what-would-falsify-it"
28077
+ mutation = "sed -i s/0.0005/0.5/ run_sta.py"
28078
+ failure = "assert 5e-16 == 5e-13"
28079
+
28080
+ with tempfile.TemporaryDirectory(prefix="keel-detects-") as raw:
28081
+ root = Path(raw)
28082
+
28083
+ def start(name: str, **kwargs) -> dict:
28084
+ return strategy_probe_start(root, name, strategy_probe_task(**kwargs))
28085
+
28086
+ def commands_of(payload: dict) -> list[dict]:
28087
+ contract = payload.get("contract") or {}
28088
+ capsule = contract.get("capsule") or {}
28089
+ return ((capsule.get("verification") or {}).get("commands") or [])
28090
+
28091
+ # M1 — two clauses on one check, both parsed. The failure signature comes
28092
+ # first, so this is also the assertion that a signature no longer has to
28093
+ # be the final clause.
28094
+ chained = (
28095
+ "M1: node test.js asserts the public behavior. "
28096
+ f"Fails with: `boom` Detects: `{mutation}` -> `{failure}`",
28097
+ )
28098
+ payload = start("chained", strategy="vertical-tdd", commands=chained)
28099
+ if payload.get("status") != "pass":
28100
+ report(
28101
+ f"{label}: Detects: is not parsed — a check carrying a failure "
28102
+ "signature followed by an injection clause was refused; got "
28103
+ f"{payload.get('status')!r} {problem_text(payload)!r}."
28104
+ )
28105
+ return 1
28106
+ compiled = commands_of(payload)
28107
+ if len(compiled) != 1:
28108
+ report(f"{label}: the chained check did not compile to one check; {compiled!r}")
28109
+ return 1
28110
+ entry = compiled[0]
28111
+ if entry.get("failsWith") != "boom":
28112
+ report(
28113
+ f"{label}: Detects: is not parsed — the failure signature was "
28114
+ f"lost when a clause followed it; got {entry!r}."
28115
+ )
28116
+ return 1
28117
+ detects = entry.get("detects") or {}
28118
+ if detects.get("mutation") != mutation or detects.get("failure") != failure:
28119
+ report(
28120
+ f"{label}: Detects: is not parsed — the compiled check does not "
28121
+ f"carry the declared injection; got {entry!r}."
28122
+ )
28123
+ return 1
28124
+ # The clause is inside the check text, so it is inside the fingerprint.
28125
+ # Without this, an injection edited after the run would be invisible.
28126
+ other = start(
28127
+ "chained-edited",
28128
+ strategy="vertical-tdd",
28129
+ commands=(
28130
+ "M1: node test.js asserts the public behavior. "
28131
+ f"Fails with: `boom` Detects: `{mutation}` -> `assert 1 == 2`",
28132
+ ),
28133
+ )
28134
+ first = str((payload.get("contract") or {}).get("fingerprint") or "")
28135
+ second = str((other.get("contract") or {}).get("fingerprint") or "")
28136
+ if not first or first == second:
28137
+ report(
28138
+ f"{label}: editing the declared injection did not move the "
28139
+ f"fingerprint; {first!r} vs {second!r}."
28140
+ )
28141
+ return 1
28142
+
28143
+ # M1, second half — the declaration is enforced at completion, against a
28144
+ # `.detects` Evidence entry for the same check. The clause is a claim Keel
28145
+ # records: it never runs the mutation, so what completion holds is that
28146
+ # the declared failure appears in what the author recorded.
28147
+ def complete(name: str, detects_evidence: str | None) -> dict:
28148
+ repo = root / name
28149
+ evidence = [
28150
+ " - Contract: pending",
28151
+ " - M1: pass. node test.js reported the behavior.",
28152
+ " - M1.red: fail. `boom` as predicted.",
28153
+ " - M1.green: pass.",
28154
+ ]
28155
+ if detects_evidence is not None:
28156
+ evidence.append(f" - M1.detects: {detects_evidence}")
28157
+ task = "\n".join(
28158
+ [
28159
+ "- [x] 1.1 Injection probe",
28160
+ " - Owner: claude",
28161
+ " - Mode: implementation",
28162
+ " - Covers:",
28163
+ " - E1: the task proves its own behavior",
28164
+ " - Read:",
28165
+ " - README.md",
28166
+ " - Touch:",
28167
+ " - src/example.js",
28168
+ " - Verify:",
28169
+ " - Strategy: vertical-tdd",
28170
+ " - M1: node test.js asserts the public behavior. "
28171
+ f"Fails with: `boom` Detects: `{mutation}` -> `{failure}`",
28172
+ " - Evidence:",
28173
+ *evidence,
28174
+ " - Review:",
28175
+ " - Status: pass",
28176
+ " - Acceptance check: the behavior is proven.",
28177
+ " - Scope check: only Touch changed.",
28178
+ " - Findings: none.",
28179
+ " - Blocker: none",
28180
+ " - Reauthorizations: none",
28181
+ ]
28182
+ )
28183
+ write_gate_fixture(repo, tasks=task)
28184
+ started = run_keel(
28185
+ repo, "gate", "task-start", "--change", "demo", "--task", "1.1",
28186
+ "--record", "--json",
28187
+ )
28188
+ try:
28189
+ anchor_value = str(
28190
+ (json.loads(started.stdout).get("contract") or {}).get(
28191
+ "fingerprint"
28192
+ )
28193
+ or ""
28194
+ )
28195
+ except json.JSONDecodeError:
28196
+ anchor_value = ""
28197
+ if not anchor_value:
28198
+ return {"status": "unstarted", "problems": [{"message": started.stdout[:300]}]}
28199
+ done = run_keel(
28200
+ repo, "gate", "task-complete", "--change", "demo", "--task", "1.1",
28201
+ "--json",
28202
+ )
28203
+ try:
28204
+ return json.loads(done.stdout)
28205
+ except json.JSONDecodeError:
28206
+ return {"status": "unparsed", "problems": [{"message": done.stdout[:400]}]}
28207
+
28208
+ absent = complete("no-detects", None)
28209
+ if absent.get("status") == "pass":
28210
+ report(
28211
+ f"{label}: Detects: is not enforced — a declared injection with "
28212
+ "no `.detects` Evidence completed cleanly."
28213
+ )
28214
+ return 1
28215
+ if "missing-injection-evidence" not in problem_codes(absent):
28216
+ report(
28217
+ f"{label}: Detects: is not enforced — an absent `.detects` was "
28218
+ f"refused under another diagnostic; {problem_codes(absent)!r} "
28219
+ f"{problem_text(absent)!r}."
28220
+ )
28221
+ return 1
28222
+ wrong = complete("wrong-detects", "ran the mutation; it still passed.")
28223
+ if wrong.get("status") == "pass":
28224
+ report(
28225
+ f"{label}: Detects: is not enforced — a `.detects` recording "
28226
+ "something other than the declared failure completed cleanly."
28227
+ )
28228
+ return 1
28229
+ if "injection-missing-declared-failure" not in problem_codes(wrong):
28230
+ report(
28231
+ f"{label}: a `.detects` lacking the declared failure was "
28232
+ f"refused under another diagnostic; {problem_codes(wrong)!r} "
28233
+ f"{problem_text(wrong)!r}."
28234
+ )
28235
+ return 1
28236
+ right = complete("good-detects", f"fail, as declared: `{failure}`.")
28237
+ if right.get("status") != "pass":
28238
+ report(
28239
+ f"{label}: a `.detects` carrying the declared failure was still "
28240
+ f"refused; {problem_codes(right)!r} {problem_text(right)!r}."
28241
+ )
28242
+ return 1
28243
+
28244
+ # M2 — a (regression) check may declare one. It has no honest red by
28245
+ # construction and is exempt from .red/.green, so an injection is the
28246
+ # only mechanism that can show it is not vacuous: this is where the
28247
+ # clause is worth most, which is why the report's suggested refusal here
28248
+ # was narrowed rather than adopted.
28249
+ regression = start(
28250
+ "regression",
28251
+ strategy="vertical-tdd",
28252
+ commands=(
28253
+ "M1: node test.js asserts the new behavior. Fails with: `boom`",
28254
+ f"M2 (regression): node all.js stays green. Detects: `{mutation}` -> `{failure}`",
28255
+ ),
28256
+ )
28257
+ if regression.get("status") != "pass":
28258
+ report(
28259
+ f"{label}: regression check may not declare an injection; got "
28260
+ f"{regression.get('status')!r} {problem_text(regression)!r}."
28261
+ )
28262
+ return 1
28263
+ second_entry = next(
28264
+ (e for e in commands_of(regression) if e.get("label") == "M2"), {}
28265
+ )
28266
+ if not (second_entry.get("detects") or {}).get("failure"):
28267
+ report(
28268
+ f"{label}: regression check may not declare an injection — the "
28269
+ f"clause was dropped; got {second_entry!r}."
28270
+ )
28271
+ return 1
28272
+
28273
+ # M3 — a malformed clause is named, not ignored. Both shapes: one literal
28274
+ # with no arrow, and an arrow with nothing after it.
28275
+ for name, clause in (
28276
+ ("one-literal", f"Detects: `{mutation}`"),
28277
+ ("no-target", f"Detects: `{mutation}` ->"),
28278
+ ):
28279
+ bad = start(
28280
+ name,
28281
+ strategy="vertical-tdd",
28282
+ commands=(f"M1: node test.js asserts the behavior. {clause}",),
28283
+ )
28284
+ if bad.get("status") == "pass":
28285
+ report(
28286
+ f"{label}: a malformed injection clause ({name}) was "
28287
+ "silently ignored, which leaves the author believing an "
28288
+ "injection is enforced when none was parsed."
28289
+ )
28290
+ return 1
28291
+ if "malformed-injection" not in problem_codes(bad):
28292
+ report(
28293
+ f"{label}: a malformed injection clause ({name}) was "
28294
+ "silently ignored under another diagnostic; got "
28295
+ f"{problem_codes(bad)!r} {problem_text(bad)!r}."
28296
+ )
28297
+ return 1
28298
+ # A marker inside inline code is quoted material, not a declaration —
28299
+ # which is what lets this repository's own tasks write about the clause.
28300
+ quoted = start(
28301
+ "quoted",
28302
+ strategy="vertical-tdd",
28303
+ commands=(
28304
+ "M1: node test.js asserts that a check may close with "
28305
+ "`Detects:` and two literals. Fails with: `boom`",
28306
+ ),
28307
+ )
28308
+ if quoted.get("status") != "pass":
28309
+ report(
28310
+ f"{label}: a clause named inside inline code was silently "
28311
+ f"ignored as a declaration; got {problem_text(quoted)!r}."
28312
+ )
28313
+ return 1
28314
+
28315
+ # 1.2 — `Measured:` binds a literal to the check's own recorded output.
28316
+ # The failure class it answers is a number that reads like a measurement
28317
+ # and is an estimate or a recollection; the one instance of it that was
28318
+ # caught in the reporting session was caught exactly this way, by sitting
28319
+ # in a clause the gate held against recorded output.
28320
+ measured_literal = "1799.9"
28321
+
28322
+ def complete_measured(name: str, m1_evidence: str, clause: str) -> dict:
28323
+ repo = root / name
28324
+ task = "\n".join(
28325
+ [
28326
+ "- [x] 1.1 Measurement probe",
28327
+ " - Owner: claude",
28328
+ " - Mode: implementation",
28329
+ " - Covers:",
28330
+ " - E1: the task proves its own behavior",
28331
+ " - Read:",
28332
+ " - README.md",
28333
+ " - Touch:",
28334
+ " - src/example.js",
28335
+ " - Verify:",
28336
+ " - Strategy: vertical-tdd",
28337
+ f" - M1: node test.js reports the fanout load.{clause}",
28338
+ " - Evidence:",
28339
+ " - Contract: pending",
28340
+ f" - M1: {m1_evidence}",
28341
+ " - M1.red: fail. the reader did not exist.",
28342
+ " - M1.green: pass.",
28343
+ " - Review:",
28344
+ " - Status: pass",
28345
+ " - Acceptance check: the behavior is proven.",
28346
+ " - Scope check: only Touch changed.",
28347
+ " - Findings: none.",
28348
+ " - Blocker: none",
28349
+ " - Reauthorizations: none",
28350
+ ]
28351
+ )
28352
+ write_gate_fixture(repo, tasks=task)
28353
+ run_keel(
28354
+ repo, "gate", "task-start", "--change", "demo", "--task", "1.1",
28355
+ "--record", "--json",
28356
+ )
28357
+ done = run_keel(
28358
+ repo, "gate", "task-complete", "--change", "demo", "--task", "1.1",
28359
+ "--json",
28360
+ )
28361
+ try:
28362
+ return json.loads(done.stdout)
28363
+ except json.JSONDecodeError:
28364
+ return {"status": "unparsed", "problems": [{"message": done.stdout[:400]}]}
28365
+
28366
+ estimated = complete_measured(
28367
+ "measured-absent",
28368
+ "pass. node test.js reported a fanout load of 1200 fF.",
28369
+ f" Measured: `{measured_literal}`",
28370
+ )
28371
+ if estimated.get("status") == "pass":
28372
+ report(
28373
+ f"{label}: Measured: is not enforced — a declared literal absent "
28374
+ "from the check's own recorded output completed cleanly, which is "
28375
+ "an estimate presented as a measurement."
28376
+ )
28377
+ return 1
28378
+ if "measurement-missing-from-evidence" not in problem_codes(estimated):
28379
+ report(
28380
+ f"{label}: a declared measurement absent from the output was "
28381
+ f"refused under another diagnostic; {problem_codes(estimated)!r} "
28382
+ f"{problem_text(estimated)!r}."
28383
+ )
28384
+ return 1
28385
+ real = complete_measured(
28386
+ "measured-present",
28387
+ f"pass. node test.js reported a fanout load of {measured_literal} fF.",
28388
+ f" Measured: `{measured_literal}`",
28389
+ )
28390
+ if real.get("status") != "pass":
28391
+ report(
28392
+ f"{label}: a declared measurement present in the output was "
28393
+ f"refused; {problem_codes(real)!r} {problem_text(real)!r}."
28394
+ )
28395
+ return 1
28396
+ # D4 — opt-in. A number in Evidence that no check declared is required
28397
+ # nowhere: the universal rule was declined on measurement, because in this
28398
+ # repository's own archive it would reach 847 inline-code spans, most of
28399
+ # them version strings, computed counts, and quoted references.
28400
+ undeclared = complete_measured(
28401
+ "measured-undeclared",
28402
+ "pass. node test.js reported `1200` fF across `2433` fanout pins.",
28403
+ "",
28404
+ )
28405
+ if undeclared.get("status") != "pass":
28406
+ report(
28407
+ f"{label}: numbers in Evidence with no `Measured:` clause were "
28408
+ f"refused, so the opt-in boundary did not hold; "
28409
+ f"{problem_codes(undeclared)!r} {problem_text(undeclared)!r}."
28410
+ )
28411
+ return 1
28412
+
28413
+ # 1.3 — the clauses are documented where the first one is documented. A
28414
+ # vocabulary an author cannot discover is a vocabulary nobody declares.
28415
+ readme = (ROOT / "README.md").read_text(encoding="utf-8")
28416
+ agents = (ROOT / "AGENTS.md").read_text(encoding="utf-8")
28417
+ for needle, why in (
28418
+ ("Detects:", "the injection clause must be named"),
28419
+ ("Measured:", "the measurement clause must be named"),
28420
+ ):
28421
+ if needle not in readme:
28422
+ report(
28423
+ f"{label}: README does not name {needle} — {why}, and a clause "
28424
+ "an author cannot find is a clause nobody declares."
28425
+ )
28426
+ return 1
28427
+ if needle not in agents:
28428
+ report(
28429
+ f"{label}: the protocol's verification discipline does not name "
28430
+ f"{needle} — {why}."
28431
+ )
28432
+ return 1
28433
+ flat_readme = re.sub(r"\s+", " ", readme)
28434
+ for needle, why in (
28435
+ ("records the claim", "the README must say Keel does not judge the injection"),
28436
+ ("does not run", "the README must say Keel does not run the mutation"),
28437
+ ):
28438
+ if needle not in flat_readme:
28439
+ report(f"{label}: README does not name Detects: honestly — {why}.")
28440
+ return 1
28441
+ # The chaining is taught by the example rather than by prose about it: a
28442
+ # reader copies the example, and an example showing one clause teaches that
28443
+ # one clause is all there is.
28444
+ chained_example = re.search(
28445
+ r"Fails with: `[^`]+` Detects: `[^`]+` -> `[^`]+`", readme
28446
+ )
28447
+ if not chained_example:
28448
+ report(
28449
+ f"{label}: README does not name Detects: in a worked example beside "
28450
+ "a failure signature, so a reader learns the clauses are mutually "
28451
+ "exclusive from the only example they have."
28452
+ )
28453
+ return 1
28454
+
28455
+ if label not in {name for name, _ in SCENARIOS}:
28456
+ report(f"{label}: the scenario registry does not include it.")
28457
+ return 1
28458
+ report(f"{label} scenario passed.")
28459
+ return 0
28460
+
28461
+
28462
+ def validate_a_negation_is_not_a_marker_scenario() -> int:
28463
+ """Issue #144: `Not resolved here:` was read as `Resolved here:`.
28464
+
28465
+ The marker scan's `\\b` sits at the space inside the negation, so a finding
28466
+ that said in as many words it was *not* resolved here had the next word read
28467
+ as its resolution evidence — a dismissal read as a repair, then refused for
28468
+ lacking repair evidence. 5.42.0 established that a quoted marker is a
28469
+ quotation; a negated one is the same class and was not covered.
28470
+ """
28471
+ label = "a-negation-is-not-a-marker"
28472
+
28473
+ def findings_task(findings: str) -> str:
28474
+ return "\n".join(
28475
+ [
28476
+ "- [x] 1.1 Findings probe",
28477
+ " - Owner: claude",
28478
+ " - Mode: implementation",
28479
+ " - Covers:",
28480
+ " - E1: the task proves its own behavior",
28481
+ " - Read:",
28482
+ " - README.md",
28483
+ " - Touch:",
28484
+ " - src/example.js",
28485
+ " - Verify:",
28486
+ " - Strategy: vertical-tdd",
28487
+ " - M1: node test.js asserts the public behavior",
28488
+ " - Evidence:",
28489
+ " - Contract: pending",
28490
+ " - M1: pass. node test.js reported the behavior.",
28491
+ " - M1.red: fail. the behavior did not exist.",
28492
+ " - M1.green: pass.",
28493
+ " - Review:",
28494
+ " - Status: pass",
28495
+ " - Acceptance check: the behavior is proven.",
28496
+ " - Scope check: only Touch changed.",
28497
+ f" - Findings: {findings}",
28498
+ " - Blocker: none",
28499
+ " - Reauthorizations: none",
28500
+ ]
28501
+ )
28502
+
28503
+ with tempfile.TemporaryDirectory(prefix="keel-negation-") as raw:
28504
+ root = Path(raw)
28505
+
28506
+ def complete(name: str, findings: str) -> dict:
28507
+ repo = root / name
28508
+ write_gate_fixture(repo, tasks=findings_task(findings))
28509
+ run_keel(
28510
+ repo, "gate", "task-start", "--change", "demo", "--task", "1.1",
28511
+ "--record", "--json",
28512
+ )
28513
+ done = run_keel(
28514
+ repo, "gate", "task-complete", "--change", "demo", "--task", "1.1",
28515
+ "--json",
28516
+ )
28517
+ try:
28518
+ return json.loads(done.stdout)
28519
+ except json.JSONDecodeError:
28520
+ return {"status": "unparsed", "problems": [{"message": done.stdout[:400]}]}
28521
+
28522
+ # M1 — the text that produced #144, verbatim in shape.
28523
+ negated = complete(
28524
+ "negated",
28525
+ "one, still open. The count is derivable from the declarations the "
28526
+ "module reads. Not resolved here: it is a different module and would "
28527
+ "be scope expansion. Durable owner: https://example.com/issues/1",
28528
+ )
28529
+ if negated.get("status") != "pass":
28530
+ report(
28531
+ f"{label}: names neither a check nor a path — a finding saying it "
28532
+ "was *not* resolved here, closing with a durable owner, was "
28533
+ f"refused; {problem_codes(negated)!r} {problem_text(negated)!r}."
28534
+ )
28535
+ return 1
28536
+ if "resolution evidence" in problem_text(negated).lower():
28537
+ report(
28538
+ f"{label}: the negated phrase still produced a resolution-evidence "
28539
+ f"diagnostic; {problem_text(negated)!r}."
28540
+ )
28541
+ return 1
28542
+
28543
+ # M2 — the positive control, in all three opening positions. Without it
28544
+ # the narrowing could pass by recognizing no marker anywhere.
28545
+ for name, findings in (
28546
+ ("opens-value", "Resolved here: M9"),
28547
+ ("after-break", "one, and it was fixed.\n Resolved here: M9"),
28548
+ ("after-stop", "one, and it was fixed. Resolved here: M9"),
28549
+ ):
28550
+ payload = complete(name, findings)
28551
+ if payload.get("status") == "pass":
28552
+ report(
28553
+ f"{label}: stopped recognizing a real marker ({name}) — a "
28554
+ "`Resolved here:` naming a check this task does not declare "
28555
+ "completed cleanly."
28556
+ )
28557
+ return 1
28558
+ if "M9" not in problem_text(payload):
28559
+ report(
28560
+ f"{label}: stopped recognizing a real marker ({name}) — the "
28561
+ f"refusal does not name the cited check; {problem_text(payload)!r}."
28562
+ )
28563
+ return 1
28564
+
28565
+ # M3 — a marker that opens no clause is refused, not ignored, and the
28566
+ # refusal names the rule so the author is not told to add what they wrote.
28567
+ swallowed = complete(
28568
+ "swallowed", "one, and the fix was Resolved here: M1 in passing"
28569
+ )
28570
+ if swallowed.get("status") == "pass":
28571
+ report(
28572
+ f"{label}: a finding whose only marker opens no clause completed "
28573
+ "cleanly, so it carried no disposition and nothing said so."
28574
+ )
28575
+ return 1
28576
+ # Asserted on the whole phrase, not the word: `opens` alone matches
28577
+ # inside `openspec`, which the same diagnostic already prints, and the
28578
+ # assertion was vacuous until this was tightened.
28579
+ if "opens its clause" not in problem_text(swallowed).lower():
28580
+ report(
28581
+ f"{label}: does not name the opening requirement — an author "
28582
+ "whose sentence visibly contains a marker is told to add a "
28583
+ f"disposition; {problem_text(swallowed)!r}."
28584
+ )
28585
+ return 1
28586
+
28587
+ if label not in {name for name, _ in SCENARIOS}:
28588
+ report(f"{label}: the scenario registry does not include it.")
28589
+ return 1
28590
+ report(f"{label} scenario passed.")
28591
+ return 0
28592
+
28593
+
28064
28594
  SCENARIOS: tuple = (
28065
28595
  ("stateless-continuity", validate_stateless_continuity_scenario),
28066
28596
  ("core-gates", validate_core_gates_scenario),
@@ -28415,6 +28945,14 @@ SCENARIOS: tuple = (
28415
28945
  "the-routing-rule-reaches-the-decision",
28416
28946
  validate_the_routing_rule_reaches_the_decision_scenario,
28417
28947
  ),
28948
+ (
28949
+ "a-claim-names-what-would-falsify-it",
28950
+ validate_a_claim_names_what_would_falsify_it_scenario,
28951
+ ),
28952
+ (
28953
+ "a-negation-is-not-a-marker",
28954
+ validate_a_negation_is_not_a_marker_scenario,
28955
+ ),
28418
28956
  )
28419
28957
 
28420
28958
 
package/src/core/gates.js CHANGED
@@ -672,7 +672,24 @@ function transientOwnerMessage(candidate) {
672
672
  // swallow and changes nothing about why this one is narrow. The match is
673
673
  // global because each resolved claim owes its own evidence; checking only the
674
674
  // first would let a second one assert itself for free.
675
- const RESOLVED_HERE = /\bresolved here\s*:[ \t]*(\S*)/gi;
675
+ // A marker is a disposition only where it OPENS its clause: the start of the
676
+ // value, a line break, or sentence-ending punctuation may precede it, and a word
677
+ // may not. `Not resolved here:` contains the marker and means its opposite, and a
678
+ // `\b` boundary sat happily at the space inside it — the negation was read as a
679
+ // resolution claim and the next word, `it`, as its evidence, so a dismissal was
680
+ // read as a repair and then refused for lacking repair evidence (issue #144).
681
+ // Quoting was the first way a marker could appear without being one (5.42.0);
682
+ // negating it is the second.
683
+ //
684
+ // Written as a lookbehind so the captures keep reading from the same offsets —
685
+ // every rule here is positional, and a rule that shortened the text would move
686
+ // what they read.
687
+ const CLAUSE_OPENING = "(?<=^|[\\n.;:!?\u2014\u2013])[ \\t]*";
688
+
689
+ const RESOLVED_HERE = new RegExp(
690
+ `${CLAUSE_OPENING}resolved here\\s*:[ \\t]*(\\S*)`,
691
+ "gi"
692
+ );
676
693
 
677
694
  // Resolution evidence is deliberately narrower than a durable owner. An
678
695
  // `http`/`https` reference says someone else will do the work later, which is
@@ -714,8 +731,24 @@ function resolutionEvidenceVerdict(repo, value, commands, change) {
714
731
  // next token, so a shortened text would move what they read. The original is
715
732
  // kept for anything reported back, since a diagnostic quoting the blanked copy
716
733
  // would show the author a sentence with holes in it.
717
- const DISPOSITION_MARKER =
718
- /\b(?:resolved here|durable owner|discard (?:reason|rationale))\s*:/gi;
734
+ // The same opening rule, for the same reason. Narrowing only the capture would
735
+ // leave a negated marker counted as a disposition being *present* while it
736
+ // supplied no evidence — a finding accepted as disposed with nothing behind it,
737
+ // which is worse than either half of the defect alone.
738
+ // The marker vocabulary wherever it appears, with no opening rule. Two different
739
+ // questions are asked of this text and they need different patterns. *Blanking* a
740
+ // marker inside a quoted span asks "is this marker text?" — and inside a span the
741
+ // character before it is a backtick, so an opening rule would refuse to blank it
742
+ // and the quotation would survive into the recognition scan. *Recognizing* a
743
+ // disposition asks "does this marker open a clause?", which is the narrower
744
+ // question `DISPOSITION_MARKER` below answers.
745
+ const MARKER_VOCABULARY =
746
+ /(?:resolved here|durable owner|discard (?:reason|rationale))\s*:/gi;
747
+
748
+ const DISPOSITION_MARKER = new RegExp(
749
+ `${CLAUSE_OPENING}(?:resolved here|durable owner|discard (?:reason|rationale))\\s*:`,
750
+ "gi"
751
+ );
719
752
 
720
753
  function withoutQuotedMarkers(text) {
721
754
  return String(text || "").replace(/`[^`\n]*`/g, (span) =>
@@ -725,7 +758,7 @@ function withoutQuotedMarkers(text) {
725
758
  // span would destroy the very path the rule exists to read. Measured: the
726
759
  // first draft of this function did exactly that, and both the backticked
727
760
  // owner path and the backticked resolution path started failing.
728
- span.replace(DISPOSITION_MARKER, (marker) => " ".repeat(marker.length))
761
+ span.replace(MARKER_VOCABULARY, (marker) => " ".repeat(marker.length))
729
762
  );
730
763
  }
731
764
 
@@ -1022,6 +1055,68 @@ function completionChecks(repo, task, contract = null, changeVerify = null, chan
1022
1055
  );
1023
1056
  }
1024
1057
  }
1058
+ // A declared measurement is held against the check's own bare `M<n>` Evidence
1059
+ // — the entry where the command and its output are recorded. A literal that
1060
+ // output does not contain is either unpasted or not measured, and free prose
1061
+ // gives a reader no way to tell those from a real reading (issue #132).
1062
+ for (const entry of contract ? contract.capsule.verification.commands : []) {
1063
+ if (!entry.measured || !entry.label) continue;
1064
+ const recorded = evidenceValue(task, entry.label);
1065
+ if (!isConcrete(recorded)) continue;
1066
+ if (!String(recorded).includes(entry.measured)) {
1067
+ problems.push(
1068
+ problem(
1069
+ "measurement-missing-from-evidence",
1070
+ `${entry.label} declares the measurement \`${entry.measured}\`, and `
1071
+ + `its recorded ${entry.label} Evidence does not contain that `
1072
+ + "string. Paste the output the number came from, or correct the "
1073
+ + "declaration — which moves the contract fingerprint, because a "
1074
+ + "number reconciled to the output after the run is a transcription "
1075
+ + "of it.",
1076
+ )
1077
+ );
1078
+ }
1079
+ }
1080
+ // A declared injection is enforced whatever the strategy and whatever the
1081
+ // tags. It is not a red-green artifact: `.red` proves the check failed before
1082
+ // the implementation existed and a failure signature predicts the red of an
1083
+ // *absent* feature, while an injection answers what neither can — whether the
1084
+ // check still fails once the feature exists and is broken. A `(regression)`
1085
+ // check is where it matters most, because it has no honest red at all, so
1086
+ // exempting it here would remove the clause from its best use (issue #132).
1087
+ //
1088
+ // Keel does not run the mutation. What it holds is that the failure the author
1089
+ // declared before the run appears in what they recorded after it.
1090
+ for (const entry of contract ? contract.capsule.verification.commands : []) {
1091
+ if (!entry.detects || !entry.label) continue;
1092
+ const recorded = evidenceValue(task, `${entry.label}.detects`);
1093
+ if (!isConcrete(recorded)) {
1094
+ problems.push(
1095
+ problem(
1096
+ "missing-injection-evidence",
1097
+ `${entry.label} declares that \`${entry.detects.mutation}\` must make `
1098
+ + `it fail with \`${entry.detects.failure}\`, and records no `
1099
+ + `${entry.label}.detects Evidence. Run the mutation, record what it `
1100
+ + "printed, and revert it — a green check that has never been made "
1101
+ + "to fail on the defect it names is not evidence that it would.",
1102
+ )
1103
+ );
1104
+ continue;
1105
+ }
1106
+ if (!String(recorded).includes(entry.detects.failure)) {
1107
+ problems.push(
1108
+ problem(
1109
+ "injection-missing-declared-failure",
1110
+ `${entry.label} declares that its injection fails with `
1111
+ + `\`${entry.detects.failure}\`, and the recorded `
1112
+ + `${entry.label}.detects Evidence does not contain that string. `
1113
+ + "Record what the mutation actually printed, or correct the "
1114
+ + "declaration — which moves the contract fingerprint, because an "
1115
+ + "injection edited after the run is a transcription of it.",
1116
+ )
1117
+ );
1118
+ }
1119
+ }
1025
1120
  const strategy = contract
1026
1121
  ? contract.capsule.verification.strategy.toLowerCase()
1027
1122
  : "";
@@ -1207,6 +1302,16 @@ function completionChecks(repo, task, contract = null, changeVerify = null, chan
1207
1302
  + "must still do is `Durable owner:` naming "
1208
1303
  + `${DURABLE_OWNER_FORMS}; one deliberately not being done is a `
1209
1304
  + "`Discard reason:`/`Discard rationale:` prefix."
1305
+ // Said only where a marker is visibly present and was not counted,
1306
+ // which is the one case where "carry a disposition" reads as a
1307
+ // contradiction of what the author can see they wrote.
1308
+ + (mentionsUnopenedMarker(scannable)
1309
+ ? " A marker counts only where it opens its clause — after the "
1310
+ + "start of the value, a line break, or sentence-ending "
1311
+ + "punctuation. This text mentions one mid-sentence, so it was "
1312
+ + "read as prose; move it to the start of its own clause, or "
1313
+ + "keep it as prose and add the disposition separately."
1314
+ : "")
1210
1315
  )
1211
1316
  );
1212
1317
  }
@@ -1214,6 +1319,15 @@ function completionChecks(repo, task, contract = null, changeVerify = null, chan
1214
1319
  return { problems, reviewProblems };
1215
1320
  }
1216
1321
 
1322
+ // Whether the text names a disposition marker somewhere that is not a clause
1323
+ // opening. Used only to add a sentence to a refusal, never to accept anything:
1324
+ // a marker mid-sentence stays prose, and this is what tells its author why.
1325
+ function mentionsUnopenedMarker(text) {
1326
+ const body = String(text || "");
1327
+ if (!new RegExp(MARKER_VOCABULARY.source, "i").test(body)) return false;
1328
+ return !new RegExp(DISPOSITION_MARKER.source, "i").test(body);
1329
+ }
1330
+
1217
1331
  function taskComplete(repo, options) {
1218
1332
  const selection = loadSelection(repo, options);
1219
1333
  const task = selection.selected[0];
@@ -153,28 +153,88 @@ const RED_GREEN_VERIFICATION_STRATEGIES = new Set([
153
153
  // Tags an M<n> check may carry after its label, as a comma-separated set.
154
154
  const COMMAND_TAGS = new Set(["fast", "full", "regression"]);
155
155
 
156
- // A check may end by declaring the failure its red is expected to show, so that
157
- // what the red proves is written down before the red is run. The clause closes
158
- // the check: `Fails with:` followed by one inline-code literal and nothing more.
159
- // End-anchored on purpose — a check that describes this rule mentions the marker
160
- // mid-sentence, and a mention is not a declaration.
161
- const FAILURE_SIGNATURE = /\bFails with:[ \t]*`([^`\n]+)`[ \t]*$/i;
162
- const FAILURE_MARKER = /\bFails with:/i;
156
+ // A check may end by declaring things about itself, so that each is written down
157
+ // before the run it describes. Every clause has the same shape: it closes the
158
+ // clause sequence with inline-code literals, it lives inside the check text and
159
+ // therefore inside the contract fingerprint, and it is enforced by requiring its
160
+ // literal in a named Evidence entry. Keel judges none of them.
161
+ //
162
+ // The clauses chain. A check is one line — `fieldValues` splits the field per
163
+ // line and treats each as its own entry — so a single end-anchored slot would
164
+ // make the clauses mutually exclusive, and the case that motivated `Detects:`
165
+ // declares an injection beside a failure signature on one check (issue #132).
166
+ //
167
+ // Each is still anchored at the end of what remains, on purpose: a check that
168
+ // describes this rule mentions a marker mid-sentence, and a mention is not a
169
+ // declaration.
170
+ //
171
+ // - `Fails with:` — the failure the check's red must show. Predicts the red of an
172
+ // *absent* feature.
173
+ // - `Detects:` — a mutation that puts a defect in, and the failure it must
174
+ // produce. Answers the question a red cannot: the red of a *broken* feature.
175
+ // The two can be entirely unrelated, which is the whole reason this exists.
176
+ const DECLARATION_CLAUSES = [
177
+ {
178
+ name: "failure",
179
+ pattern: /\bFails with:[ \t]*`([^`\n]+)`[ \t]*$/i,
180
+ marker: /\bFails with:/i,
181
+ build: (match) => match[1].trim(),
182
+ },
183
+ {
184
+ name: "detects",
185
+ pattern: /\bDetects:[ \t]*`([^`\n]+)`[ \t]*->[ \t]*`([^`\n]+)`[ \t]*$/i,
186
+ marker: /\bDetects:/i,
187
+ build: (match) => ({
188
+ mutation: match[1].trim(),
189
+ failure: match[2].trim(),
190
+ }),
191
+ },
192
+ {
193
+ // `Measured:` — a literal the check's own recorded output must contain. The
194
+ // failure class is a number that reads like a measurement and is an estimate
195
+ // or a recollection; free prose cannot tell a reader which it is. Opt-in on
196
+ // purpose: a universal rule over every number in Evidence would reach 847
197
+ // inline-code spans in this repository's own archive, most of them version
198
+ // strings, counts the author computed, and quoted references that appear in
199
+ // no command output, and each would be a false stop.
200
+ name: "measured",
201
+ pattern: /\bMeasured:[ \t]*`([^`\n]+)`[ \t]*$/i,
202
+ marker: /\bMeasured:/i,
203
+ build: (match) => match[1].trim(),
204
+ },
205
+ ];
163
206
 
164
- // Classify a check's `Fails with:` marker. `signature` is the declared literal;
165
- // `malformed` marks a marker that is present and is not a closing clause — a
166
- // typo shape, and ignoring it would leave the author believing a signature is
167
- // enforced when none was parsed. A marker written inside inline code is quoted
168
- // material rather than a declaration, the meaning inline code already carries
169
- // here, which is what lets this file's own tasks name the marker.
170
- function failureSignature(check) {
171
- const text = String(check || "");
172
- const match = text.match(FAILURE_SIGNATURE);
173
- if (match) return { signature: match[1].trim(), malformed: false };
207
+ // Strip one matching trailing clause at a time until none matches, then report a
208
+ // marker surviving in the remaining prose as malformed. Malformed rather than
209
+ // ignored: a declaration that parsed as nothing reads to its author as a check
210
+ // being enforced. A marker inside inline code is quoted material, the meaning
211
+ // inline code already carries here, which is what lets this file's own tasks
212
+ // name the markers.
213
+ function declarationClauses(check) {
214
+ let text = String(check || "");
215
+ const declared = {};
216
+ for (let matched = true; matched; ) {
217
+ matched = false;
218
+ for (const clause of DECLARATION_CLAUSES) {
219
+ const match = text.match(clause.pattern);
220
+ if (!match) continue;
221
+ if (!(clause.name in declared)) declared[clause.name] = clause.build(match);
222
+ text = text.slice(0, match.index).replace(/[ \t]+$/, "");
223
+ matched = true;
224
+ break;
225
+ }
226
+ }
174
227
  const remainder = withoutInlineCode(text);
228
+ const malformed = {};
229
+ for (const clause of DECLARATION_CLAUSES) {
230
+ malformed[clause.name] =
231
+ !(clause.name in declared) && clause.marker.test(remainder);
232
+ }
175
233
  return {
176
- signature: null,
177
- malformed: FAILURE_MARKER.test(remainder),
234
+ signature: declared.failure == null ? null : declared.failure,
235
+ detects: declared.detects == null ? null : declared.detects,
236
+ measured: declared.measured == null ? null : declared.measured,
237
+ malformed,
178
238
  };
179
239
  }
180
240
 
@@ -226,6 +286,10 @@ function verification(task) {
226
286
  check: entry,
227
287
  failsWith: null,
228
288
  malformedSignature: false,
289
+ detects: null,
290
+ malformedInjection: false,
291
+ measured: null,
292
+ malformedMeasurement: false,
229
293
  };
230
294
  }
231
295
  const tags = (match[2] || "")
@@ -240,17 +304,25 @@ function verification(task) {
240
304
  check: entry,
241
305
  failsWith: null,
242
306
  malformedSignature: false,
307
+ detects: null,
308
+ malformedInjection: false,
309
+ measured: null,
310
+ malformedMeasurement: false,
243
311
  };
244
312
  }
245
313
  const check = normalizeText(match[3]);
246
- const failure = failureSignature(check);
314
+ const clauses = declarationClauses(check);
247
315
  return {
248
316
  label: match[1],
249
317
  layer: tags.includes("fast") ? "fast" : "full",
250
318
  regression: tags.includes("regression"),
251
319
  check,
252
- failsWith: failure.signature,
253
- malformedSignature: failure.malformed,
320
+ failsWith: clauses.signature,
321
+ malformedSignature: clauses.malformed.failure,
322
+ detects: clauses.detects,
323
+ malformedInjection: clauses.malformed.detects,
324
+ measured: clauses.measured,
325
+ malformedMeasurement: clauses.malformed.measured,
254
326
  };
255
327
  });
256
328
  return {
@@ -443,6 +515,29 @@ function failureSignatureProblems(task) {
443
515
  });
444
516
  continue;
445
517
  }
518
+ if (entry.malformedMeasurement) {
519
+ problems.push({
520
+ code: "malformed-measurement",
521
+ message:
522
+ `${entry.label} carries a \`Measured:\` marker that does not close `
523
+ + "the check with a literal. Write it as `Measured: `<literal>`` at "
524
+ + "the end of the check, or fence the marker in inline code when the "
525
+ + "check is describing it rather than declaring one.",
526
+ });
527
+ continue;
528
+ }
529
+ if (entry.malformedInjection) {
530
+ problems.push({
531
+ code: "malformed-injection",
532
+ message:
533
+ `${entry.label} carries a \`Detects:\` marker that does not close `
534
+ + "the check with a mutation and the failure it must produce. Write "
535
+ + "it as `Detects: `<mutation>` -> `<failure>`` at the end of the "
536
+ + "check, or fence the marker in inline code when the check is "
537
+ + "describing it rather than declaring one.",
538
+ });
539
+ continue;
540
+ }
446
541
  if (!entry.failsWith) continue;
447
542
  if (!redGreen) {
448
543
  problems.push({
@@ -1229,6 +1324,8 @@ function compileTaskContract(repo, change, task) {
1229
1324
  // field is what the gate reads. Emitted only when declared, so every
1230
1325
  // check without one keeps the capsule shape and fingerprint it had.
1231
1326
  if (entry.failsWith) emitted.failsWith = entry.failsWith;
1327
+ if (entry.detects) emitted.detects = entry.detects;
1328
+ if (entry.measured) emitted.measured = entry.measured;
1232
1329
  return emitted;
1233
1330
  }),
1234
1331
  },