@christang/keel 5.50.0 → 5.52.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.52.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.52.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.52.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.52.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.52.0"
41
+ PROTOCOL_VERSION = "5.52.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,300 @@ 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
+
25404
+ # A drift is a hard stop, so whatever the message says is what the author acts
25405
+ # on. Saying only that a value moved produced a wrong record in this repository:
25406
+ # the cause was recorded as a Review edit, which is measurably not covered by
25407
+ # the fingerprint at all.
25408
+ def validate_drift_names_where_to_look_scenario() -> int:
25409
+ label = "drift-names-where-to-look"
25410
+
25411
+ def fixture(root: Path, name: str, anchor_value: str) -> Path:
25412
+ repo = root / name
25413
+ task = strategy_probe_task(strategy="vertical-tdd")
25414
+ # Cite a design statement so the capsule holds two distinct sources.
25415
+ task = task.replace(
25416
+ " - E1: the task proves its own behavior",
25417
+ " - E1: the task proves its own behavior\n - D1",
25418
+ )
25419
+ task = task.replace(
25420
+ " - Contract: pending",
25421
+ f" - Contract: keel-task-capsule/v1 sha256:{anchor_value}",
25422
+ )
25423
+ write_gate_fixture(
25424
+ repo,
25425
+ tasks=task,
25426
+ design=(
25427
+ "## Context\n\nfixture\n\n## Decisions\n\n"
25428
+ "- D1 — the public behavior is proven through the CLI. "
25429
+ "Basis: fixture.\n"
25430
+ ),
25431
+ )
25432
+ return repo
25433
+
25434
+ with tempfile.TemporaryDirectory(prefix="keel-drift-") as raw:
25435
+ root = Path(raw)
25436
+
25437
+ # The true anchor, so the matching case can be compared against it.
25438
+ settled = fixture(root, "settled", "0" * 64)
25439
+ started = run_keel(
25440
+ settled, "gate", "task-start", "--change", "demo", "--task", "1.1",
25441
+ "--json", "--no-guard",
25442
+ )
25443
+ true_value = json.loads(started.stdout)["contract"]["fingerprint"]["value"]
25444
+
25445
+ drifted = fixture(root, "drifted", "0" * 64)
25446
+ result = run_keel(drifted, "context", "--change", "demo", "--json")
25447
+ payload = json.loads(result.stdout)
25448
+ if payload.get("status") != "blocked":
25449
+ report(
25450
+ f"{label}: a task whose anchor does not match was not blocked; "
25451
+ f"status {payload.get('status')!r}."
25452
+ )
25453
+ return 1
25454
+ reason = " ".join(str(x) for x in (payload.get("reasons") or []))
25455
+
25456
+ for needed in ("0" * 8, true_value[:8]):
25457
+ if needed not in reason:
25458
+ report(
25459
+ f"{label}: the drift reason no longer reports both "
25460
+ f"fingerprints; {reason!r}."
25461
+ )
25462
+ return 1
25463
+ for source in (
25464
+ "openspec/changes/demo/tasks.md",
25465
+ "openspec/changes/demo/design.md",
25466
+ ):
25467
+ if source not in reason:
25468
+ report(
25469
+ f"{label}: the drift reason does not name the authority "
25470
+ f"source {source!r}; {reason!r}."
25471
+ )
25472
+ return 1
25473
+ # The list is the authority set, not the change directory.
25474
+ if "proposal.md" in reason:
25475
+ report(
25476
+ f"{label}: the drift reason names a file the capsule resolved "
25477
+ f"no authority text from; {reason!r}."
25478
+ )
25479
+ return 1
25480
+ lowered = reason.lower()
25481
+ for word in ("evidence", "review", "checkbox"):
25482
+ if word not in lowered:
25483
+ report(
25484
+ f"{label}: the drift reason does not say that {word} is "
25485
+ f"outside the fingerprint; {reason!r}."
25486
+ )
25487
+ return 1
25488
+ # D3: it names a search set, never a culprit.
25489
+ for guess in ("changed field", "the field that changed", "caused by"):
25490
+ if guess in lowered:
25491
+ report(
25492
+ f"{label}: the drift reason claims which field moved, which "
25493
+ f"it cannot know; {reason!r}."
25494
+ )
25495
+ return 1
25496
+
25497
+ matching = fixture(root, "matching", true_value)
25498
+ ok = json.loads(
25499
+ run_keel(matching, "context", "--change", "demo", "--json").stdout
25500
+ )
25501
+ if ok.get("status") != "ready":
25502
+ report(
25503
+ f"{label}: a task whose anchor matches was not ready; "
25504
+ f"{ok.get('status')!r} {ok.get('reasons')!r}."
25505
+ )
25506
+ return 1
25507
+ ok_reason = " ".join(str(x) for x in (ok.get("reasons") or []))
25508
+ if "fingerprint" in ok_reason.lower():
25509
+ report(
25510
+ f"{label}: a matching anchor now reports drift wording; "
25511
+ f"{ok_reason!r}."
25512
+ )
25513
+ return 1
25514
+
25515
+ report(f"{label} scenario passed.")
25516
+ return 0
25517
+
25518
+
25225
25519
  # A scenario name, as the registry spells one. Two registered names carry no
