@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.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.
|
|
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.
|
|
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.
|
|
41
|
-
PROTOCOL_VERSION = "5.
|
|
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
|
-
|
|
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
|
|
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,
|
|
1055
|
+
} else if (!findingOwnerIsDurable(repo, scannable, change)) {
|
|
1025
1056
|
problems.push(
|
|
1026
1057
|
problem(
|
|
1027
1058
|
"finding-owner",
|