@christang/keel 5.50.0 → 5.51.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.
@@ -1,4 +1,4 @@
1
- <!-- keel:start version=5.50.0 -->
1
+ <!-- keel:start version=5.51.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.50.0",
5
+ "version": "5.51.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.50.0",
3
+ "version": "5.51.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.50.0",
3
+ "version": "5.51.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",
@@ -37,8 +37,8 @@ REQUIRED_SCRIPTS = [
37
37
  "scripts/validate_plugin.py",
38
38
  ]
39
39
 
40
- PACKAGE_VERSION = "5.50.0"
41
- PROTOCOL_VERSION = "5.50.0"
40
+ PACKAGE_VERSION = "5.51.0"
41
+ PROTOCOL_VERSION = "5.51.0"
42
42
  LEGACY_MANAGED_START = "<!-- keel:start version=2.1 -->"
43
43
  OPENSPEC_SCHEMA_NAME = "keel-spec-driven"
44
44
  # Mirrors KEEL_PACKAGE_NAME in scripts/install_to_repo.py, one of the two
@@ -25222,6 +25222,185 @@ def validate_weakest_strategy_states_its_reason_scenario() -> int:
25222
25222
  return 0
25223
25223
 
25224
25224
 
25225
+ # A completed task fixture whose Review Findings the caller writes. Everything
25226
+ # else is the shape `task-complete` needs so that the only thing under test is
25227
+ # the Findings block.
25228
+ def findings_fixture_repo(root: Path, name: str, findings: str) -> Path:
25229
+ repo = root / name
25230
+ task = strategy_probe_task(strategy="vertical-tdd")
25231
+ task = task.replace("- [ ] 1.1", "- [x] 1.1")
25232
+ task = task.replace(
25233
+ " - M1: pending",
25234
+ " - M1: pass. node test.js reported 3 passing.\n"
25235
+ " - M1.red: fail, for the right reason. the assertion was absent.\n"
25236
+ " - M1.green: pass. node test.js reported 3 passing.",
25237
+ )
25238
+ task = task.replace(" - Findings: none", f" - Findings: {findings}")
25239
+ write_gate_fixture(repo, tasks=task)
25240
+ started = run_keel(
25241
+ repo, "gate", "task-start", "--change", "demo", "--task", "1.1", "--json"
25242
+ )
25243
+ fingerprint = json.loads(started.stdout)["contract"]["fingerprint"]["value"]
25244
+ tasks_path = repo / "openspec/changes/demo/tasks.md"
25245
+ tasks_path.write_text(
25246
+ tasks_path.read_text(encoding="utf-8").replace(
25247
+ " - Contract: pending",
25248
+ f" - Contract: keel-task-capsule/v1 sha256:{fingerprint}",
25249
+ ),
25250
+ encoding="utf-8",
25251
+ )
25252
+ return repo
25253
+
25254
+
25255
+ def findings_completion(root: Path, name: str, findings: str) -> dict:
25256
+ repo = findings_fixture_repo(root, name, findings)
25257
+ result = run_keel(
25258
+ repo, "gate", "task-complete", "--change", "demo", "--task", "1.1", "--json"
25259
+ )
25260
+ try:
25261
+ return json.loads(result.stdout)
25262
+ except json.JSONDecodeError:
25263
+ return {"status": "unparsed", "problems": [{"message": result.stdout[:400]}]}
25264
+
25265
+
25266
+ # `Findings` is free prose by design, and in a repository whose subject is the
25267
+ # protocol that prose names the markers themselves. A quoted marker was read as
25268
+ # a disposition — and because the resolution scan is global and stops at the
25269
+ # first claim it cannot resolve, one mention eclipsed every real disposition
25270
+ # beside it. 5.42.0 already decided that an inline-code span holds quoted
25271
+ # material rather than an assertion; the rule had not reached this scan.
25272
+ def validate_quoted_marker_is_not_a_disposition_scenario() -> int:
25273
+ label = "a-quoted-marker-is-not-a-disposition"
25274
+ tracker = "https://github.com/TanglmChris/keel/issues/114"
25275
+ with tempfile.TemporaryDirectory(prefix="keel-quoted-marker-") as raw:
25276
+ root = Path(raw)
25277
+
25278
+ owned = findings_completion(
25279
+ root,
25280
+ "owned",
25281
+ "one, still open. The `Resolved here:` grammar is what this finding "
25282
+ f"is about. Durable owner: {tracker}",
25283
+ )
25284
+ if owned.get("status") != "pass":
25285
+ report(
25286
+ f"{label}: a quoted marker eclipsed a genuine tracker owner; "
25287
+ f"{problem_codes(owned)!r} {problem_text(owned)!r}."
25288
+ )
25289
+ return 1
25290
+
25291
+ resolved = findings_completion(
25292
+ root,
25293
+ "resolved",
25294
+ "one, fixed here. The `Durable owner:` form is what this finding is "
25295
+ "about. Resolved here: M1",
25296
+ )
25297
+ if resolved.get("status") != "pass":
25298
+ report(
25299
+ f"{label}: a quoted marker eclipsed a genuine resolution; "
25300
+ f"{problem_codes(resolved)!r} {problem_text(resolved)!r}."
25301
+ )
25302
+ return 1
25303
+
25304
+ # Every marker, not only the one that fired.
25305
+ for index, marker in enumerate(
25306
+ ("Resolved here:", "Durable owner:", "Discard reason:", "Discard rationale:")
25307
+ ):
25308
+ only = findings_completion(
25309
+ root,
25310
+ f"only-{index}",
25311
+ f"one. The `{marker}` marker is the topic of this finding, and "
25312
+ "nothing here disposes of it.",
25313
+ )
25314
+ codes = problem_codes(only)
25315
+ if only.get("status") != "fail":
25316
+ report(
25317
+ f"{label}: a block whose only {marker!r} is quoted was "
25318
+ "accepted, so the quotation created a disposition."
25319
+ )
25320
+ return 1
25321
+ if "finding-resolution-evidence" in codes:
25322
+ report(
25323
+ f"{label}: a block whose only {marker!r} is quoted was "
25324
+ "refused as a resolution with unusable evidence; it carries "
25325
+ f"no disposition at all. Codes {codes!r}."
25326
+ )
25327
+ return 1
25328
+ if "finding-owner" not in codes:
25329
+ report(
25330
+ f"{label}: a block whose only {marker!r} is quoted did not "
25331
+ f"report the missing-disposition problem. Codes {codes!r}."
25332
+ )
25333
+ return 1
25334
+
25335
+ # D5: the refusal names what it read.
25336
+ misread = findings_completion(
25337
+ root, "misread", "one. Resolved here: thoroughly, by rewriting it."
25338
+ )
25339
+ if misread.get("status") != "fail":
25340
+ report(f"{label}: a resolution naming no evidence was accepted.")
25341
+ return 1
25342
+ if "thoroughly" not in problem_text(misread):
25343
+ report(
25344
+ f"{label}: the refusal does not name the text it read as "
25345
+ f"evidence; got {problem_text(misread)!r}."
25346
+ )
25347
+ return 1
25348
+
25349
+ # The verdicts that must not move.
25350
+ plain = findings_completion(root, "plain", "one, fixed. Resolved here: M1")
25351
+ if plain.get("status") != "pass":
25352
+ report(
25353
+ f"{label}: an ordinary resolution was refused; "
25354
+ f"{problem_text(plain)!r}."
25355
+ )
25356
+ return 1
25357
+ undeclared = findings_completion(
25358
+ root, "undeclared", "one, fixed. Resolved here: M9"
25359
+ )
25360
+ if undeclared.get("status") != "fail":
25361
+ report(
25362
+ f"{label}: a resolution naming a check the task does not "
25363
+ "declare was accepted."
25364
+ )
25365
+ return 1
25366
+ missing_path = findings_completion(
25367
+ root, "missing-path", "one, open. Durable owner: docs/nowhere.md"
25368
+ )
25369
+ if missing_path.get("status") != "fail":
25370
+ report(
25371
+ f"{label}: an owner naming a path that does not exist was "
25372
+ "accepted."
25373
+ )
25374
+ return 1
25375
+
25376
+ # A disposition may legitimately wrap its value in backticks. The first
25377
+ # draft of the blanking removed whole code spans and destroyed exactly
25378
+ # these two forms; nothing in the suite noticed, which is why they are
25379
+ # asserted here rather than left to the reader who happens to ask.
25380
+ for name, findings in (
25381
+ ("backticked-owner", "one, open. Durable owner: `keel/archive/note.md`"),
25382
+ ("backticked-resolution", "one, fixed. Resolved here: `src/example.js`"),
25383
+ ):
25384
+ repo = findings_fixture_repo(root, name, findings)
25385
+ write_text(repo / "keel/archive/note.md", "note\n")
25386
+ write_text(repo / "src/example.js", "//\n")
25387
+ result = run_keel(
25388
+ repo, "gate", "task-complete", "--change", "demo", "--task",
25389
+ "1.1", "--json",
25390
+ )
25391
+ payload = json.loads(result.stdout)
25392
+ if payload.get("status") != "pass":
25393
+ report(
25394
+ f"{label}: a disposition whose value is wrapped in "
25395
+ f"backticks was refused ({name}); "
25396
+ f"{problem_codes(payload)!r} {problem_text(payload)!r}."
25397
+ )
25398
+ return 1
25399
+
25400
+ report(f"{label} scenario passed.")
25401
+ return 0
25402
+
25403
+
25225
25404
  # A scenario name, as the registry spells one. Two registered names carry no
