@christang/keel 5.51.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.51.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.51.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.51.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.51.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.51.0"
41
- PROTOCOL_VERSION = "5.51.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
@@ -25401,6 +25401,121 @@ def validate_quoted_marker_is_not_a_disposition_scenario() -> int:
25401
25401
  return 0
25402
25402
 
25403
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
+
25404
25519
  # A scenario name, as the registry spells one. Two registered names carry no
25405
25520
  # hyphen — `cli` and `uninstall` — so requiring one would leave exactly those
25406
25521
  # two unchecked, and allowing single words was measured to add no false
@@ -25635,6 +25750,7 @@ SCENARIOS: tuple = (
25635
25750
  ("a-strategy-is-declared", validate_strategy_is_declared_scenario),
25636
25751
  ("the-weakest-strategy-states-its-reason", validate_weakest_strategy_states_its_reason_scenario),
25637
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),
25638
25754
  (
25639
25755
  "authored-scenario-names-are-registered",
25640
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
  }