@christang/keel 5.53.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 +40 -0
- package/assets/bootstrap/AGENTS.md +1 -1
- package/bin/keel.js +15 -0
- package/package.json +1 -1
- package/plugins/keel/.claude-plugin/plugin.json +1 -1
- package/plugins/keel/.codex-plugin/plugin.json +1 -1
- package/scripts/validate_plugin.py +311 -2
- package/src/core/context.js +111 -7
- package/src/core/gates.js +53 -3
package/README.md
CHANGED
|
@@ -333,6 +333,46 @@ 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
|
+
|
|
353
|
+
## Pausing a change
|
|
354
|
+
|
|
355
|
+
A change you have deliberately stopped — waiting on something outside the repository, or simply
|
|
356
|
+
not the priority — can say so where it lives, in its own `openspec/changes/<name>/.openspec.yaml`:
|
|
357
|
+
|
|
358
|
+
```yaml
|
|
359
|
+
schema: keel-spec-driven
|
|
360
|
+
created: 2026-09-05
|
|
361
|
+
keel:
|
|
362
|
+
status: paused
|
|
363
|
+
reason: waiting on the competition brief; the priority is knowledge that needs no tooling
|
|
364
|
+
since: 2026-09-05
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
`keel context` then passes over it when inferring what to do next, and says which change it passed
|
|
368
|
+
over and why — a skip you cannot see would be worse than the wrong recommendation it replaces. If
|
|
369
|
+
every active change is paused, `context` reports that, with each reason, rather than reporting that
|
|
370
|
+
nothing exists.
|
|
371
|
+
|
|
372
|
+
This changes only what Keel *recommends*. No gate, the write guard, and completion all behave
|
|
373
|
+
exactly as they would without it, and `keel context --change <paused>` still selects it — you asked
|
|
374
|
+
for it by name. Keel never pauses a change on its own.
|
|
375
|
+
|
|
336
376
|
## Commands
|
|
337
377
|
|
|
338
378
|
```bash
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.
|
|
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.
|
|
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.
|
|
41
|
-
PROTOCOL_VERSION = "5.
|
|
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
|
|
@@ -25812,6 +25812,313 @@ def validate_explanation_is_printed_once_scenario() -> int:
|
|
|
25812
25812
|
return 0
|
|
25813
25813
|
|
|
25814
25814
|
|
|
25815
|
+
# Issue #112's most valuable finding: a change its owner had stopped kept being
|
|
25816
|
+
# recommended, and the workaround was a paragraph in the project's CLAUDE.md
|
|
25817
|
+
# telling future sessions to ignore keel's own primary output.
|
|
25818
|
+
def validate_paused_change_is_not_the_next_action_scenario() -> int:
|
|
25819
|
+
label = "a-paused-change-is-not-the-next-action"
|
|
25820
|
+
|
|
25821
|
+
def write_change(repo: Path, name: str, keel_block: str | None) -> None:
|
|
25822
|
+
task = strategy_probe_task(strategy="vertical-tdd")
|
|
25823
|
+
change = repo / "openspec" / "changes" / name
|
|
25824
|
+
write_text(
|
|
25825
|
+
change / "tasks.md",
|
|
25826
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n## Expectation Coverage\n\n"
|
|
25827
|
+
"- E1:\n - Covered by: 1.1\n\n## Tasks\n\n" + task,
|
|
25828
|
+
)
|
|
25829
|
+
write_text(change / "proposal.md", "# Proposal\n")
|
|
25830
|
+
write_text(change / "design.md", "## Context\n\nfixture\n")
|
|
25831
|
+
write_text(change / "specs/demo/spec.md", "## ADDED Requirements\n")
|
|
25832
|
+
write_text(
|
|
25833
|
+
change / ".openspec.yaml",
|
|
25834
|
+
"schema: keel-spec-driven\ncreated: 2026-09-08\n"
|
|
25835
|
+
+ (keel_block or ""),
|
|
25836
|
+
)
|
|
25837
|
+
|
|
25838
|
+
paused_block = (
|
|
25839
|
+
"keel:\n"
|
|
25840
|
+
" status: paused\n"
|
|
25841
|
+
" reason: 打分寻优要等赛题,当前优先级是不依赖工具的知识沉淀\n"
|
|
25842
|
+
" since: 2026-09-05\n"
|
|
25843
|
+
)
|
|
25844
|
+
|
|
25845
|
+
def context(repo: Path, *args):
|
|
25846
|
+
result = run_keel(repo, "context", "--json", *args)
|
|
25847
|
+
try:
|
|
25848
|
+
return json.loads(result.stdout)
|
|
25849
|
+
except json.JSONDecodeError:
|
|
25850
|
+
return {"status": "unparsed", "reasons": [result.stdout[:300]]}
|
|
25851
|
+
|
|
25852
|
+
def said(payload: dict) -> str:
|
|
25853
|
+
return " ".join(
|
|
25854
|
+
str(x) for x in
|
|
25855
|
+
(payload.get("reasons") or []) + (payload.get("warnings") or [])
|
|
25856
|
+
)
|
|
25857
|
+
|
|
25858
|
+
with tempfile.TemporaryDirectory(prefix="keel-paused-") as raw:
|
|
25859
|
+
root = Path(raw)
|
|
25860
|
+
|
|
25861
|
+
# Without the declaration the pair is ambiguous, so the skip is what
|
|
25862
|
+
# changes the answer rather than the fixture shape.
|
|
25863
|
+
both = root / "both"
|
|
25864
|
+
write_change(both, "alpha", None)
|
|
25865
|
+
write_change(both, "beta", None)
|
|
25866
|
+
if context(both).get("status") != "ambiguous":
|
|
25867
|
+
report(
|
|
25868
|
+
f"{label}: two unpaused changes were not ambiguous, so the "
|
|
25869
|
+
"fixture does not isolate what pausing changes."
|
|
25870
|
+
)
|
|
25871
|
+
return 1
|
|
25872
|
+
|
|
25873
|
+
mixed = root / "mixed"
|
|
25874
|
+
write_change(mixed, "alpha", paused_block)
|
|
25875
|
+
write_change(mixed, "beta", None)
|
|
25876
|
+
picked = context(mixed)
|
|
25877
|
+
if picked.get("status") != "ready":
|
|
25878
|
+
report(
|
|
25879
|
+
f"{label}: inference did not pass over the paused change; "
|
|
25880
|
+
f"{picked.get('status')!r} {said(picked)!r}."
|
|
25881
|
+
)
|
|
25882
|
+
return 1
|
|
25883
|
+
if (picked.get("selection") or {}).get("change") != "beta":
|
|
25884
|
+
report(
|
|
25885
|
+
f"{label}: inference selected {picked.get('selection')!r} "
|
|
25886
|
+
"rather than the change that is not paused."
|
|
25887
|
+
)
|
|
25888
|
+
return 1
|
|
25889
|
+
spoken = said(picked)
|
|
25890
|
+
if "alpha" not in spoken or "打分寻优" not in spoken:
|
|
25891
|
+
report(
|
|
25892
|
+
f"{label}: the paused change was skipped silently or without "
|
|
25893
|
+
f"its reason; {spoken!r}."
|
|
25894
|
+
)
|
|
25895
|
+
return 1
|
|
25896
|
+
|
|
25897
|
+
allpaused = root / "allpaused"
|
|
25898
|
+
write_change(allpaused, "alpha", paused_block)
|
|
25899
|
+
write_change(allpaused, "beta", paused_block)
|
|
25900
|
+
idle = context(allpaused)
|
|
25901
|
+
if idle.get("status") != "idle":
|
|
25902
|
+
report(
|
|
25903
|
+
f"{label}: a repository whose every change is paused reported "
|
|
25904
|
+
f"{idle.get('status')!r}."
|
|
25905
|
+
)
|
|
25906
|
+
return 1
|
|
25907
|
+
spoken = said(idle)
|
|
25908
|
+
for needed in ("alpha", "beta", "打分寻优"):
|
|
25909
|
+
if needed not in spoken:
|
|
25910
|
+
report(
|
|
25911
|
+
f"{label}: the all-paused report omits {needed!r}; "
|
|
25912
|
+
f"{spoken!r}."
|
|
25913
|
+
)
|
|
25914
|
+
return 1
|
|
25915
|
+
|
|
25916
|
+
explicit = context(mixed, "--change", "alpha")
|
|
25917
|
+
if (explicit.get("selection") or {}).get("change") != "alpha":
|
|
25918
|
+
report(
|
|
25919
|
+
f"{label}: explicit selection no longer reaches a paused "
|
|
25920
|
+
f"change; {explicit.get('status')!r} {said(explicit)!r}."
|
|
25921
|
+
)
|
|
25922
|
+
return 1
|
|
25923
|
+
if "paused" not in said(explicit).lower():
|
|
25924
|
+
report(
|
|
25925
|
+
f"{label}: explicit selection did not report that the change "
|
|
25926
|
+
f"is paused; {said(explicit)!r}."
|
|
25927
|
+
)
|
|
25928
|
+
return 1
|
|
25929
|
+
|
|
25930
|
+
# D6: a declaration Keel cannot read pauses nothing.
|
|
25931
|
+
broken = root / "broken"
|
|
25932
|
+
write_change(broken, "alpha", "keel:\n status: perhaps-later\n")
|
|
25933
|
+
write_change(broken, "beta", None)
|
|
25934
|
+
unreadable = context(broken)
|
|
25935
|
+
if unreadable.get("status") != "ambiguous":
|
|
25936
|
+
report(
|
|
25937
|
+
f"{label}: an unreadable declaration removed a change from "
|
|
25938
|
+
f"inference; {unreadable.get('status')!r} {said(unreadable)!r}."
|
|
25939
|
+
)
|
|
25940
|
+
return 1
|
|
25941
|
+
if "perhaps-later" not in said(unreadable):
|
|
25942
|
+
report(
|
|
25943
|
+
f"{label}: an unreadable declaration was not reported; "
|
|
25944
|
+
f"{said(unreadable)!r}."
|
|
25945
|
+
)
|
|
25946
|
+
return 1
|
|
25947
|
+
|
|
25948
|
+
# D4: pausing is not a gate.
|
|
25949
|
+
gated = run_keel(
|
|
25950
|
+
mixed, "gate", "task-start", "--change", "alpha", "--task", "1.1",
|
|
25951
|
+
"--json", "--no-guard",
|
|
25952
|
+
)
|
|
25953
|
+
ungated = run_keel(
|
|
25954
|
+
both, "gate", "task-start", "--change", "alpha", "--task", "1.1",
|
|
25955
|
+
"--json", "--no-guard",
|
|
25956
|
+
)
|
|
25957
|
+
if json.loads(gated.stdout).get("status") != json.loads(
|
|
25958
|
+
ungated.stdout
|
|
25959
|
+
).get("status"):
|
|
25960
|
+
report(
|
|
25961
|
+
f"{label}: pausing changed a gate verdict — paused "
|
|
25962
|
+
f"{json.loads(gated.stdout).get('status')!r} against unpaused "
|
|
25963
|
+
f"{json.loads(ungated.stdout).get('status')!r}."
|
|
25964
|
+
)
|
|
25965
|
+
return 1
|
|
25966
|
+
|
|
25967
|
+
report(f"{label} scenario passed.")
|
|
25968
|
+
return 0
|
|
25969
|
+
|
|
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
|
+
|
|
25815
26122
|
# A scenario name, as the registry spells one. Two registered names carry no
|
|
25816
26123
|
# hyphen — `cli` and `uninstall` — so requiring one would leave exactly those
|
|
25817
26124
|
# two unchecked, and allowing single words was measured to add no false
|
|
@@ -26050,6 +26357,8 @@ SCENARIOS: tuple = (
|
|
|
26050
26357
|
("a-reference-outlives-its-declaration", validate_reference_outlives_its_declaration_scenario),
|
|
26051
26358
|
("the-obligation-is-stated-early", validate_obligation_is_stated_early_scenario),
|
|
26052
26359
|
("an-explanation-is-printed-once", validate_explanation_is_printed_once_scenario),
|
|
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),
|
|
26053
26362
|
(
|
|
26054
26363
|
"authored-scenario-names-are-registered",
|
|
26055
26364
|
validate_authored_scenario_names_scenario,
|
package/src/core/context.js
CHANGED
|
@@ -230,8 +230,21 @@ function resolveExplicit(repo, change, task) {
|
|
|
230
230
|
if (task && !/^\d+(?:\.\d+)+$/.test(task)) {
|
|
231
231
|
return blocked(`Invalid explicit task: ${task}`);
|
|
232
232
|
}
|
|
233
|
+
// A pause is skipped by inference and never by explicit selection: the owner
|
|
234
|
+
// has said which change they mean. It is still reported, because a session
|
|
235
|
+
// resuming into a paused change should know that is what it is.
|
|
236
|
+
const declaration = pauseDeclaration(repo, change);
|
|
237
|
+
const paused = declaration && declaration.paused
|
|
238
|
+
? [
|
|
239
|
+
`Explicitly selected change is paused: ${change} — ${declaration.reason}`
|
|
240
|
+
+ (declaration.since ? ` (since ${declaration.since})` : "")
|
|
241
|
+
+ ". Inference passes over it; you asked for it by name.",
|
|
242
|
+
]
|
|
243
|
+
: [];
|
|
233
244
|
if (!task) {
|
|
234
|
-
|
|
245
|
+
const context = selectionForChange(repo, change, "explicit");
|
|
246
|
+
context.warnings.push(...paused);
|
|
247
|
+
return context;
|
|
235
248
|
}
|
|
236
249
|
|
|
237
250
|
const tasksPath = path.join(repo, "openspec", "changes", change, "tasks.md");
|
|
@@ -259,6 +272,58 @@ function activeChanges(repo) {
|
|
|
259
272
|
.sort();
|
|
260
273
|
}
|
|
261
274
|
|
|
275
|
+
// A change its owner deliberately stopped. Declared where the change lives,
|
|
276
|
+
// under a `keel:` key of the OpenSpec change config — namespaced because that
|
|
277
|
+
// file is OpenSpec's, and a bare `status:` would be a claim on a key OpenSpec
|
|
278
|
+
// may define differently. Read by inference and by nothing else: pausing says
|
|
279
|
+
// what to recommend, never what is allowed, so every gate behaves identically
|
|
280
|
+
// on a paused change and explicit selection still reaches it.
|
|
281
|
+
//
|
|
282
|
+
// A declaration that cannot be read leaves the change available and is
|
|
283
|
+
// reported. The alternative failure — a change silently dropped from inference
|
|
284
|
+
// because its config had a typo — is this defect pointed the other way.
|
|
285
|
+
function pauseDeclaration(repo, change) {
|
|
286
|
+
const configPath = path.join(
|
|
287
|
+
repo, "openspec", "changes", change, ".openspec.yaml"
|
|
288
|
+
);
|
|
289
|
+
if (!fs.existsSync(configPath)) return null;
|
|
290
|
+
let content;
|
|
291
|
+
try {
|
|
292
|
+
content = fs.readFileSync(configPath, "utf8");
|
|
293
|
+
} catch {
|
|
294
|
+
return { unreadable: "the file could not be read" };
|
|
295
|
+
}
|
|
296
|
+
// The indented body of a top-level `keel:` key: every following line that
|
|
297
|
+
// starts with whitespace. Simpler and safer than a lookahead for the next
|
|
298
|
+
// top-level key, which has to spell "end of input" as well.
|
|
299
|
+
const block = content.match(/^keel:[ \t]*\r?\n((?:[ \t]+\S[^\n]*\r?\n?)*)/m);
|
|
300
|
+
if (!block) return null;
|
|
301
|
+
const entries = new Map();
|
|
302
|
+
for (const line of block[1].split(/\r?\n/)) {
|
|
303
|
+
const match = line.match(/^\s+([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/);
|
|
304
|
+
if (match) entries.set(match[1].toLowerCase(), parseScalar(match[2]));
|
|
305
|
+
}
|
|
306
|
+
if (!entries.has("status")) return null;
|
|
307
|
+
const status = String(entries.get("status") || "").toLowerCase();
|
|
308
|
+
if (status !== "paused") {
|
|
309
|
+
return { unreadable: `status: ${entries.get("status")}` };
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
paused: true,
|
|
313
|
+
reason: entries.get("reason") || "no reason recorded",
|
|
314
|
+
since: entries.get("since") || null,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function pauseNote(change, declaration) {
|
|
319
|
+
return (
|
|
320
|
+
`Paused change not inferred: ${change} — ${declaration.reason}`
|
|
321
|
+
+ (declaration.since ? ` (since ${declaration.since})` : "")
|
|
322
|
+
+ ". Select it explicitly with `keel context --change "
|
|
323
|
+
+ `${change}\` if it is what you mean.`
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
262
327
|
function inferContext(repo) {
|
|
263
328
|
const changes = activeChanges(repo);
|
|
264
329
|
if (changes.length === 0) {
|
|
@@ -270,20 +335,57 @@ function inferContext(repo) {
|
|
|
270
335
|
["No active OpenSpec change was found."]
|
|
271
336
|
);
|
|
272
337
|
}
|
|
273
|
-
const
|
|
338
|
+
const declarations = new Map(
|
|
339
|
+
changes.map((change) => [change, pauseDeclaration(repo, change)])
|
|
340
|
+
);
|
|
341
|
+
const unreadable = [...declarations.entries()]
|
|
342
|
+
.filter(([, declaration]) => declaration && declaration.unreadable)
|
|
343
|
+
.map(([change, declaration]) =>
|
|
344
|
+
`Keel configuration for ${change} is not a pause declaration `
|
|
345
|
+
+ `(${declaration.unreadable}); the change stays available to `
|
|
346
|
+
+ "inference. A pause is `keel:` with `status: paused` and a `reason:`."
|
|
347
|
+
);
|
|
348
|
+
const paused = changes.filter(
|
|
349
|
+
(change) => declarations.get(change) && declarations.get(change).paused
|
|
350
|
+
);
|
|
351
|
+
const pauseNotes = paused.map(
|
|
352
|
+
(change) => pauseNote(change, declarations.get(change))
|
|
353
|
+
);
|
|
354
|
+
const active = changes.filter((change) => !paused.includes(change));
|
|
355
|
+
if (active.length === 0) {
|
|
356
|
+
// "Nothing to do" and "everything here is deliberately on hold" are
|
|
357
|
+
// different states, and the second is the one that tells a returning
|
|
358
|
+
// session whether to un-pause something or start something new.
|
|
359
|
+
return result(
|
|
360
|
+
"idle",
|
|
361
|
+
null,
|
|
362
|
+
"none",
|
|
363
|
+
[],
|
|
364
|
+
[
|
|
365
|
+
"Every active OpenSpec change is paused.",
|
|
366
|
+
...pauseNotes,
|
|
367
|
+
...unreadable,
|
|
368
|
+
]
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
const contexts = active.map((change) => selectionForChange(repo, change, "inferred"));
|
|
274
372
|
const storage = contexts.filter((context) => context.storageOnly);
|
|
275
373
|
const candidates = contexts.filter((context) => !context.storageOnly);
|
|
276
|
-
const warnings =
|
|
277
|
-
(
|
|
278
|
-
|
|
279
|
-
|
|
374
|
+
const warnings = [
|
|
375
|
+
...storage.map(
|
|
376
|
+
(context) =>
|
|
377
|
+
`Storage-only backlog ignored during inference: ${context.selection.change}.`
|
|
378
|
+
),
|
|
379
|
+
...pauseNotes,
|
|
380
|
+
...unreadable,
|
|
381
|
+
];
|
|
280
382
|
if (candidates.length === 0) {
|
|
281
383
|
return result(
|
|
282
384
|
"idle",
|
|
283
385
|
null,
|
|
284
386
|
"none",
|
|
285
387
|
storage.flatMap((context) => context.read),
|
|
286
|
-
["No actionable OpenSpec change was found."]
|
|
388
|
+
["No actionable OpenSpec change was found.", ...pauseNotes, ...unreadable]
|
|
287
389
|
);
|
|
288
390
|
}
|
|
289
391
|
if (candidates.length > 1) {
|
|
@@ -297,6 +399,8 @@ function inferContext(repo) {
|
|
|
297
399
|
+ candidates
|
|
298
400
|
.map((context) => context.selection?.change || context.read[0])
|
|
299
401
|
.join(", "),
|
|
402
|
+
...pauseNotes,
|
|
403
|
+
...unreadable,
|
|
300
404
|
]
|
|
301
405
|
);
|
|
302
406
|
}
|
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}.
|
|
370
|
-
+
|
|
371
|
-
|
|
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
|
}
|