@christang/keel 5.3.1 → 5.3.4
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/assets/bootstrap/AGENTS.md +2 -2
- package/assets/openspec/schemas/keel-spec-driven/schema.yaml +28 -6
- package/assets/openspec/schemas/keel-spec-driven/templates/tasks.md +30 -7
- package/package.json +1 -1
- package/plugins/keel/.claude-plugin/plugin.json +1 -1
- package/plugins/keel/.codex-plugin/plugin.json +1 -1
- package/plugins/keel/scripts/session-start.js +8 -1
- package/scripts/bump_version.js +46 -8
- package/scripts/validate_plugin.py +1008 -129
- package/src/core/gates.js +196 -24
- package/src/core/task-contract.js +62 -16
|
@@ -37,8 +37,8 @@ REQUIRED_SCRIPTS = [
|
|
|
37
37
|
"scripts/validate_plugin.py",
|
|
38
38
|
]
|
|
39
39
|
|
|
40
|
-
PACKAGE_VERSION = "5.3.
|
|
41
|
-
PROTOCOL_VERSION = "5.3.
|
|
40
|
+
PACKAGE_VERSION = "5.3.4"
|
|
41
|
+
PROTOCOL_VERSION = "5.3.4"
|
|
42
42
|
LEGACY_MANAGED_START = "<!-- keel:start version=2.1 -->"
|
|
43
43
|
OPENSPEC_SCHEMA_NAME = "keel-spec-driven"
|
|
44
44
|
OPENSPEC_CONFIG_PATH = Path("openspec/config.yaml")
|
|
@@ -50,7 +50,6 @@ OPENSPEC_SURFACE_OVERLAY_END = "<!-- keel:openspec-surface-overlay:end -->"
|
|
|
50
50
|
|
|
51
51
|
SKILL_TARGETS = {"claude", "codex", "opencode"}
|
|
52
52
|
HOOK_TARGETS = {"claude"}
|
|
53
|
-
KEEL_HOOK_NAME = "keel-gate"
|
|
54
53
|
AGENT_TARGETS: set[str] = set()
|
|
55
54
|
ADAPTER_TARGETS = {"claude", "codex", "opencode"}
|
|
56
55
|
AGENT_PROTOCOL_TARGETS = {"codex", "opencode"}
|
|
@@ -415,6 +414,17 @@ def validate_npm_package(errors: list[str]) -> None:
|
|
|
415
414
|
errors.append(f"bin/keel.js missing required CLI support: {required}")
|
|
416
415
|
|
|
417
416
|
|
|
417
|
+
# Path expressions rooted at a tree the retirement check above requires to be
|
|
418
|
+
# absent. `src/core` and `src/skills` are live, so only the retired `src`
|
|
419
|
+
# children are listed.
|
|
420
|
+
RETIRED_PATH_EXPRESSIONS = (
|
|
421
|
+
r'ROOT\s*/\s*"dist"',
|
|
422
|
+
r'ROOT\s*/\s*"src"\s*/\s*"assets"',
|
|
423
|
+
r'ROOT\s*/\s*"src"\s*/\s*"hooks"',
|
|
424
|
+
r'ROOT\s*/\s*"src"\s*/\s*"adapters"',
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
|
|
418
428
|
def validate_paths(errors: list[str]) -> None:
|
|
419
429
|
for directory in REQUIRED_DIRECTORIES:
|
|
420
430
|
if not (ROOT / directory).is_dir():
|
|
@@ -448,6 +458,43 @@ def validate_paths(errors: list[str]) -> None:
|
|
|
448
458
|
f"retired custom distribution path must be removed: {retired}"
|
|
449
459
|
)
|
|
450
460
|
|
|
461
|
+
# Every Keel marker that carries a version is a shipped claim about which
|
|
462
|
+
# version this is. Derive the set from the markers that exist rather than a
|
|
463
|
+
# fixed list, because a fixed list is the next thing to fall behind — which
|
|
464
|
+
# is exactly how the `.codex/` overlays sat four versions back unnoticed.
|
|
465
|
+
for marker_file in sorted(ROOT.rglob("*")):
|
|
466
|
+
if not marker_file.is_file() or not marker_file.suffix in (".md", ".json"):
|
|
467
|
+
continue
|
|
468
|
+
relative = marker_file.relative_to(ROOT).as_posix()
|
|
469
|
+
if relative.startswith(("node_modules/", "openspec/changes/archive/", "keel/archive/")):
|
|
470
|
+
continue
|
|
471
|
+
try:
|
|
472
|
+
text = marker_file.read_text(encoding="utf-8")
|
|
473
|
+
except (UnicodeDecodeError, OSError):
|
|
474
|
+
continue
|
|
475
|
+
for found in re.findall(r"keel:[a-z-]+(?::end)?\s+version=([0-9][^\s>]*)", text):
|
|
476
|
+
if found != PACKAGE_VERSION:
|
|
477
|
+
errors.append(
|
|
478
|
+
"shipped version marker disagrees with the package version "
|
|
479
|
+
f"{PACKAGE_VERSION}: {relative} says {found}"
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
# Asserting the trees are gone is not enough: a check that still resolves a
|
|
483
|
+
# path into one of them can only ever find nothing, and rglob over a missing
|
|
484
|
+
# directory yields no error, so the check reports success forever. Naming a
|
|
485
|
+
# retired tree in a string literal is fine — that is how the checks above
|
|
486
|
+
# state what must not exist; building a Path into one is not.
|
|
487
|
+
validator_source = (ROOT / "scripts" / "validate_plugin.py").read_text(
|
|
488
|
+
encoding="utf-8"
|
|
489
|
+
)
|
|
490
|
+
for line_number, line in enumerate(validator_source.splitlines(), start=1):
|
|
491
|
+
if any(re.search(pattern, line) for pattern in RETIRED_PATH_EXPRESSIONS):
|
|
492
|
+
errors.append(
|
|
493
|
+
"validator resolves a path under a retired distribution tree, "
|
|
494
|
+
"so the check it feeds can only iterate nothing: "
|
|
495
|
+
f"scripts/validate_plugin.py:{line_number}: {line.strip()}"
|
|
496
|
+
)
|
|
497
|
+
|
|
451
498
|
|
|
452
499
|
def extract_managed_block(content: str) -> str | None:
|
|
453
500
|
start_match = MANAGED_START_RE.search(content)
|
|
@@ -571,10 +618,20 @@ def validate_templates(errors: list[str]) -> None:
|
|
|
571
618
|
f"{template['name']} includes forbidden content: {forbidden}"
|
|
572
619
|
)
|
|
573
620
|
|
|
621
|
+
# What actually ships is whatever package.json declares, so derive the roots
|
|
622
|
+
# from there rather than naming a tree that can retire out from under the
|
|
623
|
+
# check the way `src/assets` and `dist` did.
|
|
624
|
+
packaged_roots = [
|
|
625
|
+
ROOT / entry
|
|
626
|
+
for entry in json.loads(
|
|
627
|
+
(ROOT / "package.json").read_text(encoding="utf-8")
|
|
628
|
+
).get("files", [])
|
|
629
|
+
if (ROOT / entry).is_dir()
|
|
630
|
+
]
|
|
631
|
+
|
|
574
632
|
active_task_placeholders = [
|
|
575
633
|
path.relative_to(ROOT).as_posix()
|
|
576
|
-
for base in
|
|
577
|
-
if base.exists()
|
|
634
|
+
for base in packaged_roots
|
|
578
635
|
for path in base.rglob("keel/TASK.md")
|
|
579
636
|
]
|
|
580
637
|
if active_task_placeholders:
|
|
@@ -585,18 +642,9 @@ def validate_templates(errors: list[str]) -> None:
|
|
|
585
642
|
|
|
586
643
|
backlog_assets = [
|
|
587
644
|
path.relative_to(ROOT).as_posix()
|
|
588
|
-
for base in
|
|
589
|
-
if base.exists()
|
|
645
|
+
for base in packaged_roots
|
|
590
646
|
for path in base.rglob("keel/backlog/*")
|
|
591
647
|
]
|
|
592
|
-
backlog_assets.extend(
|
|
593
|
-
path.relative_to(ROOT).as_posix()
|
|
594
|
-
for path in (
|
|
595
|
-
ROOT / "src" / "assets" / "shared" / "backlog",
|
|
596
|
-
ROOT / "dist" / "shared" / "assets" / "backlog",
|
|
597
|
-
)
|
|
598
|
-
if path.exists()
|
|
599
|
-
)
|
|
600
648
|
if backlog_assets:
|
|
601
649
|
errors.append(
|
|
602
650
|
"package must not include keel backlog assets: "
|
|
@@ -606,7 +654,6 @@ def validate_templates(errors: list[str]) -> None:
|
|
|
606
654
|
|
|
607
655
|
def validate_openspec_schema(errors: list[str]) -> None:
|
|
608
656
|
source_root = ROOT / "assets" / "openspec" / "schemas" / OPENSPEC_SCHEMA_NAME
|
|
609
|
-
dist_root = source_root
|
|
610
657
|
required_files = [
|
|
611
658
|
"schema.yaml",
|
|
612
659
|
"templates/proposal.md",
|
|
@@ -615,13 +662,12 @@ def validate_openspec_schema(errors: list[str]) -> None:
|
|
|
615
662
|
"templates/tasks.md",
|
|
616
663
|
]
|
|
617
664
|
|
|
618
|
-
for
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
)
|
|
665
|
+
for relative in required_files:
|
|
666
|
+
if not (source_root / relative).is_file():
|
|
667
|
+
errors.append(
|
|
668
|
+
"OpenSpec source schema missing file: "
|
|
669
|
+
f"{source_root.relative_to(ROOT).as_posix()}/{relative}"
|
|
670
|
+
)
|
|
625
671
|
|
|
626
672
|
schema_path = source_root / "schema.yaml"
|
|
627
673
|
tasks_template_path = source_root / "templates" / "tasks.md"
|
|
@@ -717,33 +763,11 @@ def validate_openspec_schema(errors: list[str]) -> None:
|
|
|
717
763
|
f"{forbidden}"
|
|
718
764
|
)
|
|
719
765
|
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
}
|
|
726
|
-
dist_files = {
|
|
727
|
-
path.relative_to(dist_root).as_posix(): path.read_text(encoding="utf-8")
|
|
728
|
-
for path in sorted(dist_root.rglob("*"))
|
|
729
|
-
if path.is_file()
|
|
730
|
-
}
|
|
731
|
-
if source_files != dist_files:
|
|
732
|
-
missing = sorted(set(source_files) - set(dist_files))
|
|
733
|
-
unexpected = sorted(set(dist_files) - set(source_files))
|
|
734
|
-
changed = sorted(
|
|
735
|
-
path
|
|
736
|
-
for path in set(source_files) & set(dist_files)
|
|
737
|
-
if source_files[path] != dist_files[path]
|
|
738
|
-
)
|
|
739
|
-
errors.append(
|
|
740
|
-
"OpenSpec dist schema differs from source"
|
|
741
|
-
+ (
|
|
742
|
-
f"; missing={missing}, unexpected={unexpected}, changed={changed}"
|
|
743
|
-
if missing or unexpected or changed
|
|
744
|
-
else ""
|
|
745
|
-
)
|
|
746
|
-
)
|
|
766
|
+
# A source-versus-dist comparison stood here, but `dist_root` was assigned
|
|
767
|
+
# `source_root`, so it diffed a directory against itself and could not fail.
|
|
768
|
+
# The pair that really needs comparing — this packaged copy against the
|
|
769
|
+
# repo-local one OpenSpec resolves — is asserted by
|
|
770
|
+
# `invalidation-authoring-surface`.
|
|
747
771
|
|
|
748
772
|
|
|
749
773
|
def validate_skill_docs(errors: list[str]) -> None:
|
|
@@ -946,18 +970,17 @@ def snapshot_files(root: Path) -> dict[str, str]:
|
|
|
946
970
|
return snapshot
|
|
947
971
|
|
|
948
972
|
|
|
949
|
-
def packaged_openspec_schema_install_paths() -> list[str]:
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
/ "assets"
|
|
955
|
-
/ "openspec"
|
|
956
|
-
/ "schemas"
|
|
957
|
-
/ OPENSPEC_SCHEMA_NAME
|
|
973
|
+
def packaged_openspec_schema_install_paths(root: Path | None = None) -> list[str]:
|
|
974
|
+
# The root the installer itself reads (install_to_repo.openspec_schema_actions),
|
|
975
|
+
# which raises on the same condition. A validator that answered `[]` here left
|
|
976
|
+
# six install/uninstall/clear assertions iterating nothing and reporting pass.
|
|
977
|
+
schema_root = root if root is not None else (
|
|
978
|
+
ROOT / "assets" / "openspec" / "schemas" / OPENSPEC_SCHEMA_NAME
|
|
958
979
|
)
|
|
959
980
|
if not schema_root.is_dir():
|
|
960
|
-
|
|
981
|
+
raise FileNotFoundError(
|
|
982
|
+
f"packaged OpenSpec schema root is missing: {schema_root}"
|
|
983
|
+
)
|
|
961
984
|
|
|
962
985
|
return [
|
|
963
986
|
(OPENSPEC_SCHEMA_ROOT / path.relative_to(schema_root)).as_posix()
|
|
@@ -1758,7 +1781,7 @@ def validate_authoring_continuity_scenario() -> int:
|
|
|
1758
1781
|
change_root / "specs/demo/spec.md",
|
|
1759
1782
|
"## ADDED Requirements\n",
|
|
1760
1783
|
)
|
|
1761
|
-
write_text(change_root / "tasks.md", "# Tasks\n\n## Tasks\n")
|
|
1784
|
+
write_text(change_root / "tasks.md", "# Tasks\n\n## Invalidates\n\n- None.\n\n## Tasks\n")
|
|
1762
1785
|
invalid = run_keel(invalid_repo, "context", "--json")
|
|
1763
1786
|
invalid_payload = json.loads(invalid.stdout)
|
|
1764
1787
|
if (
|
|
@@ -2462,22 +2485,6 @@ def validate_update_default_registry_scenario() -> int:
|
|
|
2462
2485
|
return 0
|
|
2463
2486
|
|
|
2464
2487
|
|
|
2465
|
-
def run_keel_hook(repo: Path, event: dict) -> subprocess.CompletedProcess[str]:
|
|
2466
|
-
env = dict(os.environ)
|
|
2467
|
-
env["KEEL_CLI"] = str(ROOT / "bin/keel.js")
|
|
2468
|
-
return subprocess.run(
|
|
2469
|
-
["node", str(ROOT / "dist" / "claude" / "hooks" / KEEL_HOOK_NAME / "keel-gate.js")],
|
|
2470
|
-
cwd=repo,
|
|
2471
|
-
env=env,
|
|
2472
|
-
input=json.dumps(event),
|
|
2473
|
-
text=True,
|
|
2474
|
-
encoding="utf-8",
|
|
2475
|
-
errors="replace",
|
|
2476
|
-
capture_output=True,
|
|
2477
|
-
check=False,
|
|
2478
|
-
)
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
2488
|
def gate_task(
|
|
2482
2489
|
*,
|
|
2483
2490
|
checked: bool,
|
|
@@ -2527,7 +2534,9 @@ def write_gate_fixture(repo: Path, tasks: str, design: str = "## Context\n\nfixt
|
|
|
2527
2534
|
change = repo / "openspec/changes/demo"
|
|
2528
2535
|
write_text(
|
|
2529
2536
|
change / "tasks.md",
|
|
2530
|
-
"# Tasks\n\n"
|
|
2537
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
2538
|
+
"## Invalidates\n\n"
|
|
2539
|
+
"- None.\n\n"
|
|
2531
2540
|
"## Expectation Coverage\n\n"
|
|
2532
2541
|
"- E1:\n"
|
|
2533
2542
|
" - Covered by: 1.1\n\n"
|
|
@@ -2625,7 +2634,7 @@ def validate_cli_scenario() -> int:
|
|
|
2625
2634
|
|
|
2626
2635
|
write_text(
|
|
2627
2636
|
repo / "openspec/changes/status-drift/tasks.md",
|
|
2628
|
-
"# Tasks\n\n"
|
|
2637
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
2629
2638
|
"## Tasks\n\n"
|
|
2630
2639
|
"- [x] A1 implementation **未提交**\n\n"
|
|
2631
2640
|
"## Execution Status\n\n"
|
|
@@ -2648,7 +2657,7 @@ def validate_cli_scenario() -> int:
|
|
|
2648
2657
|
|
|
2649
2658
|
write_text(
|
|
2650
2659
|
repo / "openspec/changes/status-drift/tasks.md",
|
|
2651
|
-
"# Tasks\n\n"
|
|
2660
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
2652
2661
|
"> Boundary: tasks.md is the source for the current change's slice checklist and logical completion only. Completion state is only `[x]` / `[ ]`; progress and the default next slice are derived from the checklist. Do not record commit hashes, branch/merge state, dirty/uncommitted state, or manually computed completion counts. Durable work state belongs in OpenSpec; HANDOFF is only an explicit pointer override.\n\n"
|
|
2653
2662
|
"## Tasks\n\n"
|
|
2654
2663
|
"- [x] A1 implementation\n\n"
|
|
@@ -2666,7 +2675,7 @@ def validate_cli_scenario() -> int:
|
|
|
2666
2675
|
|
|
2667
2676
|
write_text(
|
|
2668
2677
|
repo / "openspec/changes/archive/2026-07-09-finished/tasks.md",
|
|
2669
|
-
"# Tasks\n\n"
|
|
2678
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
2670
2679
|
"- [x] A1 implementation\n\n"
|
|
2671
2680
|
"## Evidence\n\n"
|
|
2672
2681
|
"- Scope check: pre-existing dirty paths were not attributed to this task.\n",
|
|
@@ -2862,7 +2871,7 @@ def validate_stateless_continuity_scenario() -> int:
|
|
|
2862
2871
|
|
|
2863
2872
|
write_text(
|
|
2864
2873
|
repo / "openspec/changes/other/tasks.md",
|
|
2865
|
-
"# Tasks\n\n- [ ] 1.1 Other task\n",
|
|
2874
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n- [ ] 1.1 Other task\n",
|
|
2866
2875
|
)
|
|
2867
2876
|
write_text(
|
|
2868
2877
|
repo / "keel/HANDOFF.md",
|
|
@@ -2922,7 +2931,7 @@ def validate_stateless_continuity_scenario() -> int:
|
|
|
2922
2931
|
|
|
2923
2932
|
write_text(
|
|
2924
2933
|
tasks_path,
|
|
2925
|
-
"# Tasks\n\n"
|
|
2934
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
2926
2935
|
"- [x] 1.1 First slice\n"
|
|
2927
2936
|
"- [x] 1.2 Second slice\n",
|
|
2928
2937
|
)
|
|
@@ -3042,7 +3051,7 @@ def validate_stateless_continuity_scenario() -> int:
|
|
|
3042
3051
|
stale_repo.mkdir()
|
|
3043
3052
|
write_text(
|
|
3044
3053
|
stale_repo / "openspec/changes/current/tasks.md",
|
|
3045
|
-
"# Tasks\n\n- [ ] 1.1 Current task\n",
|
|
3054
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n- [ ] 1.1 Current task\n",
|
|
3046
3055
|
)
|
|
3047
3056
|
write_text(
|
|
3048
3057
|
stale_repo / "keel/HANDOFF.md",
|
|
@@ -3067,7 +3076,7 @@ def validate_stateless_continuity_scenario() -> int:
|
|
|
3067
3076
|
|
|
3068
3077
|
write_text(
|
|
3069
3078
|
stale_repo / "openspec/changes/current/tasks.md",
|
|
3070
|
-
"# Tasks\n\n- [x] 1.1 Current task\n",
|
|
3079
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n- [x] 1.1 Current task\n",
|
|
3071
3080
|
)
|
|
3072
3081
|
write_text(
|
|
3073
3082
|
stale_repo / "keel/HANDOFF.md",
|
|
@@ -3094,7 +3103,7 @@ def validate_stateless_continuity_scenario() -> int:
|
|
|
3094
3103
|
|
|
3095
3104
|
write_text(
|
|
3096
3105
|
stale_repo / "openspec/changes/current/tasks.md",
|
|
3097
|
-
"# Tasks\n\n- [ ] 1.1 Current task\n",
|
|
3106
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n- [ ] 1.1 Current task\n",
|
|
3098
3107
|
)
|
|
3099
3108
|
write_text(
|
|
3100
3109
|
stale_repo / "keel/HANDOFF.md",
|
|
@@ -3439,7 +3448,7 @@ def task_contract_fixture(
|
|
|
3439
3448
|
command_lines = "".join(f" - {item}\n" for item in commands)
|
|
3440
3449
|
evidence_lines = "".join(f" - {item}\n" for item in evidence)
|
|
3441
3450
|
return (
|
|
3442
|
-
"# Tasks\n\n"
|
|
3451
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
3443
3452
|
"- [ ] 1.1 Exercise task contract\n"
|
|
3444
3453
|
" - Owner: keel-agent\n"
|
|
3445
3454
|
f" - Mode: {mode}\n"
|
|
@@ -3476,6 +3485,794 @@ def task_contract_fixture(
|
|
|
3476
3485
|
)
|
|
3477
3486
|
|
|
3478
3487
|
|
|
3488
|
+
# task-start requires a change to declare what it invalidates, so every fixture
|
|
3489
|
+
# that expects to start a task carries the cheapest legitimate answer. Scenarios
|
|
3490
|
+
# exercising the declaration itself replace this block.
|
|
3491
|
+
INVALIDATES_NONE = "## Invalidates\n\n- None.\n\n"
|
|
3492
|
+
|
|
3493
|
+
|
|
3494
|
+
INVALIDATION_ENTRIES = (
|
|
3495
|
+
'- I1: "Touch is the write boundary" — assets/bootstrap/AGENTS.md.'
|
|
3496
|
+
" Updated by: 1.1\n"
|
|
3497
|
+
'- I2: "does not accept a GitHub issue URL" —'
|
|
3498
|
+
" keel/archive/follow-ups/note.md."
|
|
3499
|
+
" Discard reason: archive notes are historical evidence.\n"
|
|
3500
|
+
'- I3: "the suite is not portable" — README.md.'
|
|
3501
|
+
" Durable owner: https://example.invalid/issues/1\n"
|
|
3502
|
+
)
|
|
3503
|
+
|
|
3504
|
+
|
|
3505
|
+
def invalidation_repo(root: Path, name: str, section: str | None) -> Path:
|
|
3506
|
+
repo = root / name
|
|
3507
|
+
# A real Contract anchor, so the missing-section case can prove that a
|
|
3508
|
+
# failing authoring gate leaves the anchor untouched rather than merely
|
|
3509
|
+
# failing earlier for want of one.
|
|
3510
|
+
body = task_contract_fixture(evidence=("Contract: pending", "M1: pending"))
|
|
3511
|
+
body = body.replace(INVALIDATES_NONE, "" if section is None else section)
|
|
3512
|
+
write_text(repo / "openspec/changes/demo/tasks.md", body)
|
|
3513
|
+
return repo
|
|
3514
|
+
|
|
3515
|
+
|
|
3516
|
+
def validate_task_start_invalidation_scenario() -> int:
|
|
3517
|
+
label = "task-start-invalidation"
|
|
3518
|
+
|
|
3519
|
+
def gate(repo: Path, *extra: str) -> tuple[subprocess.CompletedProcess[str], dict]:
|
|
3520
|
+
result = run_keel(
|
|
3521
|
+
repo, "gate", "task-start", ".",
|
|
3522
|
+
"--change", "demo", "--task", "1.1", "--json", *extra,
|
|
3523
|
+
)
|
|
3524
|
+
payload = json.loads(result.stdout) if result.stdout.strip() else {}
|
|
3525
|
+
return result, payload
|
|
3526
|
+
|
|
3527
|
+
def codes(payload: dict) -> set[str]:
|
|
3528
|
+
return {item.get("code") for item in payload.get("problems", [])}
|
|
3529
|
+
|
|
3530
|
+
def messages(payload: dict) -> str:
|
|
3531
|
+
return " ".join(item.get("message", "") for item in payload.get("problems", []))
|
|
3532
|
+
|
|
3533
|
+
with tempfile.TemporaryDirectory(
|
|
3534
|
+
prefix="keel-invalidation-", ignore_cleanup_errors=True
|
|
3535
|
+
) as raw:
|
|
3536
|
+
tmp = Path(raw)
|
|
3537
|
+
|
|
3538
|
+
# A change that never answered the question cannot start, and the
|
|
3539
|
+
# refusal writes nothing — the guard manifest and the Contract anchor
|
|
3540
|
+
# are both withheld, so a failed authoring gate leaves no state behind.
|
|
3541
|
+
missing = invalidation_repo(tmp, "missing", None)
|
|
3542
|
+
tasks_before = (missing / "openspec/changes/demo/tasks.md").read_text(
|
|
3543
|
+
encoding="utf-8"
|
|
3544
|
+
)
|
|
3545
|
+
result, payload = gate(missing, "--record")
|
|
3546
|
+
if (
|
|
3547
|
+
payload.get("status") != "fail"
|
|
3548
|
+
or "invalidation-declaration" not in codes(payload)
|
|
3549
|
+
):
|
|
3550
|
+
report(f"{label} accepted a change with no invalidation section.")
|
|
3551
|
+
report(repr(payload.get("problems")))
|
|
3552
|
+
return 1
|
|
3553
|
+
if (missing / "keel/guard.json").exists():
|
|
3554
|
+
report(f"{label} wrote a guard manifest for a failing authoring gate.")
|
|
3555
|
+
return 1
|
|
3556
|
+
if (missing / "openspec/changes/demo/tasks.md").read_text(
|
|
3557
|
+
encoding="utf-8"
|
|
3558
|
+
) != tasks_before:
|
|
3559
|
+
report(f"{label} recorded a Contract anchor for a failing gate.")
|
|
3560
|
+
return 1
|
|
3561
|
+
|
|
3562
|
+
none_repo = invalidation_repo(tmp, "none", INVALIDATES_NONE)
|
|
3563
|
+
_, none_payload = gate(none_repo, "--no-guard")
|
|
3564
|
+
if none_payload.get("status") != "pass":
|
|
3565
|
+
report(f"{label} refused a legitimate declaration of nothing.")
|
|
3566
|
+
report(repr(none_payload.get("problems")))
|
|
3567
|
+
return 1
|
|
3568
|
+
|
|
3569
|
+
full_repo = invalidation_repo(
|
|
3570
|
+
tmp, "full", "## Invalidates\n\n" + INVALIDATION_ENTRIES + "\n"
|
|
3571
|
+
)
|
|
3572
|
+
_, full_payload = gate(full_repo, "--no-guard")
|
|
3573
|
+
if full_payload.get("status") != "pass":
|
|
3574
|
+
report(f"{label} refused well-formed entries covering all three closures.")
|
|
3575
|
+
report(repr(full_payload.get("problems")))
|
|
3576
|
+
return 1
|
|
3577
|
+
|
|
3578
|
+
# The declaration is change-level bookkeeping, not task authority: two
|
|
3579
|
+
# changes whose tasks are byte-identical must compile the same capsule
|
|
3580
|
+
# however differently they answered this question.
|
|
3581
|
+
none_print = none_payload.get("contract", {}).get("fingerprint", {}).get("value")
|
|
3582
|
+
full_print = full_payload.get("contract", {}).get("fingerprint", {}).get("value")
|
|
3583
|
+
if not none_print or none_print != full_print:
|
|
3584
|
+
report(
|
|
3585
|
+
f"{label} let the invalidation section move the capsule "
|
|
3586
|
+
f"fingerprint: {none_print} vs {full_print}"
|
|
3587
|
+
)
|
|
3588
|
+
return 1
|
|
3589
|
+
|
|
3590
|
+
# A location list only ever names files the author already recalled,
|
|
3591
|
+
# which is the failure this section exists to prevent, so an entry
|
|
3592
|
+
# without the searchable wording is refused.
|
|
3593
|
+
no_phrase = invalidation_repo(
|
|
3594
|
+
tmp,
|
|
3595
|
+
"no-phrase",
|
|
3596
|
+
"## Invalidates\n\n- I1: AGENTS.md and README.md. Updated by: 1.1\n\n",
|
|
3597
|
+
)
|
|
3598
|
+
_, no_phrase_payload = gate(no_phrase, "--no-guard")
|
|
3599
|
+
if (
|
|
3600
|
+
no_phrase_payload.get("status") != "fail"
|
|
3601
|
+
or "I1" not in messages(no_phrase_payload)
|
|
3602
|
+
):
|
|
3603
|
+
report(f"{label} accepted an entry with no searchable phrase.")
|
|
3604
|
+
report(repr(no_phrase_payload.get("problems")))
|
|
3605
|
+
return 1
|
|
3606
|
+
|
|
3607
|
+
unclosed = invalidation_repo(
|
|
3608
|
+
tmp,
|
|
3609
|
+
"unclosed",
|
|
3610
|
+
'## Invalidates\n\n- I1: "Touch is the write boundary" — AGENTS.md.\n\n',
|
|
3611
|
+
)
|
|
3612
|
+
_, unclosed_payload = gate(unclosed, "--no-guard")
|
|
3613
|
+
if (
|
|
3614
|
+
unclosed_payload.get("status") != "fail"
|
|
3615
|
+
or "I1" not in messages(unclosed_payload)
|
|
3616
|
+
):
|
|
3617
|
+
report(f"{label} accepted an entry that never closed.")
|
|
3618
|
+
report(repr(unclosed_payload.get("problems")))
|
|
3619
|
+
return 1
|
|
3620
|
+
|
|
3621
|
+
unknown_owner = invalidation_repo(
|
|
3622
|
+
tmp,
|
|
3623
|
+
"unknown-task",
|
|
3624
|
+
'## Invalidates\n\n- I1: "Touch is the write boundary" — AGENTS.md.'
|
|
3625
|
+
" Updated by: 9.9\n\n",
|
|
3626
|
+
)
|
|
3627
|
+
_, unknown_payload = gate(unknown_owner, "--no-guard")
|
|
3628
|
+
if unknown_payload.get("status") != "fail":
|
|
3629
|
+
report(f"{label} accepted an updater task that does not exist.")
|
|
3630
|
+
return 1
|
|
3631
|
+
|
|
3632
|
+
if not codes(payload):
|
|
3633
|
+
report(f"{label} reported a failure with no problem code.")
|
|
3634
|
+
return 1
|
|
3635
|
+
|
|
3636
|
+
report(f"{label} scenario passed.")
|
|
3637
|
+
return 0
|
|
3638
|
+
|
|
3639
|
+
|
|
3640
|
+
SCHEMA_COPY_PAIRS = (
|
|
3641
|
+
(
|
|
3642
|
+
"openspec/schemas/keel-spec-driven/templates/tasks.md",
|
|
3643
|
+
"assets/openspec/schemas/keel-spec-driven/templates/tasks.md",
|
|
3644
|
+
),
|
|
3645
|
+
(
|
|
3646
|
+
"openspec/schemas/keel-spec-driven/schema.yaml",
|
|
3647
|
+
"assets/openspec/schemas/keel-spec-driven/schema.yaml",
|
|
3648
|
+
),
|
|
3649
|
+
)
|
|
3650
|
+
|
|
3651
|
+
|
|
3652
|
+
def validate_anchor_reverification_bound_scenario() -> int:
|
|
3653
|
+
label = "anchor-reverification-bound"
|
|
3654
|
+
|
|
3655
|
+
# The fingerprint is described as recompiled and compared at resume,
|
|
3656
|
+
# projection, and completion, with no stated bound. It holds while the
|
|
3657
|
+
# change is live: the capsule records each authority's source as a path
|
|
3658
|
+
# under the change directory, and archiving renames that directory. An
|
|
3659
|
+
# unstated boundary reads as no boundary, so demonstrate where it is.
|
|
3660
|
+
with tempfile.TemporaryDirectory(prefix="keel-anchor-bound-") as raw_tmp:
|
|
3661
|
+
repo = Path(raw_tmp) / "repo"
|
|
3662
|
+
repo.mkdir()
|
|
3663
|
+
live = repo / "openspec/changes/demo/tasks.md"
|
|
3664
|
+
write_text(live, task_contract_fixture(evidence=("Contract: pending", "M1: pending")))
|
|
3665
|
+
|
|
3666
|
+
recorded = run_keel(
|
|
3667
|
+
repo, "gate", "task-start", "--change", "demo", "--task", "1.1",
|
|
3668
|
+
"--record", "--json",
|
|
3669
|
+
)
|
|
3670
|
+
payload = json.loads(recorded.stdout)
|
|
3671
|
+
if payload.get("status") != "pass":
|
|
3672
|
+
report(f"{label} could not record an anchor on a live change.")
|
|
3673
|
+
report(json.dumps(payload.get("problems", []), indent=2))
|
|
3674
|
+
return 1
|
|
3675
|
+
anchor = payload["contract"]["fingerprint"]["value"]
|
|
3676
|
+
|
|
3677
|
+
# Live: recompiling reproduces the recorded value, which is the
|
|
3678
|
+
# guarantee the resident protocol states.
|
|
3679
|
+
again = json.loads(
|
|
3680
|
+
run_keel(
|
|
3681
|
+
repo, "gate", "task-start", "--change", "demo", "--task", "1.1", "--json"
|
|
3682
|
+
).stdout
|
|
3683
|
+
)
|
|
3684
|
+
if again["contract"]["fingerprint"]["value"] != anchor:
|
|
3685
|
+
report(f"{label} a live anchor did not recompile to its recorded value.")
|
|
3686
|
+
return 1
|
|
3687
|
+
|
|
3688
|
+
# Archived: the gate refuses to select the change at all, so the bound
|
|
3689
|
+
# is enforced rather than merely documented.
|
|
3690
|
+
archived = repo / "openspec/changes/archive/2026-07-28-demo/tasks.md"
|
|
3691
|
+
write_text(archived, live.read_text(encoding="utf-8"))
|
|
3692
|
+
refused = run_keel(
|
|
3693
|
+
repo, "gate", "task-start",
|
|
3694
|
+
"--change", "archive/2026-07-28-demo", "--task", "1.1",
|
|
3695
|
+
)
|
|
3696
|
+
if refused.returncode == 0 or "invalid change name" not in (
|
|
3697
|
+
refused.stderr + refused.stdout
|
|
3698
|
+
):
|
|
3699
|
+
report(
|
|
3700
|
+
f"{label} the gate accepted an archived change; the bound this "
|
|
3701
|
+
"documents is supposed to be enforced, not advisory."
|
|
3702
|
+
)
|
|
3703
|
+
report((refused.stderr or refused.stdout).strip())
|
|
3704
|
+
return 1
|
|
3705
|
+
|
|
3706
|
+
# And the reason the refusal is right: compiling the archived copy
|
|
3707
|
+
# directly yields a different fingerprint, because each authority's
|
|
3708
|
+
# `source` names the directory the task now lives in.
|
|
3709
|
+
probe = subprocess.run(
|
|
3710
|
+
[
|
|
3711
|
+
"node", "-e",
|
|
3712
|
+
"const {loadTaskContract}=require(process.argv[1]);"
|
|
3713
|
+
"const c=loadTaskContract(process.argv[2],'archive/2026-07-28-demo','1.1');"
|
|
3714
|
+
"process.stdout.write(c.contract.fingerprint.value);",
|
|
3715
|
+
str(ROOT / "src/core/task-contract.js"),
|
|
3716
|
+
str(repo),
|
|
3717
|
+
],
|
|
3718
|
+
text=True, encoding="utf-8", capture_output=True, check=False,
|
|
3719
|
+
)
|
|
3720
|
+
if probe.returncode != 0:
|
|
3721
|
+
report(f"{label} could not compile the archived copy directly.")
|
|
3722
|
+
report((probe.stderr or probe.stdout).strip())
|
|
3723
|
+
return 1
|
|
3724
|
+
if probe.stdout.strip() == anchor:
|
|
3725
|
+
report(
|
|
3726
|
+
f"{label} the archived copy reproduced the anchor, so the "
|
|
3727
|
+
"documented bound no longer describes reality — revisit the "
|
|
3728
|
+
"protocol wording rather than relaxing this check."
|
|
3729
|
+
)
|
|
3730
|
+
return 1
|
|
3731
|
+
|
|
3732
|
+
# And the resident protocol must say where the guarantee stops.
|
|
3733
|
+
resident = (ROOT / "AGENTS.md").read_text(encoding="utf-8")
|
|
3734
|
+
if "while its change is live" not in resident:
|
|
3735
|
+
report(
|
|
3736
|
+
f"{label} the resident protocol describes recompilation without "
|
|
3737
|
+
"stating that it holds while the change is live."
|
|
3738
|
+
)
|
|
3739
|
+
return 1
|
|
3740
|
+
|
|
3741
|
+
report(f"{label} scenario passed.")
|
|
3742
|
+
return 0
|
|
3743
|
+
|
|
3744
|
+
|
|
3745
|
+
def validate_authoring_surface_owner_and_tags_scenario() -> int:
|
|
3746
|
+
label = "authoring-surface-owner-and-tags"
|
|
3747
|
+
|
|
3748
|
+
# Both rules this change adds widen what a gate accepts. An author only
|
|
3749
|
+
# benefits if the shipped surface says so, so the template, the artifact
|
|
3750
|
+
# instruction the CLI hands back, and the resident protocol each state them.
|
|
3751
|
+
template = (
|
|
3752
|
+
ROOT / "openspec/schemas/keel-spec-driven/templates/tasks.md"
|
|
3753
|
+
).read_text(encoding="utf-8")
|
|
3754
|
+
resident = (ROOT / "AGENTS.md").read_text(encoding="utf-8")
|
|
3755
|
+
|
|
3756
|
+
# Read the instruction the way an author receives it — through the CLI in a
|
|
3757
|
+
# repository Keel installed — rather than from the schema file it is
|
|
3758
|
+
# composed from, so a change that never reaches the author is a failure.
|
|
3759
|
+
with tempfile.TemporaryDirectory(prefix="keel-authoring-surface-") as raw_tmp:
|
|
3760
|
+
repo = Path(raw_tmp) / "repo"
|
|
3761
|
+
repo.mkdir()
|
|
3762
|
+
install = run_keel(repo, "--install")
|
|
3763
|
+
if install.returncode != 0:
|
|
3764
|
+
report(f"{label} keel --install failed.")
|
|
3765
|
+
report((install.stderr or install.stdout).strip())
|
|
3766
|
+
return 1
|
|
3767
|
+
created = run_openspec(repo, "new", "change", "surface-probe")
|
|
3768
|
+
if created is None:
|
|
3769
|
+
report(f"{label} skipped: the openspec CLI is not on PATH.")
|
|
3770
|
+
return 3
|
|
3771
|
+
if created.returncode != 0:
|
|
3772
|
+
report(f"{label} could not scaffold a change to read the instruction.")
|
|
3773
|
+
report((created.stderr or created.stdout).strip())
|
|
3774
|
+
return 1
|
|
3775
|
+
instructions = run_openspec(
|
|
3776
|
+
repo, "instructions", "tasks", "--change", "surface-probe"
|
|
3777
|
+
)
|
|
3778
|
+
if instructions is None or instructions.returncode != 0:
|
|
3779
|
+
report(f"{label} could not read the tasks artifact instruction.")
|
|
3780
|
+
if instructions is not None:
|
|
3781
|
+
report((instructions.stderr or instructions.stdout).strip())
|
|
3782
|
+
return 1
|
|
3783
|
+
instruction = instructions.stdout
|
|
3784
|
+
|
|
3785
|
+
for surface, text in (("tasks template", template), ("tasks instruction", instruction)):
|
|
3786
|
+
for needle in (
|
|
3787
|
+
"regression",
|
|
3788
|
+
"at least one check untagged",
|
|
3789
|
+
):
|
|
3790
|
+
if needle not in text:
|
|
3791
|
+
report(f"{label} {surface} does not describe the tag: {needle}")
|
|
3792
|
+
return 1
|
|
3793
|
+
# D6 — the wording trap: red and green accompany the bare label.
|
|
3794
|
+
if "in addition to" not in text.lower():
|
|
3795
|
+
report(
|
|
3796
|
+
f"{label} {surface} still reads as though `.red`/`.green` "
|
|
3797
|
+
"replace the bare M<n> Evidence rather than accompanying it."
|
|
3798
|
+
)
|
|
3799
|
+
return 1
|
|
3800
|
+
if "repo-relative path that exists" not in text:
|
|
3801
|
+
report(
|
|
3802
|
+
f"{label} {surface} does not state the existing-path owner form."
|
|
3803
|
+
)
|
|
3804
|
+
return 1
|
|
3805
|
+
if "HANDOFF" not in text:
|
|
3806
|
+
report(f"{label} {surface} does not state that HANDOFF is refused.")
|
|
3807
|
+
return 1
|
|
3808
|
+
|
|
3809
|
+
for needle in (
|
|
3810
|
+
"regression-only-strategy",
|
|
3811
|
+
"in addition to the bare",
|
|
3812
|
+
"any repo-relative path that exists",
|
|
3813
|
+
):
|
|
3814
|
+
if needle not in resident:
|
|
3815
|
+
report(f"{label} resident protocol does not state: {needle}")
|
|
3816
|
+
return 1
|
|
3817
|
+
|
|
3818
|
+
for local, packaged in SCHEMA_COPY_PAIRS:
|
|
3819
|
+
if (ROOT / local).read_text(encoding="utf-8") != (
|
|
3820
|
+
ROOT / packaged
|
|
3821
|
+
).read_text(encoding="utf-8"):
|
|
3822
|
+
report(f"{label} schema copies diverge: {local} vs {packaged}")
|
|
3823
|
+
return 1
|
|
3824
|
+
|
|
3825
|
+
report(f"{label} scenario passed.")
|
|
3826
|
+
return 0
|
|
3827
|
+
|
|
3828
|
+
|
|
3829
|
+
def validate_durable_owner_vocabulary_scenario() -> int:
|
|
3830
|
+
label = "durable-owner-vocabulary"
|
|
3831
|
+
|
|
3832
|
+
# The accepted owner forms are shape checks: a gate cannot resolve a URL or
|
|
3833
|
+
# confirm an archive path is the right one. A repo-relative path is the one
|
|
3834
|
+
# form it can actually check, so refusing it drew the line in the least
|
|
3835
|
+
# defensible place.
|
|
3836
|
+
with tempfile.TemporaryDirectory(prefix="keel-owner-vocab-") as raw_tmp:
|
|
3837
|
+
repo = Path(raw_tmp) / "repo"
|
|
3838
|
+
repo.mkdir()
|
|
3839
|
+
tasks_path = repo / "openspec/changes/demo/tasks.md"
|
|
3840
|
+
write_text(repo / "openspec/FOLLOWUP.md", "# Follow-ups\n")
|
|
3841
|
+
write_text(repo / "keel/HANDOFF.md", "pointer\n")
|
|
3842
|
+
write_text(repo / "keel/archive/notes/2026-07-28-example.md", "note\n")
|
|
3843
|
+
|
|
3844
|
+
def invalidation_start(closure: str) -> dict:
|
|
3845
|
+
write_text(
|
|
3846
|
+
tasks_path,
|
|
3847
|
+
task_contract_fixture().replace(
|
|
3848
|
+
"## Invalidates\n\n- None.\n\n",
|
|
3849
|
+
'## Invalidates\n\n- I1: "the wording that is now wrong" '
|
|
3850
|
+
f"— somewhere in the repo. {closure}\n\n",
|
|
3851
|
+
),
|
|
3852
|
+
)
|
|
3853
|
+
result = run_keel(
|
|
3854
|
+
repo, "gate", "task-start", "--change", "demo", "--task", "1.1", "--json"
|
|
3855
|
+
)
|
|
3856
|
+
return json.loads(result.stdout)
|
|
3857
|
+
|
|
3858
|
+
def completion(findings: str) -> dict:
|
|
3859
|
+
fixture = (
|
|
3860
|
+
task_contract_fixture(evidence=("M1: check exercised.",))
|
|
3861
|
+
.replace("- [ ] 1.1", "- [x] 1.1")
|
|
3862
|
+
.replace(" - Status: pending\n", " - Status: pass\n")
|
|
3863
|
+
.replace(
|
|
3864
|
+
" - Acceptance check: pending\n",
|
|
3865
|
+
" - Acceptance check: behavior proven through the public CLI.\n",
|
|
3866
|
+
)
|
|
3867
|
+
.replace(
|
|
3868
|
+
" - Scope check: pending\n",
|
|
3869
|
+
" - Scope check: writes stayed inside Touch.\n",
|
|
3870
|
+
)
|
|
3871
|
+
.replace(
|
|
3872
|
+
" - Findings: pending\n", f" - Findings: {findings}\n"
|
|
3873
|
+
)
|
|
3874
|
+
)
|
|
3875
|
+
write_text(tasks_path, fixture)
|
|
3876
|
+
result = run_keel(
|
|
3877
|
+
repo, "gate", "task-complete", "--change", "demo", "--task", "1.1", "--json"
|
|
3878
|
+
)
|
|
3879
|
+
return json.loads(result.stdout)
|
|
3880
|
+
|
|
3881
|
+
def close(closure: str) -> dict:
|
|
3882
|
+
write_text(
|
|
3883
|
+
tasks_path,
|
|
3884
|
+
task_contract_fixture(evidence=("M1: check exercised.",))
|
|
3885
|
+
.replace("- [ ] 1.1", "- [x] 1.1")
|
|
3886
|
+
.replace(" - Status: pending\n", " - Status: pass\n")
|
|
3887
|
+
.replace(
|
|
3888
|
+
" - Acceptance check: pending\n",
|
|
3889
|
+
" - Acceptance check: proven.\n",
|
|
3890
|
+
)
|
|
3891
|
+
.replace(" - Scope check: pending\n", " - Scope check: inside Touch.\n")
|
|
3892
|
+
.replace(" - Findings: pending\n", " - Findings: none\n")
|
|
3893
|
+
+ f"\n## Expectation Coverage\n\n- E1: the expectation. {closure}\n",
|
|
3894
|
+
)
|
|
3895
|
+
write_text(repo / "openspec/changes/demo/proposal.md", "# Proposal\n")
|
|
3896
|
+
write_text(repo / "openspec/changes/demo/design.md", "## Context\n\nfixture\n")
|
|
3897
|
+
write_text(
|
|
3898
|
+
repo / "openspec/changes/demo/specs/demo/spec.md",
|
|
3899
|
+
"## ADDED Requirements\n",
|
|
3900
|
+
)
|
|
3901
|
+
result = run_keel(
|
|
3902
|
+
repo, "gate", "change-close", "--change", "demo", "--action", "sync", "--json"
|
|
3903
|
+
)
|
|
3904
|
+
return json.loads(result.stdout)
|
|
3905
|
+
|
|
3906
|
+
# M1 — an existing repo path closes an entry in all three shared places.
|
|
3907
|
+
ledger = "Durable owner: openspec/FOLLOWUP.md"
|
|
3908
|
+
payload = invalidation_start(ledger)
|
|
3909
|
+
if payload.get("status") != "pass":
|
|
3910
|
+
report(f"{label} refused a repo ledger as an invalidation owner.")
|
|
3911
|
+
report(json.dumps(payload.get("problems", []), indent=2))
|
|
3912
|
+
return 1
|
|
3913
|
+
|
|
3914
|
+
payload = completion(f"the IDE shell contract gap. {ledger}")
|
|
3915
|
+
if payload.get("status") != "pass":
|
|
3916
|
+
report(f"{label} refused a repo ledger as a Findings owner.")
|
|
3917
|
+
report(json.dumps(payload.get("problems", []), indent=2))
|
|
3918
|
+
return 1
|
|
3919
|
+
|
|
3920
|
+
payload = close(ledger)
|
|
3921
|
+
if any(
|
|
3922
|
+
item.get("code") == "expectation-closure"
|
|
3923
|
+
for item in payload.get("problems", [])
|
|
3924
|
+
):
|
|
3925
|
+
report(f"{label} refused a repo ledger as an Expectation Coverage owner.")
|
|
3926
|
+
report(json.dumps(payload.get("problems", []), indent=2))
|
|
3927
|
+
return 1
|
|
3928
|
+
|
|
3929
|
+
# And a path with no file behind it is refused, distinguishably.
|
|
3930
|
+
payload = invalidation_start("Durable owner: openspec/NOT-THERE.md")
|
|
3931
|
+
codes = {item.get("code") for item in payload.get("problems", [])}
|
|
3932
|
+
messages = " ".join(
|
|
3933
|
+
item.get("message", "") for item in payload.get("problems", [])
|
|
3934
|
+
)
|
|
3935
|
+
if (
|
|
3936
|
+
payload.get("status") != "fail"
|
|
3937
|
+
or "invalidation-owner-missing" not in codes
|
|
3938
|
+
or "openspec/NOT-THERE.md" not in messages
|
|
3939
|
+
):
|
|
3940
|
+
report(f"{label} accepted a durable owner with no file behind it.")
|
|
3941
|
+
report(json.dumps(payload.get("problems", []), indent=2))
|
|
3942
|
+
return 1
|
|
3943
|
+
|
|
3944
|
+
# M2 — the pointer override is still not an owner, although it exists.
|
|
3945
|
+
payload = invalidation_start("Durable owner: keel/HANDOFF.md")
|
|
3946
|
+
messages = " ".join(
|
|
3947
|
+
item.get("message", "") for item in payload.get("problems", [])
|
|
3948
|
+
)
|
|
3949
|
+
if payload.get("status") != "fail" or "HANDOFF" not in messages:
|
|
3950
|
+
report(f"{label} accepted keel/HANDOFF.md as a durable owner.")
|
|
3951
|
+
report(json.dumps(payload.get("problems", []), indent=2))
|
|
3952
|
+
return 1
|
|
3953
|
+
|
|
3954
|
+
# A refusal names the forms it accepts, including the new one.
|
|
3955
|
+
payload = invalidation_start("no closure at all")
|
|
3956
|
+
messages = " ".join(
|
|
3957
|
+
item.get("message", "") for item in payload.get("problems", [])
|
|
3958
|
+
)
|
|
3959
|
+
for expected in ("Durable owner:", "repo-relative path that exists", "Discard reason:"):
|
|
3960
|
+
if expected not in messages:
|
|
3961
|
+
report(f"{label} refusal does not name the accepted form: {expected}")
|
|
3962
|
+
report(messages)
|
|
3963
|
+
return 1
|
|
3964
|
+
|
|
3965
|
+
# M3 — every previously accepted form still closes.
|
|
3966
|
+
for closure in (
|
|
3967
|
+
"Durable owner: openspec/changes/demo/proposal.md",
|
|
3968
|
+
"Durable owner: keel/archive/notes/2026-07-28-example.md",
|
|
3969
|
+
"Durable owner: https://github.com/TanglmChris/keel/issues/20",
|
|
3970
|
+
"Discard reason: it stands as written.",
|
|
3971
|
+
):
|
|
3972
|
+
write_text(repo / "openspec/changes/demo/proposal.md", "# Proposal\n")
|
|
3973
|
+
payload = invalidation_start(closure)
|
|
3974
|
+
if payload.get("status") != "pass":
|
|
3975
|
+
report(f"{label} dropped a previously accepted form: {closure}")
|
|
3976
|
+
report(json.dumps(payload.get("problems", []), indent=2))
|
|
3977
|
+
return 1
|
|
3978
|
+
|
|
3979
|
+
report(f"{label} scenario passed.")
|
|
3980
|
+
return 0
|
|
3981
|
+
|
|
3982
|
+
|
|
3983
|
+
def regression_tag_fixture(
|
|
3984
|
+
commands: tuple[str, ...],
|
|
3985
|
+
evidence: tuple[str, ...],
|
|
3986
|
+
*,
|
|
3987
|
+
strategy: str = "vertical-tdd",
|
|
3988
|
+
) -> str:
|
|
3989
|
+
return (
|
|
3990
|
+
task_contract_fixture(commands=commands, evidence=evidence)
|
|
3991
|
+
.replace(
|
|
3992
|
+
" - Commands:\n",
|
|
3993
|
+
f" - Verification Strategy: {strategy}\n - Commands:\n",
|
|
3994
|
+
)
|
|
3995
|
+
.replace(" - Status: pending\n", " - Status: pass\n")
|
|
3996
|
+
.replace(
|
|
3997
|
+
" - Acceptance check: pending\n",
|
|
3998
|
+
" - Acceptance check: behavior proven through the public CLI.\n",
|
|
3999
|
+
)
|
|
4000
|
+
.replace(
|
|
4001
|
+
" - Scope check: pending\n",
|
|
4002
|
+
" - Scope check: writes stayed inside Touch.\n",
|
|
4003
|
+
)
|
|
4004
|
+
.replace(" - Findings: pending\n", " - Findings: none\n")
|
|
4005
|
+
)
|
|
4006
|
+
|
|
4007
|
+
|
|
4008
|
+
def validate_regression_check_tag_scenario() -> int:
|
|
4009
|
+
label = "regression-check-tag"
|
|
4010
|
+
|
|
4011
|
+
# A regression check asserts that something already green is still green, so
|
|
4012
|
+
# it has no honest red. Requiring one leaves an author fabricating evidence
|
|
4013
|
+
# or folding the guard into the behavior check; the tag is the third option.
|
|
4014
|
+
with tempfile.TemporaryDirectory(prefix="keel-regression-tag-") as raw_tmp:
|
|
4015
|
+
repo = Path(raw_tmp) / "repo"
|
|
4016
|
+
repo.mkdir()
|
|
4017
|
+
tasks_path = repo / "openspec/changes/demo/tasks.md"
|
|
4018
|
+
|
|
4019
|
+
def complete(fixture: str) -> dict:
|
|
4020
|
+
write_text(tasks_path, fixture)
|
|
4021
|
+
result = run_keel(
|
|
4022
|
+
repo, "gate", "task-complete", "--change", "demo", "--task", "1.1", "--json"
|
|
4023
|
+
)
|
|
4024
|
+
return json.loads(result.stdout)
|
|
4025
|
+
|
|
4026
|
+
def start(fixture: str) -> dict:
|
|
4027
|
+
write_text(tasks_path, fixture)
|
|
4028
|
+
result = run_keel(
|
|
4029
|
+
repo, "gate", "task-start", "--change", "demo", "--task", "1.1", "--json"
|
|
4030
|
+
)
|
|
4031
|
+
return json.loads(result.stdout)
|
|
4032
|
+
|
|
4033
|
+
mixed_commands = (
|
|
4034
|
+
"M1: behavior reaches the public interface",
|
|
4035
|
+
"M2 (regression): the existing suite stays green",
|
|
4036
|
+
)
|
|
4037
|
+
|
|
4038
|
+
# M1 — a tagged check completes without red/green, and the untagged one
|
|
4039
|
+
# still needs both.
|
|
4040
|
+
payload = complete(
|
|
4041
|
+
regression_tag_fixture(
|
|
4042
|
+
mixed_commands,
|
|
4043
|
+
(
|
|
4044
|
+
"M1: behavior exercised.",
|
|
4045
|
+
"M1.red: failed before the implementation.",
|
|
4046
|
+
"M1.green: passed after.",
|
|
4047
|
+
"M2: existing suite still green.",
|
|
4048
|
+
),
|
|
4049
|
+
)
|
|
4050
|
+
)
|
|
4051
|
+
if payload.get("status") != "pass":
|
|
4052
|
+
report(f"{label} refused a tagged regression check that needs no red.")
|
|
4053
|
+
report(json.dumps(payload.get("problems", []), indent=2))
|
|
4054
|
+
return 1
|
|
4055
|
+
|
|
4056
|
+
# D5 — the exemption is from red-green, not from evidence.
|
|
4057
|
+
payload = complete(
|
|
4058
|
+
regression_tag_fixture(
|
|
4059
|
+
mixed_commands,
|
|
4060
|
+
(
|
|
4061
|
+
"M1: behavior exercised.",
|
|
4062
|
+
"M1.red: failed before the implementation.",
|
|
4063
|
+
"M1.green: passed after.",
|
|
4064
|
+
"M2: pending",
|
|
4065
|
+
),
|
|
4066
|
+
)
|
|
4067
|
+
)
|
|
4068
|
+
if payload.get("status") != "fail" or not any(
|
|
4069
|
+
"M2" in item.get("message", "")
|
|
4070
|
+
for item in payload.get("problems", [])
|
|
4071
|
+
):
|
|
4072
|
+
report(f"{label} completed a tagged check with no evidence at all.")
|
|
4073
|
+
report(json.dumps(payload, indent=2))
|
|
4074
|
+
return 1
|
|
4075
|
+
|
|
4076
|
+
# M2 — the strategy cannot be emptied out by tagging every check.
|
|
4077
|
+
payload = start(
|
|
4078
|
+
regression_tag_fixture(
|
|
4079
|
+
(
|
|
4080
|
+
"M1 (regression): the existing suite stays green",
|
|
4081
|
+
"M2 (regression): the golden files stay byte-identical",
|
|
4082
|
+
),
|
|
4083
|
+
("M1: pending", "M2: pending"),
|
|
4084
|
+
)
|
|
4085
|
+
)
|
|
4086
|
+
codes = {item.get("code") for item in payload.get("problems", [])}
|
|
4087
|
+
if payload.get("status") != "fail" or "regression-only-strategy" not in codes:
|
|
4088
|
+
report(
|
|
4089
|
+
f"{label} accepted a red-green strategy whose every check is tagged."
|
|
4090
|
+
)
|
|
4091
|
+
report(json.dumps(payload, indent=2))
|
|
4092
|
+
return 1
|
|
4093
|
+
|
|
4094
|
+
# M3 — an untagged check emits no tag key, so its capsule and fingerprint
|
|
4095
|
+
# are byte-identical to what they were before the tag existed.
|
|
4096
|
+
payload = start(
|
|
4097
|
+
regression_tag_fixture(
|
|
4098
|
+
("M1: behavior reaches the public interface",),
|
|
4099
|
+
("M1: pending",),
|
|
4100
|
+
)
|
|
4101
|
+
)
|
|
4102
|
+
entries = (
|
|
4103
|
+
payload.get("contract", {})
|
|
4104
|
+
.get("capsule", {})
|
|
4105
|
+
.get("verification", {})
|
|
4106
|
+
.get("commands", [])
|
|
4107
|
+
)
|
|
4108
|
+
if payload.get("status") != "pass" or [sorted(entry) for entry in entries] != [
|
|
4109
|
+
["check", "label"]
|
|
4110
|
+
]:
|
|
4111
|
+
report(
|
|
4112
|
+
f"{label} changed the compiled shape of an untagged check, which "
|
|
4113
|
+
"moves every recorded contract fingerprint."
|
|
4114
|
+
)
|
|
4115
|
+
report(json.dumps(entries, indent=2))
|
|
4116
|
+
return 1
|
|
4117
|
+
|
|
4118
|
+
# And a tagged check does declare itself in the capsule, so the exemption
|
|
4119
|
+
# is a visible term of the contract rather than a silent skip.
|
|
4120
|
+
payload = start(
|
|
4121
|
+
regression_tag_fixture(mixed_commands, ("M1: pending", "M2: pending"))
|
|
4122
|
+
)
|
|
4123
|
+
tagged = next(
|
|
4124
|
+
(
|
|
4125
|
+
entry
|
|
4126
|
+
for entry in payload.get("contract", {})
|
|
4127
|
+
.get("capsule", {})
|
|
4128
|
+
.get("verification", {})
|
|
4129
|
+
.get("commands", [])
|
|
4130
|
+
if entry.get("label") == "M2"
|
|
4131
|
+
),
|
|
4132
|
+
None,
|
|
4133
|
+
)
|
|
4134
|
+
if not tagged or tagged.get("regression") is not True:
|
|
4135
|
+
report(f"{label} did not record the regression tag in the capsule.")
|
|
4136
|
+
report(json.dumps(payload.get("contract", {}), indent=2))
|
|
4137
|
+
return 1
|
|
4138
|
+
|
|
4139
|
+
report(f"{label} scenario passed.")
|
|
4140
|
+
return 0
|
|
4141
|
+
|
|
4142
|
+
|
|
4143
|
+
def validate_packaged_schema_derivation_scenario() -> int:
|
|
4144
|
+
label = "packaged-schema-derivation"
|
|
4145
|
+
|
|
4146
|
+
# The helper derives the consumer-repo paths every install/uninstall/clear
|
|
4147
|
+
# assertion iterates. When its root stopped existing it returned an empty
|
|
4148
|
+
# list, so those loops compared nothing and reported success. Anchor it to
|
|
4149
|
+
# what the installer really writes, and make emptiness a failure here.
|
|
4150
|
+
try:
|
|
4151
|
+
packaged_openspec_schema_install_paths(ROOT / "no-such-packaged-root")
|
|
4152
|
+
except FileNotFoundError as error:
|
|
4153
|
+
if "no-such-packaged-root" not in str(error):
|
|
4154
|
+
report(f"{label} missing-root failure does not name the path it expected.")
|
|
4155
|
+
report(str(error))
|
|
4156
|
+
return 1
|
|
4157
|
+
else:
|
|
4158
|
+
report(
|
|
4159
|
+
f"{label} returned a set for a missing packaged root instead of failing; "
|
|
4160
|
+
"an absent root must not silently empty its callers' assertions."
|
|
4161
|
+
)
|
|
4162
|
+
return 1
|
|
4163
|
+
|
|
4164
|
+
derived = packaged_openspec_schema_install_paths()
|
|
4165
|
+
if not derived:
|
|
4166
|
+
report(
|
|
4167
|
+
f"{label} derived no packaged schema paths, so every assertion that "
|
|
4168
|
+
"iterates them verifies nothing."
|
|
4169
|
+
)
|
|
4170
|
+
return 1
|
|
4171
|
+
|
|
4172
|
+
with tempfile.TemporaryDirectory(prefix="keel-packaged-schema-") as raw_tmp:
|
|
4173
|
+
repo = Path(raw_tmp) / "repo"
|
|
4174
|
+
repo.mkdir()
|
|
4175
|
+
install = run_keel(repo, "--install")
|
|
4176
|
+
if install.returncode != 0:
|
|
4177
|
+
report(f"{label} keel --install failed.")
|
|
4178
|
+
report((install.stderr or install.stdout).strip())
|
|
4179
|
+
return 1
|
|
4180
|
+
|
|
4181
|
+
schema_root = repo / OPENSPEC_SCHEMA_ROOT
|
|
4182
|
+
installed = sorted(
|
|
4183
|
+
path.relative_to(repo).as_posix()
|
|
4184
|
+
for path in schema_root.rglob("*")
|
|
4185
|
+
if path.is_file()
|
|
4186
|
+
)
|
|
4187
|
+
if installed != sorted(derived):
|
|
4188
|
+
report(
|
|
4189
|
+
f"{label} derived paths do not match what keel --install wrote."
|
|
4190
|
+
)
|
|
4191
|
+
report(f"derived: {sorted(derived)}")
|
|
4192
|
+
report(f"installed: {installed}")
|
|
4193
|
+
return 1
|
|
4194
|
+
|
|
4195
|
+
report(f"{label} scenario passed.")
|
|
4196
|
+
return 0
|
|
4197
|
+
|
|
4198
|
+
|
|
4199
|
+
def validate_invalidation_authoring_surface_scenario() -> int:
|
|
4200
|
+
label = "invalidation-authoring-surface"
|
|
4201
|
+
|
|
4202
|
+
# The two schema copies are the repo-local one OpenSpec resolves and the
|
|
4203
|
+
# packaged one `keel --init` writes. This is the only check that asserts they
|
|
4204
|
+
# agree: compact-task-authoring used to imply it through a projection loop
|
|
4205
|
+
# rooted at trees that no longer exist, so it compared nothing and has since
|
|
4206
|
+
# been removed.
|
|
4207
|
+
for local, packaged in SCHEMA_COPY_PAIRS:
|
|
4208
|
+
local_text = (ROOT / local).read_text(encoding="utf-8")
|
|
4209
|
+
packaged_text = (ROOT / packaged).read_text(encoding="utf-8")
|
|
4210
|
+
if local_text != packaged_text:
|
|
4211
|
+
report(f"{label} schema copies diverge: {local} vs {packaged}")
|
|
4212
|
+
return 1
|
|
4213
|
+
|
|
4214
|
+
template = (
|
|
4215
|
+
ROOT / "openspec/schemas/keel-spec-driven/templates/tasks.md"
|
|
4216
|
+
).read_text(encoding="utf-8")
|
|
4217
|
+
for marker in ("## Invalidates", "- None.", "- I1:"):
|
|
4218
|
+
if marker not in template:
|
|
4219
|
+
report(f"{label} tasks template lacks the invalidation section: {marker}")
|
|
4220
|
+
return 1
|
|
4221
|
+
|
|
4222
|
+
schema = (
|
|
4223
|
+
ROOT / "openspec/schemas/keel-spec-driven/schema.yaml"
|
|
4224
|
+
).read_text(encoding="utf-8")
|
|
4225
|
+
for marker in ("## Invalidates", "Updated by:", "Discard reason:"):
|
|
4226
|
+
if marker not in schema:
|
|
4227
|
+
report(
|
|
4228
|
+
f"{label} authoring instruction does not describe the "
|
|
4229
|
+
f"invalidation section: {marker}"
|
|
4230
|
+
)
|
|
4231
|
+
return 1
|
|
4232
|
+
|
|
4233
|
+
resident = resident_session_start_section(ROOT / "AGENTS.md")
|
|
4234
|
+
agents = (ROOT / "AGENTS.md").read_text(encoding="utf-8")
|
|
4235
|
+
if resident is None:
|
|
4236
|
+
report(f"{label} resident AGENTS.md has no Session Start section.")
|
|
4237
|
+
return 1
|
|
4238
|
+
for marker in ("## Invalidates", "## Expectation Coverage"):
|
|
4239
|
+
if marker not in agents:
|
|
4240
|
+
report(f"{label} resident protocol does not name {marker}.")
|
|
4241
|
+
return 1
|
|
4242
|
+
|
|
4243
|
+
# An author who scaffolds and fills in the tasks must not additionally have
|
|
4244
|
+
# to discover this section, so the template's own answer has to satisfy the
|
|
4245
|
+
# gate. Placeholders are filled generically; the assertion is narrow on
|
|
4246
|
+
# purpose — no invalidation problem may survive.
|
|
4247
|
+
filled = re.sub(r"<!--[\s\S]*?-->", "", template)
|
|
4248
|
+
filled = filled.replace("<strategy>", "evidence-first")
|
|
4249
|
+
filled = re.sub(r"<[^<>\n]+>", "concrete authored value", filled)
|
|
4250
|
+
with tempfile.TemporaryDirectory(
|
|
4251
|
+
prefix="keel-invalidation-surface-", ignore_cleanup_errors=True
|
|
4252
|
+
) as raw:
|
|
4253
|
+
repo = Path(raw) / "scaffold"
|
|
4254
|
+
write_text(repo / "openspec/changes/demo/tasks.md", filled)
|
|
4255
|
+
started = run_keel(
|
|
4256
|
+
repo, "gate", "task-start", ".",
|
|
4257
|
+
"--change", "demo", "--task", "1.1", "--json", "--no-guard",
|
|
4258
|
+
)
|
|
4259
|
+
payload = json.loads(started.stdout) if started.stdout.strip() else {}
|
|
4260
|
+
offenders = [
|
|
4261
|
+
item for item in payload.get("problems", [])
|
|
4262
|
+
if str(item.get("code", "")).startswith("invalidation-")
|
|
4263
|
+
]
|
|
4264
|
+
if offenders:
|
|
4265
|
+
report(
|
|
4266
|
+
f"{label} a filled-in scaffold still fails the invalidation "
|
|
4267
|
+
"gate, so the template's own answer is not usable."
|
|
4268
|
+
)
|
|
4269
|
+
report(repr(offenders))
|
|
4270
|
+
return 1
|
|
4271
|
+
|
|
4272
|
+
report(f"{label} scenario passed.")
|
|
4273
|
+
return 0
|
|
4274
|
+
|
|
4275
|
+
|
|
3479
4276
|
def validate_task_contract_core_scenario() -> int:
|
|
3480
4277
|
with tempfile.TemporaryDirectory(prefix="keel-task-contract-") as raw_tmp:
|
|
3481
4278
|
repo = Path(raw_tmp)
|
|
@@ -3720,7 +4517,7 @@ def task_capsule_expanded_fixture() -> str:
|
|
|
3720
4517
|
|
|
3721
4518
|
def task_capsule_compact_fixture() -> str:
|
|
3722
4519
|
return (
|
|
3723
|
-
"# Tasks\n\n"
|
|
4520
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
3724
4521
|
"- [ ] 1.1 Exercise task contract\n"
|
|
3725
4522
|
" - Covers:\n"
|
|
3726
4523
|
" - E1: Public behavior passes.\n"
|
|
@@ -4263,7 +5060,7 @@ TRACKER_OWNER = "https://github.com/TanglmChris/keel/issues/12"
|
|
|
4263
5060
|
def tracker_owner_tasks(findings: str, closure: str) -> str:
|
|
4264
5061
|
"""One complete, checked task plus one Expectation Coverage closure line."""
|
|
4265
5062
|
return (
|
|
4266
|
-
"# Tasks\n\n"
|
|
5063
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
4267
5064
|
"## Expectation Coverage\n\n"
|
|
4268
5065
|
"- E1:\n"
|
|
4269
5066
|
f" - {closure}\n\n"
|
|
@@ -4369,6 +5166,9 @@ def validate_tracker_durable_owner_scenario() -> int:
|
|
|
4369
5166
|
report((handoff.stderr or handoff.stdout).strip())
|
|
4370
5167
|
return 1
|
|
4371
5168
|
|
|
5169
|
+
# The archive path must now exist to own anything: a note nobody wrote
|
|
5170
|
+
# owns nothing, and a path is the one owner form a gate can check.
|
|
5171
|
+
write_text(repo / "keel/archive/follow-ups/x.md", "follow-up note\n")
|
|
4372
5172
|
archived = complete("stale local note; owner keel/archive/follow-ups/x.md")
|
|
4373
5173
|
if archived.returncode != 0:
|
|
4374
5174
|
report(
|
|
@@ -5167,7 +5967,7 @@ def validate_task_capsule_scenario() -> int:
|
|
|
5167
5967
|
|
|
5168
5968
|
close_task = (
|
|
5169
5969
|
completion_task
|
|
5170
|
-
.replace("# Tasks\n\n", "# Tasks\n\n## Expectation Coverage\n\n"
|
|
5970
|
+
.replace("# Tasks\n\n## Invalidates\n\n- None.\n\n", "# Tasks\n\n## Invalidates\n\n- None.\n\n## Expectation Coverage\n\n"
|
|
5171
5971
|
"- E1:\n - Covered by: 1.1\n\n## 1. Work\n\n")
|
|
5172
5972
|
.replace("- [ ] 1.1", "- [x] 1.1")
|
|
5173
5973
|
)
|
|
@@ -5227,7 +6027,7 @@ def validate_core_gates_scenario() -> int:
|
|
|
5227
6027
|
tasks_path = repo / "openspec/changes/demo/tasks.md"
|
|
5228
6028
|
write_text(
|
|
5229
6029
|
tasks_path,
|
|
5230
|
-
"# Tasks\n\n"
|
|
6030
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
5231
6031
|
"- [ ] 1.1 Incomplete task\n"
|
|
5232
6032
|
" - Owner: keel-agent\n",
|
|
5233
6033
|
)
|
|
@@ -5262,7 +6062,7 @@ def validate_core_gates_scenario() -> int:
|
|
|
5262
6062
|
|
|
5263
6063
|
write_text(
|
|
5264
6064
|
tasks_path,
|
|
5265
|
-
"# Tasks\n\n"
|
|
6065
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
5266
6066
|
"- [ ] 1.1 Complete executable task\n"
|
|
5267
6067
|
" - Owner: keel-agent\n"
|
|
5268
6068
|
" - Mode: implementation\n"
|
|
@@ -5412,7 +6212,7 @@ def validate_core_gates_scenario() -> int:
|
|
|
5412
6212
|
findings: str = "none",
|
|
5413
6213
|
) -> str:
|
|
5414
6214
|
return (
|
|
5415
|
-
"# Tasks\n\n"
|
|
6215
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
5416
6216
|
"- [ ] 1.1 Complete behavior\n"
|
|
5417
6217
|
" - Owner: keel-agent\n"
|
|
5418
6218
|
" - Mode: implementation\n"
|
|
@@ -5531,7 +6331,7 @@ def validate_core_gates_scenario() -> int:
|
|
|
5531
6331
|
)
|
|
5532
6332
|
write_text(
|
|
5533
6333
|
completion_repo / "openspec/changes/follow-up/tasks.md",
|
|
5534
|
-
"# Tasks\n\n- [ ] 1.1 Own the finding\n",
|
|
6334
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n- [ ] 1.1 Own the finding\n",
|
|
5535
6335
|
)
|
|
5536
6336
|
owned_finding = run_keel(
|
|
5537
6337
|
completion_repo,
|
|
@@ -5849,7 +6649,7 @@ def validate_core_gates_scenario() -> int:
|
|
|
5849
6649
|
if extra_touch:
|
|
5850
6650
|
touch += " - src/extra.js\n"
|
|
5851
6651
|
return (
|
|
5852
|
-
"# Tasks\n\n"
|
|
6652
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
5853
6653
|
"- [ ] 1.1 Record behavior\n"
|
|
5854
6654
|
" - Owner: keel-agent\n"
|
|
5855
6655
|
" - Mode: implementation\n"
|
|
@@ -6067,7 +6867,7 @@ def validate_core_gates_scenario() -> int:
|
|
|
6067
6867
|
def close_task(checked: bool, review_status: str = "pass") -> str:
|
|
6068
6868
|
mark = "x" if checked else " "
|
|
6069
6869
|
return (
|
|
6070
|
-
"# Tasks\n\n"
|
|
6870
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
6071
6871
|
"## Expectation Coverage\n\n"
|
|
6072
6872
|
"- E1:\n"
|
|
6073
6873
|
" - Covered by: 1.1\n\n"
|
|
@@ -6226,7 +7026,7 @@ def validate_core_gates_scenario() -> int:
|
|
|
6226
7026
|
|
|
6227
7027
|
def validate_scope_rename_attribution_scenario() -> int:
|
|
6228
7028
|
rename_task = (
|
|
6229
|
-
"# Tasks\n\n"
|
|
7029
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
6230
7030
|
"- [ ] 1.1 Complete behavior\n"
|
|
6231
7031
|
" - Owner: keel-agent\n"
|
|
6232
7032
|
" - Mode: implementation\n"
|
|
@@ -6429,7 +7229,7 @@ def validate_target_capability_adapters_scenario() -> int:
|
|
|
6429
7229
|
|
|
6430
7230
|
write_text(
|
|
6431
7231
|
repo / "openspec/changes/demo/tasks.md",
|
|
6432
|
-
"# Tasks\n\n- [ ] 1.1 Incomplete\n - Owner: keel-agent\n",
|
|
7232
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n- [ ] 1.1 Incomplete\n - Owner: keel-agent\n",
|
|
6433
7233
|
)
|
|
6434
7234
|
gate = run_keel(
|
|
6435
7235
|
repo,
|
|
@@ -6855,6 +7655,31 @@ def session_start_context(result: subprocess.CompletedProcess[str]) -> str | Non
|
|
|
6855
7655
|
return output.get("additionalContext")
|
|
6856
7656
|
|
|
6857
7657
|
|
|
7658
|
+
# The projection is delivered through additionalContext, which the host injects
|
|
7659
|
+
# into the agent and never renders for the human. Every branch must therefore
|
|
7660
|
+
# carry the instruction to relay it, including — especially — the degraded ones,
|
|
7661
|
+
# because a projection nobody sees is a projection nobody checks.
|
|
7662
|
+
SESSION_START_DISCLOSURE = "to the user in your first reply"
|
|
7663
|
+
|
|
7664
|
+
# A host loads its plugins once per session, so the projection can be absent for
|
|
7665
|
+
# reasons no repository check can see. The resident protocol is the carrier of
|
|
7666
|
+
# last resort and must state the same obligation without trading away the
|
|
7667
|
+
# continuity rules it already carried.
|
|
7668
|
+
RESIDENT_SESSION_START_REQUIRED = (
|
|
7669
|
+
SESSION_START_DISCLOSURE,
|
|
7670
|
+
"keel context",
|
|
7671
|
+
"never infer continuity from native memory",
|
|
7672
|
+
)
|
|
7673
|
+
|
|
7674
|
+
|
|
7675
|
+
def resident_session_start_section(path: Path) -> str | None:
|
|
7676
|
+
text = path.read_text(encoding="utf-8")
|
|
7677
|
+
match = re.search(
|
|
7678
|
+
r"^## Session Start$(.*?)^## ", text, re.MULTILINE | re.DOTALL
|
|
7679
|
+
)
|
|
7680
|
+
return match.group(1) if match else None
|
|
7681
|
+
|
|
7682
|
+
|
|
6858
7683
|
def validate_native_plugin_session_start_scenario() -> int:
|
|
6859
7684
|
real_cli = f'node "{ROOT / "bin/keel.js"}"'
|
|
6860
7685
|
codex_event = {"hook_event_name": "SessionStart", "source": "startup"}
|
|
@@ -6902,6 +7727,7 @@ def validate_native_plugin_session_start_scenario() -> int:
|
|
|
6902
7727
|
or "demo#1.1" not in codex_context
|
|
6903
7728
|
or "task-start" not in codex_context
|
|
6904
7729
|
or "disposable" not in codex_context
|
|
7730
|
+
or SESSION_START_DISCLOSURE not in codex_context
|
|
6905
7731
|
):
|
|
6906
7732
|
report(
|
|
6907
7733
|
"native-plugin-session-start ready projection lacks concise "
|
|
@@ -6913,6 +7739,24 @@ def validate_native_plugin_session_start_scenario() -> int:
|
|
|
6913
7739
|
report("native-plugin-session-start projection overstepped.")
|
|
6914
7740
|
return 1
|
|
6915
7741
|
|
|
7742
|
+
idle_repo = tmp / "idle"
|
|
7743
|
+
(idle_repo / "openspec/changes").mkdir(parents=True)
|
|
7744
|
+
idle_result = run_session_start_hook(
|
|
7745
|
+
idle_repo, codex_event, keel_cli=real_cli
|
|
7746
|
+
)
|
|
7747
|
+
idle_context = session_start_context(idle_result)
|
|
7748
|
+
if (
|
|
7749
|
+
idle_result.returncode != 0
|
|
7750
|
+
or not idle_context
|
|
7751
|
+
or "idle" not in idle_context
|
|
7752
|
+
or SESSION_START_DISCLOSURE not in idle_context
|
|
7753
|
+
):
|
|
7754
|
+
report(
|
|
7755
|
+
"native-plugin-session-start idle projection did not disclose "
|
|
7756
|
+
"its status to the user: " + repr(idle_context)
|
|
7757
|
+
)
|
|
7758
|
+
return 1
|
|
7759
|
+
|
|
6916
7760
|
ambiguous_repo = tmp / "ambiguous"
|
|
6917
7761
|
ambiguous_repo.mkdir()
|
|
6918
7762
|
for change in ("alpha", "beta"):
|
|
@@ -6929,6 +7773,7 @@ def validate_native_plugin_session_start_scenario() -> int:
|
|
|
6929
7773
|
or not ambiguous_context
|
|
6930
7774
|
or "ambiguous" not in ambiguous_context
|
|
6931
7775
|
or "keel context" not in ambiguous_context
|
|
7776
|
+
or SESSION_START_DISCLOSURE not in ambiguous_context
|
|
6932
7777
|
or "alpha#1.1" in ambiguous_context
|
|
6933
7778
|
):
|
|
6934
7779
|
report(
|
|
@@ -6958,6 +7803,7 @@ def validate_native_plugin_session_start_scenario() -> int:
|
|
|
6958
7803
|
or not missing_context
|
|
6959
7804
|
or "missing or incompatible" not in missing_context
|
|
6960
7805
|
or "keel context" not in missing_context
|
|
7806
|
+
or SESSION_START_DISCLOSURE not in missing_context
|
|
6961
7807
|
):
|
|
6962
7808
|
report("native-plugin-session-start missing-CLI fallback failed.")
|
|
6963
7809
|
report(repr(missing_context))
|
|
@@ -6980,6 +7826,7 @@ def validate_native_plugin_session_start_scenario() -> int:
|
|
|
6980
7826
|
malformed_result.returncode != 0
|
|
6981
7827
|
or not malformed_context
|
|
6982
7828
|
or "malformed" not in malformed_context
|
|
7829
|
+
or SESSION_START_DISCLOSURE not in malformed_context
|
|
6983
7830
|
):
|
|
6984
7831
|
report("native-plugin-session-start malformed-output fallback failed.")
|
|
6985
7832
|
report(repr(malformed_context))
|
|
@@ -7005,6 +7852,7 @@ def validate_native_plugin_session_start_scenario() -> int:
|
|
|
7005
7852
|
hang_result.returncode != 0
|
|
7006
7853
|
or not hang_context
|
|
7007
7854
|
or "failed or timed out" not in hang_context
|
|
7855
|
+
or SESSION_START_DISCLOSURE not in hang_context
|
|
7008
7856
|
):
|
|
7009
7857
|
report("native-plugin-session-start timeout fallback failed.")
|
|
7010
7858
|
report(repr(hang_context))
|
|
@@ -7025,6 +7873,21 @@ def validate_native_plugin_session_start_scenario() -> int:
|
|
|
7025
7873
|
)
|
|
7026
7874
|
return 1
|
|
7027
7875
|
|
|
7876
|
+
resident = resident_session_start_section(ROOT / "AGENTS.md")
|
|
7877
|
+
if resident is None:
|
|
7878
|
+
report(
|
|
7879
|
+
"native-plugin-session-start resident AGENTS.md has no Session "
|
|
7880
|
+
"Start section."
|
|
7881
|
+
)
|
|
7882
|
+
return 1
|
|
7883
|
+
for needle in RESIDENT_SESSION_START_REQUIRED:
|
|
7884
|
+
if needle not in resident:
|
|
7885
|
+
report(
|
|
7886
|
+
"native-plugin-session-start resident Session Start section is "
|
|
7887
|
+
f"missing: {needle}"
|
|
7888
|
+
)
|
|
7889
|
+
return 1
|
|
7890
|
+
|
|
7028
7891
|
report("native-plugin-session-start scenario passed.")
|
|
7029
7892
|
return 0
|
|
7030
7893
|
|
|
@@ -7356,13 +8219,7 @@ def validate_expectation_alignment_real_tasks_scenario() -> int:
|
|
|
7356
8219
|
|
|
7357
8220
|
|
|
7358
8221
|
def validate_compact_task_authoring_scenario() -> int:
|
|
7359
|
-
source_root = (
|
|
7360
|
-
ROOT / "src" / "assets" / "shared" / "openspec" / "schemas" / OPENSPEC_SCHEMA_NAME
|
|
7361
|
-
)
|
|
7362
8222
|
local_root = ROOT / "openspec" / "schemas" / OPENSPEC_SCHEMA_NAME
|
|
7363
|
-
dist_root = (
|
|
7364
|
-
ROOT / "dist" / "shared" / "assets" / "openspec" / "schemas" / OPENSPEC_SCHEMA_NAME
|
|
7365
|
-
)
|
|
7366
8223
|
|
|
7367
8224
|
which = run_openspec(ROOT, "schema", "which", OPENSPEC_SCHEMA_NAME, "--json")
|
|
7368
8225
|
if which is None or which.returncode != 0:
|
|
@@ -7421,21 +8278,9 @@ def validate_compact_task_authoring_scenario() -> int:
|
|
|
7421
8278
|
)
|
|
7422
8279
|
return 1
|
|
7423
8280
|
|
|
7424
|
-
|
|
7425
|
-
|
|
7426
|
-
|
|
7427
|
-
continue
|
|
7428
|
-
relative = source_file.relative_to(source_root)
|
|
7429
|
-
projected = projection_root / relative
|
|
7430
|
-
if not projected.is_file() or (
|
|
7431
|
-
source_file.read_text(encoding="utf-8")
|
|
7432
|
-
!= projected.read_text(encoding="utf-8")
|
|
7433
|
-
):
|
|
7434
|
-
report(
|
|
7435
|
-
f"compact-task-authoring {label} projection diverges from "
|
|
7436
|
-
f"canonical source: {relative.as_posix()}"
|
|
7437
|
-
)
|
|
7438
|
-
return 1
|
|
8281
|
+
# The projection loop that stood here compared against `src/assets` and
|
|
8282
|
+
# `dist`, both retired, so it iterated nothing. `invalidation-authoring-surface`
|
|
8283
|
+
# asserts the two copies that do exist are byte-identical.
|
|
7439
8284
|
|
|
7440
8285
|
with tempfile.TemporaryDirectory(prefix="keel-compact-") as raw_tmp:
|
|
7441
8286
|
repo = Path(raw_tmp) / "fixture"
|
|
@@ -7713,7 +8558,7 @@ def validate_native_runtime_projection_scenario() -> int:
|
|
|
7713
8558
|
|
|
7714
8559
|
def projection_task(acceptance: str = "observable result") -> str:
|
|
7715
8560
|
return (
|
|
7716
|
-
"# Tasks\n\n"
|
|
8561
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
7717
8562
|
"- [ ] 1.1 Project selected behavior\n"
|
|
7718
8563
|
" - Owner: keel-agent\n"
|
|
7719
8564
|
" - Mode: implementation\n"
|
|
@@ -8307,7 +9152,7 @@ NATIVE_GOAL_VERSION = "keel-native-goal/v1"
|
|
|
8307
9152
|
|
|
8308
9153
|
|
|
8309
9154
|
def _goal_tasks_file(blocks: list[str]) -> str:
|
|
8310
|
-
return "# Tasks\n\n" + "\n\n".join(blocks) + "\n"
|
|
9155
|
+
return "# Tasks\n\n## Invalidates\n\n- None.\n\n" + "\n\n".join(blocks) + "\n"
|
|
8311
9156
|
|
|
8312
9157
|
|
|
8313
9158
|
def _goal_task_block(
|
|
@@ -8926,7 +9771,7 @@ def validate_fast_pre_push_doctor_scenario() -> int:
|
|
|
8926
9771
|
|
|
8927
9772
|
def validate_verify_layer_tag_scenario() -> int:
|
|
8928
9773
|
fixture = (
|
|
8929
|
-
"# Tasks\n\n"
|
|
9774
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
8930
9775
|
"- [ ] 1.1 Exercise the verification-layer tag\n"
|
|
8931
9776
|
" - Covers:\n"
|
|
8932
9777
|
" - E1: Public behavior passes.\n"
|
|
@@ -9982,6 +10827,20 @@ def validate_thin_native_install_scenario() -> int:
|
|
|
9982
10827
|
)
|
|
9983
10828
|
return 1
|
|
9984
10829
|
|
|
10830
|
+
# The bootstrap is the whole resident protocol a consumer gets, and the
|
|
10831
|
+
# qualifier "for product files" left readers to infer what it excluded.
|
|
10832
|
+
# The inference actually made was that tasks.md belongs in Touch.
|
|
10833
|
+
if not re.search(r"Touch\b[^\n]*\bbound", block, re.IGNORECASE):
|
|
10834
|
+
report("thin-native-install bootstrap does not state what Touch bounds.")
|
|
10835
|
+
return 1
|
|
10836
|
+
if not re.search(r"change'?s own dir|own change dir", block, re.IGNORECASE):
|
|
10837
|
+
report(
|
|
10838
|
+
"thin-native-install bootstrap does not name the record-write "
|
|
10839
|
+
"exemption, so a consumer still infers that tasks.md belongs in "
|
|
10840
|
+
"Touch."
|
|
10841
|
+
)
|
|
10842
|
+
return 1
|
|
10843
|
+
|
|
9985
10844
|
claude_text = (repo / "CLAUDE.md").read_text(encoding="utf-8")
|
|
9986
10845
|
if claude_text.count("@AGENTS.md") != 1:
|
|
9987
10846
|
report(
|
|
@@ -10319,7 +11178,7 @@ def validate_native_plugin_install_matrix_scenario() -> int:
|
|
|
10319
11178
|
def guard_task_fixture(checked: bool = False) -> str:
|
|
10320
11179
|
box = "x" if checked else " "
|
|
10321
11180
|
return (
|
|
10322
|
-
"# Tasks\n\n"
|
|
11181
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
10323
11182
|
f"- [{box}] 1.1 Exercise guarded feature\n"
|
|
10324
11183
|
" - Covers:\n"
|
|
10325
11184
|
" - E1: Guarded public behavior passes.\n"
|
|
@@ -10419,7 +11278,7 @@ RECORD_LAYER_SPEC = (
|
|
|
10419
11278
|
def record_layer_tasks(checked: bool = False, touch: str = "src/feature.js") -> str:
|
|
10420
11279
|
box = "x" if checked else " "
|
|
10421
11280
|
return (
|
|
10422
|
-
"# Tasks\n\n"
|
|
11281
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
10423
11282
|
f"- [{box}] 1.1 Exercise guarded feature\n"
|
|
10424
11283
|
" - Covers:\n"
|
|
10425
11284
|
" - demo-cap / Guarded behavior holds / Guarded public behavior passes\n"
|
|
@@ -10435,7 +11294,7 @@ def record_layer_tasks(checked: bool = False, touch: str = "src/feature.js") ->
|
|
|
10435
11294
|
|
|
10436
11295
|
def mode_fixture_tasks(mode: str, touch: str) -> str:
|
|
10437
11296
|
return (
|
|
10438
|
-
"# Tasks\n\n"
|
|
11297
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
10439
11298
|
"- [ ] 1.1 Establish the version baseline\n"
|
|
10440
11299
|
f" - Mode: {mode}\n"
|
|
10441
11300
|
" - Covers:\n"
|
|
@@ -10577,7 +11436,7 @@ def sibling_scope_tasks(sibling_checked: bool, sibling_touch: str) -> str:
|
|
|
10577
11436
|
)
|
|
10578
11437
|
|
|
10579
11438
|
return (
|
|
10580
|
-
"# Tasks\n\n"
|
|
11439
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
10581
11440
|
"## 1. Work\n\n"
|
|
10582
11441
|
+ task("1.1", "Own the shared file", sibling_checked, sibling_touch)
|
|
10583
11442
|
+ "\n"
|
|
@@ -10701,7 +11560,7 @@ def validate_resident_topic_matching_scenario() -> int:
|
|
|
10701
11560
|
"""
|
|
10702
11561
|
label = "resident-topic-matching"
|
|
10703
11562
|
source = (ROOT / "assets/bootstrap/AGENTS.md").read_text(encoding="utf-8")
|
|
10704
|
-
original = "Touch
|
|
11563
|
+
original = "Touch bounds product writes; the change's own dir is exempt."
|
|
10705
11564
|
if original not in source:
|
|
10706
11565
|
report(
|
|
10707
11566
|
f"{label}: the fixture's anchor sentence is not in the bootstrap; "
|
|
@@ -11258,7 +12117,7 @@ def validate_touch_write_guard_scenario() -> int:
|
|
|
11258
12117
|
|
|
11259
12118
|
def compaction_task_fixture() -> str:
|
|
11260
12119
|
return (
|
|
11261
|
-
"# Tasks\n\n"
|
|
12120
|
+
"# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
11262
12121
|
"- [ ] 1.1 Exercise compaction continuity\n"
|
|
11263
12122
|
" - Covers:\n"
|
|
11264
12123
|
" - E1: Continuity survives compaction.\n"
|
|
@@ -11768,7 +12627,11 @@ def validate_touch_guard_surface_scenario() -> int:
|
|
|
11768
12627
|
report(f"touch-guard-surface: README lacks guard guidance: {needle}.")
|
|
11769
12628
|
return 1
|
|
11770
12629
|
bootstrap = (ROOT / "assets/bootstrap/AGENTS.md").read_text(encoding="utf-8")
|
|
11771
|
-
|
|
12630
|
+
# The bootstrap must tell a consumer the guard exists and how to opt out;
|
|
12631
|
+
# it no longer spends bytes naming `keel guard clear`, which `keel --help`
|
|
12632
|
+
# and `keel guard status` carry. `--no-guard` is the flag it does name, so
|
|
12633
|
+
# that one stays literal and a rename of it still fails here.
|
|
12634
|
+
if "--no-guard" not in bootstrap or "guards it by default" not in bootstrap:
|
|
11772
12635
|
report("touch-guard-surface: bootstrap does not mention the guard.")
|
|
11773
12636
|
return 1
|
|
11774
12637
|
registered = {name for name, _ in SCENARIOS}
|
|
@@ -12146,6 +13009,22 @@ SCENARIOS: tuple = (
|
|
|
12146
13009
|
("repo-action-mode", validate_repo_action_mode_scenario),
|
|
12147
13010
|
("runner-skip-accounting", validate_runner_skip_accounting_scenario),
|
|
12148
13011
|
("resident-topic-matching", validate_resident_topic_matching_scenario),
|
|
13012
|
+
("task-start-invalidation", validate_task_start_invalidation_scenario),
|
|
13013
|
+
("regression-check-tag", validate_regression_check_tag_scenario),
|
|
13014
|
+
("durable-owner-vocabulary", validate_durable_owner_vocabulary_scenario),
|
|
13015
|
+
("anchor-reverification-bound", validate_anchor_reverification_bound_scenario),
|
|
13016
|
+
(
|
|
13017
|
+
"authoring-surface-owner-and-tags",
|
|
13018
|
+
validate_authoring_surface_owner_and_tags_scenario,
|
|
13019
|
+
),
|
|
13020
|
+
(
|
|
13021
|
+
"packaged-schema-derivation",
|
|
13022
|
+
validate_packaged_schema_derivation_scenario,
|
|
13023
|
+
),
|
|
13024
|
+
(
|
|
13025
|
+
"invalidation-authoring-surface",
|
|
13026
|
+
validate_invalidation_authoring_surface_scenario,
|
|
13027
|
+
),
|
|
12149
13028
|
(
|
|
12150
13029
|
"completed-sibling-attribution",
|
|
12151
13030
|
validate_completed_sibling_attribution_scenario,
|