@christang/keel 5.54.0 → 5.55.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
@@ -333,6 +333,23 @@ keel lenses add web # copy the web template into keel/lenses/web.md, the
333
333
  keel lenses add web --force # overwrite an existing lens
334
334
  ```
335
335
 
336
+ ## Re-recording a contract
337
+
338
+ Changing a task's contract after work has started moves its fingerprint, and Keel reports that the
339
+ evidence produced under the old one is stale. Sometimes that is too broad — a classification tag
340
+ added to a check leaves its assertion untouched, and re-running a three-minute experiment for it
341
+ buys nothing. Say so:
342
+
343
+ ```bash
344
+ keel gate task-start --change <c> --task <t> --record --keep-evidence M1,M3
345
+ ```
346
+
347
+ The report then names only the checks still stale, names the ones you declared unaffected, and says
348
+ the narrowing came from your declaration. **Keel does not verify the claim** — it keeps only the
349
+ previous fingerprint, not the capsule behind it, so it cannot compare a check's former text to its
350
+ current one. State your reason in the task's `Reauthorizations` line, where a reviewer can disagree
351
+ with it. Nothing about completion changes: every check still needs its Evidence.
352
+
336
353
  ## Pausing a change
337
354
 
338
355
  A change you have deliberately stopped — waiting on something outside the repository, or simply
@@ -1,4 +1,4 @@
1
- <!-- keel:start version=5.54.0 -->
1
+ <!-- keel:start version=5.55.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/bin/keel.js CHANGED
@@ -186,6 +186,7 @@ function parseArgs(argv) {
186
186
  base: null,
187
187
  noGuard: false,
188
188
  record: false,
189
+ keepEvidence: null,
189
190
  guardSubcommand: null,
190
191
  lensesSubcommand: null,
191
192
  lensName: null,
@@ -296,6 +297,20 @@ function parseArgs(argv) {
296
297
  parsed.record = true;
297
298
  continue;
298
299
  }
300
+ if (arg === "--keep-evidence") {
301
+ index += 1;
302
+ if (index >= argv.length) {
303
+ fail("--keep-evidence requires a comma-separated list of M<n> labels");
304
+ }
305
+ if (parsed.keepEvidence !== null) {
306
+ fail("--keep-evidence was provided more than once");
307
+ }
308
+ parsed.keepEvidence = argv[index]
309
+ .split(",")
310
+ .map((entry) => entry.trim())
311
+ .filter(Boolean);
312
+ continue;
313
+ }
299
314
  if (arg === "--change" || arg === "--task") {
300
315
  index += 1;
301
316
  if (index >= argv.length) {
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.54.0",
5
+ "version": "5.55.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.54.0",
3
+ "version": "5.55.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.54.0",
3
+ "version": "5.55.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.54.0"
41
- PROTOCOL_VERSION = "5.54.0"
40
+ PACKAGE_VERSION = "5.55.0"
41
+ PROTOCOL_VERSION = "5.55.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
@@ -25968,6 +25968,157 @@ def validate_paused_change_is_not_the_next_action_scenario() -> int:
25968
25968
  return 0
25969
25969
 
25970
25970
 
25971
+ # Issue #112 recorded four re-verifications in one session from contract
25972
+ # changes that could not affect evidence — renaming `M2:` to `M2 (regression):`
25973
+ # among them, with the assertion unchanged by a character. One of the four meant
25974
+ # breaking a testbench, re-running, and restoring it. The gate genuinely cannot
25975
+ # judge which evidence survives; what it could do is stop saying "all of it".
25976
+ def validate_evidence_survives_what_did_not_change_scenario() -> int:
25977
+ label = "evidence-survives-what-did-not-change"
25978
+
25979
+ def fixture(root: Path, name: str, strategy: str = "evidence-first") -> Path:
25980
+ repo = root / name
25981
+ task = strategy_probe_task(
25982
+ strategy=strategy,
25983
+ reason="fixture; nothing here can fail first",
25984
+ commands=(
25985
+ "M1: the first check asserts the public behavior",
25986
+ "M2: the second check asserts the public behavior",
25987
+ "M3: the third check asserts the public behavior",
25988
+ ),
25989
+ )
25990
+ # A recorded anchor that is not the compiled one, so --record re-records.
25991
+ task = task.replace(
25992
+ " - Contract: pending",
25993
+ " - Contract: keel-task-capsule/v1 sha256:" + "0" * 64,
25994
+ )
25995
+ write_gate_fixture(repo, tasks=task)
25996
+ return repo
25997
+
25998
+ def start(repo: Path, *args):
25999
+ result = run_keel(
26000
+ repo, "gate", "task-start", "--change", "demo", "--task", "1.1",
26001
+ "--json", "--no-guard", *args,
26002
+ )
26003
+ try:
26004
+ return json.loads(result.stdout)
26005
+ except json.JSONDecodeError:
26006
+ return {"status": "unparsed", "problems": [{"message": result.stdout[:300]}]}
26007
+
26008
+ def said(payload: dict) -> str:
26009
+ return " ".join(str(x) for x in (payload.get("warnings") or []))
26010
+
26011
+ with tempfile.TemporaryDirectory(prefix="keel-keep-evidence-") as raw:
26012
+ root = Path(raw)
26013
+
26014
+ # Control: without a declaration the report covers every check.
26015
+ blanket = start(fixture(root, "blanket"), "--record")
26016
+ if blanket.get("status") != "pass":
26017
+ report(
26018
+ f"{label}: the re-record fixture did not pass; "
26019
+ f"{problem_text(blanket)!r}."
26020
+ )
26021
+ return 1
26022
+ if "stale" not in said(blanket):
26023
+ report(
26024
+ f"{label}: the fixture did not produce a stale-evidence "
26025
+ f"report to narrow; {said(blanket)!r}."
26026
+ )
26027
+ return 1
26028
+
26029
+ narrowed = start(
26030
+ fixture(root, "narrowed"), "--record", "--keep-evidence", "M1,M3"
26031
+ )
26032
+ if narrowed.get("status") != "pass":
26033
+ report(
26034
+ f"{label}: a declaration refused a valid re-record; "
26035
+ f"{problem_text(narrowed)!r}."
26036
+ )
26037
+ return 1
26038
+ spoken = said(narrowed)
26039
+ stale_line = next(
26040
+ (w for w in narrowed["warnings"] if "stale" in str(w)), ""
26041
+ )
26042
+ if "M2" not in stale_line:
26043
+ report(
26044
+ f"{label}: the narrowed report does not name the check that is "
26045
+ f"still stale; {stale_line!r}."
26046
+ )
26047
+ return 1
26048
+ for kept in ("M1", "M3"):
26049
+ if kept not in stale_line:
26050
+ report(
26051
+ f"{label}: the narrowed report does not name {kept} as "
26052
+ f"declared unaffected; {stale_line!r}."
26053
+ )
26054
+ return 1
26055
+ if "declar" not in stale_line.lower():
26056
+ report(
26057
+ f"{label}: the narrowed report does not attribute the "
26058
+ f"narrowing to the declaration; {stale_line!r}."
26059
+ )
26060
+ return 1
26061
+
26062
+ unknown = start(
26063
+ fixture(root, "unknown"), "--record", "--keep-evidence", "M9"
26064
+ )
26065
+ if unknown.get("status") != "fail":
26066
+ report(
26067
+ f"{label}: a declaration naming a check the contract does not "
26068
+ "declare was accepted; task-start returned "
26069
+ f"{unknown.get('status')!r}."
26070
+ )
26071
+ return 1
26072
+ if "M9" not in problem_text(unknown):
26073
+ report(
26074
+ f"{label}: the refusal does not name the label it could not "
26075
+ f"resolve; {problem_text(unknown)!r}."
26076
+ )
26077
+ return 1
26078
+
26079
+ stray = start(fixture(root, "stray"), "--keep-evidence", "M1")
26080
+ if stray.get("status") != "fail":
26081
+ report(
26082
+ f"{label}: --keep-evidence without --record was accepted, so "
26083
+ "an author could believe they declared something nothing read."
26084
+ )
26085
+ return 1
26086
+
26087
+ # Completion is unchanged: the declaration is about the past.
26088
+ completing = fixture(root, "completing", strategy="vertical-tdd")
26089
+ start(completing, "--record", "--keep-evidence", "M1,M2,M3")
26090
+ tasks_path = completing / "openspec/changes/demo/tasks.md"
26091
+ tasks_path.write_text(
26092
+ tasks_path.read_text(encoding="utf-8")
26093
+ .replace("- [ ] 1.1", "- [x] 1.1")
26094
+ .replace(" - M1: pending", " - M1: pass. ran it.")
26095
+ .replace(" - M2: pending", " - M2: pass. ran it.")
26096
+ .replace(" - M3: pending", " - M3: pass. ran it."),
26097
+ encoding="utf-8",
26098
+ )
26099
+ done = json.loads(
26100
+ run_keel(
26101
+ completing, "gate", "task-complete", "--change", "demo",
26102
+ "--task", "1.1", "--json",
26103
+ ).stdout
26104
+ )
26105
+ if done.get("status") != "fail":
26106
+ report(
26107
+ f"{label}: a declaration let a red-green task complete without "
26108
+ "its .red/.green Evidence."
26109
+ )
26110
+ return 1
26111
+ if "missing-strategy-evidence" not in problem_codes(done):
26112
+ report(
26113
+ f"{label}: completion stopped requiring red-green Evidence; "
26114
+ f"{problem_codes(done)!r}."
26115
+ )
26116
+ return 1
26117
+
26118
+ report(f"{label} scenario passed.")
26119
+ return 0
26120
+
26121
+
25971
26122
  # A scenario name, as the registry spells one. Two registered names carry no
25972
26123
  # hyphen — `cli` and `uninstall` — so requiring one would leave exactly those
25973
26124
  # two unchecked, and allowing single words was measured to add no false
@@ -26207,6 +26358,7 @@ SCENARIOS: tuple = (
26207
26358
  ("the-obligation-is-stated-early", validate_obligation_is_stated_early_scenario),
26208
26359
  ("an-explanation-is-printed-once", validate_explanation_is_printed_once_scenario),
26209
26360
  ("a-paused-change-is-not-the-next-action", validate_paused_change_is_not_the_next_action_scenario),
26361
+ ("evidence-survives-what-did-not-change", validate_evidence_survives_what_did_not_change_scenario),
26210
26362
  (
26211
26363
  "authored-scenario-names-are-registered",
26212
26364
  validate_authored_scenario_names_scenario,
package/src/core/gates.js CHANGED
@@ -288,6 +288,37 @@ function taskStart(repo, options) {
288
288
  // authors to — needs no manual edit. Refusal is kept only for a task with no
289
289
  // anchor at all, which is a malformed capsule rather than a reauthorization,
290
290
  // and it writes nothing, guard manifest included.
291
+ const declaredKeep = Array.isArray(options.keepEvidence)
292
+ ? options.keepEvidence
293
+ : [];
294
+ if (declaredKeep.length > 0 && !options.record) {
295
+ problems.push(
296
+ problem(
297
+ "keep-evidence-without-record",
298
+ "--keep-evidence declares which evidence a re-record leaves standing, "
299
+ + "and there is no re-record here. Pass --record, or drop the "
300
+ + "declaration so nothing reads it."
301
+ )
302
+ );
303
+ }
304
+ if (declaredKeep.length > 0 && compiled.diagnostics.length === 0) {
305
+ const labels = compiled.capsule.verification.commands.map(
306
+ (item) => item.label
307
+ );
308
+ const unknown = declaredKeep.filter((label) => !labels.includes(label));
309
+ if (unknown.length > 0) {
310
+ // Refused rather than ignored: the likeliest cause is a typo or a check
311
+ // that was renamed, and ignoring it would leave the author believing
312
+ // evidence was kept that was not.
313
+ problems.push(
314
+ problem(
315
+ "keep-evidence-unknown-check",
316
+ `--keep-evidence names ${unknown.join(", ")}, which this contract `
317
+ + `does not declare as a check. It declares ${labels.join(", ")}.`
318
+ )
319
+ );
320
+ }
321
+ }
291
322
  let anchorPlan = null;
292
323
  if (options.record && problems.length === 0) {
293
324
  anchorPlan = contractAnchorPlan(selection, task);
@@ -364,11 +395,30 @@ function taskStart(repo, options) {
364
395
  // call to the current agent's Review.
365
396
  const replaced = anchoredFingerprint(anchorPlan.previous);
366
397
  if (replaced && replaced !== compiled.fingerprint.value) {
398
+ // The gate still cannot judge which evidence survives, and this does not
399
+ // make it able to. What it can stop doing is saying "all of it" to an
400
+ // author who can see that one check's assertion did not change — because
401
+ // an author acting on that sentence in good faith re-runs everything,
402
+ // and issue #112 measured four such re-verifications in one session, one
403
+ // of which meant breaking a testbench and restoring it.
404
+ const kept = declaredKeep;
405
+ const labels = compiled.capsule.verification.commands.map(
406
+ (item) => item.label
407
+ );
408
+ const stale = labels.filter((label) => !kept.includes(label));
367
409
  result.warnings.push(
368
410
  `Re-recorded over a different contract: was sha256:${replaced}, now `
369
- + `sha256:${compiled.fingerprint.value}. Execution evidence produced `
370
- + "under the previous contract is stale; clear or re-verify it "
371
- + "before completing this task."
411
+ + `sha256:${compiled.fingerprint.value}. `
412
+ + (kept.length > 0
413
+ ? `Evidence for ${stale.length > 0 ? stale.join(", ") : "no check"}`
414
+ + " is stale; clear or re-verify it before completing this task. "
415
+ + `${kept.join(", ")} ${kept.length > 1 ? "were" : "was"} `
416
+ + "declared unaffected by this contract change — a declaration "
417
+ + "Keel records and does not verify, since it retains only the "
418
+ + "previous fingerprint and cannot compare a check's former text "
419
+ + "to its current one. State the reason in Reauthorizations."
420
+ : "Execution evidence produced under the previous contract is "
421
+ + "stale; clear or re-verify it before completing this task.")
372
422
  );
373
423
  }
374
424
  }