25226
25405
  # hyphen — `cli` and `uninstall` — so requiring one would leave exactly those
25227
25406
  # two unchecked, and allowing single words was measured to add no false
@@ -25455,6 +25634,7 @@ SCENARIOS: tuple = (
25455
25634
  ("output-survives-the-pipe", validate_output_survives_the_pipe_scenario),
25456
25635
  ("a-strategy-is-declared", validate_strategy_is_declared_scenario),
25457
25636
  ("the-weakest-strategy-states-its-reason", validate_weakest_strategy_states_its_reason_scenario),
25637
+ ("a-quoted-marker-is-not-a-disposition", validate_quoted_marker_is_not_a_disposition_scenario),
25458
25638
  (
25459
25639
  "authored-scenario-names-are-registered",
25460
25640
  validate_authored_scenario_names_scenario,
package/src/core/gates.js CHANGED
@@ -582,7 +582,7 @@ function resolutionEvidenceVerdict(repo, value, commands, change) {
582
582
  return { ok: false, reason: "unknown-check", label: cited[0] };
583
583
  }
584
584
  const candidate = declaredPath(evidence);
585
- if (!candidate) return { ok: false, reason: "unrecognized" };
585
+ if (!candidate) return { ok: false, reason: "unrecognized", token: evidence };
586
586
  // Resolution evidence is a file like any other and moves with the directory
587
587
  // holding it, so it earns the same verdict rather than a second answer to
588
588
  // the same question.
@@ -593,6 +593,30 @@ function resolutionEvidenceVerdict(repo, value, commands, change) {
593
593
  return { ok: false, reason: "missing", path: candidate };
594
594
  }
595
595
 
596
+ // `Findings` is free prose, and in a repository whose subject is the protocol
597
+ // that prose names the markers themselves. A quoted marker is a quotation, not
598
+ // a disposition — the rule 5.42.0 established for the tasks.md checker and the
599
+ // unfilled-slot scan, arriving here. Each span becomes an equal run of spaces
600
+ // rather than being removed, because every rule downstream reads positionally:
601
+ // `Durable owner:` captures to end of line and `Resolved here:` captures the
602
+ // next token, so a shortened text would move what they read. The original is
603
+ // kept for anything reported back, since a diagnostic quoting the blanked copy
604
+ // would show the author a sentence with holes in it.
605
+ const DISPOSITION_MARKER =
606
+ /\b(?:resolved here|durable owner|discard (?:reason|rationale))\s*:/gi;
607
+
608
+ function withoutQuotedMarkers(text) {
609
+ return String(text || "").replace(/`[^`\n]*`/g, (span) =>
610
+ // Only the marker vocabulary is removed, not the span. A disposition may
611
+ // legitimately wrap its value in backticks — `Durable owner:
612
+ // \`keel/archive/note.md\`` is a supported form — so blanking the whole
613
+ // span would destroy the very path the rule exists to read. Measured: the
614
+ // first draft of this function did exactly that, and both the backticked
615
+ // owner path and the backticked resolution path started failing.
616
+ span.replace(DISPOSITION_MARKER, (marker) => " ".repeat(marker.length))
617
+ );
618
+ }
619
+
596
620
  function resolutionEvidenceMessage(verdict) {
597
621
  const lead = "Review Findings records a finding as resolved here, but its "
598
622
  + "evidence is not usable — ";
@@ -615,7 +639,13 @@ function resolutionEvidenceMessage(verdict) {
615
639
  if (verdict.reason === "missing") {
616
640
  return `${lead}\`${verdict.path}\` does not exist.${tail}`;
617
641
  }
618
- return `${lead}it names neither a check nor a path.${tail}`;
642
+ // Naming the token turns an argument into an observation: the author
643
+ // believes they named a check or a path, and this is what the gate read
644
+ // instead.
645
+ return verdict.token
646
+ ? `${lead}it read \`${verdict.token}\`, which names neither a check nor a `
647
+ + `path.${tail}`
648
+ : `${lead}it names neither a check nor a path.${tail}`;
619
649
  }
620
650
 
621
651
  function findingOwnerIsDurable(repo, findings, change) {
@@ -1011,7 +1041,8 @@ function completionChecks(repo, task, contract = null, changeVerify = null, chan
1011
1041
  // pass as a tracker owner, which is the one reading this disposition must
1012
1042
  // not have: a link to work someone else will do is not evidence that this
1013
1043
  // task did it.
1014
- const resolved = [...reviewFields.Findings.matchAll(RESOLVED_HERE)];
1044
+ const scannable = withoutQuotedMarkers(reviewFields.Findings);
1045
+ const resolved = [...scannable.matchAll(RESOLVED_HERE)];
1015
1046
  if (resolved.length > 0) {
1016
1047
  for (const claim of resolved) {
1017
1048
  const verdict = resolutionEvidenceVerdict(repo, claim[1], commands, change);
@@ -1021,7 +1052,7 @@ function completionChecks(repo, task, contract = null, changeVerify = null, chan
1021
1052
  );
1022
1053
  break;
1023
1054
  }
1024
- } else if (!findingOwnerIsDurable(repo, reviewFields.Findings, change)) {
1055
+ } else if (!findingOwnerIsDurable(repo, scannable, change)) {
1025
1056
  problems.push(
1026
1057
  problem(
1027
1058
  "finding-owner",