25226
25520
  # hyphen — `cli` and `uninstall` — so requiring one would leave exactly those
25227
25521
  # two unchecked, and allowing single words was measured to add no false
@@ -25455,6 +25749,8 @@ SCENARIOS: tuple = (
25455
25749
  ("output-survives-the-pipe", validate_output_survives_the_pipe_scenario),
25456
25750
  ("a-strategy-is-declared", validate_strategy_is_declared_scenario),
25457
25751
  ("the-weakest-strategy-states-its-reason", validate_weakest_strategy_states_its_reason_scenario),
25752
+ ("a-quoted-marker-is-not-a-disposition", validate_quoted_marker_is_not_a_disposition_scenario),
25753
+ ("drift-names-where-to-look", validate_drift_names_where_to_look_scenario),
25458
25754
  (
25459
25755
  "authored-scenario-names-are-registered",
25460
25756
  validate_authored_scenario_names_scenario,
@@ -85,6 +85,32 @@ function taskHasCompletionEvidence(record, contract) {
85
85
  );
86
86
  }
87
87
 
88
+ // Two hashes and nothing to search was the whole message, and it produced a
89
+ // wrong record here: an anchor that moved was written up as a Review edit,
90
+ // which is measurably not covered at all. The capsule already knows the answer
91
+ // — every authority entry carries the source its text was resolved from — so
92
+ // the search set is free. What is not free is the field that moved: only the
93
+ // previous fingerprint is retained, not the capsule behind it, so this names
94
+ // where to look and never what changed.
95
+ function driftSearchSet(contract) {
96
+ const sources = [
97
+ ...new Set(
98
+ (contract.capsule.authority || [])
99
+ .map((entry) => String(entry.source || "").split("#")[0])
100
+ .filter(Boolean)
101
+ ),
102
+ ];
103
+ const where = sources.length > 0
104
+ ? `The fingerprint covers text resolved from: ${sources.join(", ")}.`
105
+ : "The fingerprint covers this task's own resolved authority text.";
106
+ return (
107
+ `${where} Evidence, Review, and the task checkbox are not covered — `
108
+ + "editing them does not move it. Reauthorize by re-running "
109
+ + "`keel gate task-start` and recording the new anchor, after confirming "
110
+ + "the change to the authority above was intended."
111
+ );
112
+ }
113
+
88
114
  function taskSelection(repo, change, record, source, requestedAction = null) {
89
115
  const tasksPath = path.join(repo, "openspec", "changes", change, "tasks.md");
90
116
  const contract = compileTaskContract(repo, change, record);
@@ -100,7 +126,8 @@ function taskSelection(repo, change, record, source, requestedAction = null) {
100
126
  if (anchor && anchor !== contract.fingerprint.value) {
101
127
  return blocked(
102
128
  `Task contract fingerprint drift for ${change}#${record.id}: recorded `
103
- + `sha256:${anchor}, current sha256:${contract.fingerprint.value}.`,
129
+ + `sha256:${anchor}, current sha256:${contract.fingerprint.value}. `
130
+ + `${driftSearchSet(contract)}`,
104
131
  [relativePath(repo, tasksPath)]
105
132
  );
106
133
  }
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",