@christang/keel 5.3.0 → 5.3.3

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.
@@ -37,8 +37,8 @@ REQUIRED_SCRIPTS = [
37
37
  "scripts/validate_plugin.py",
38
38
  ]
39
39
 
40
- PACKAGE_VERSION = "5.3.0"
41
- PROTOCOL_VERSION = "5.3.0"
40
+ PACKAGE_VERSION = "5.3.3"
41
+ PROTOCOL_VERSION = "5.3.3"
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")
@@ -99,7 +99,11 @@ RESIDENT_BLOCKS = [
99
99
  "keel gate task-start",
100
100
  "task capsule",
101
101
  "fingerprint",
102
- "Touch is the write boundary",
102
+ # Prose, not a command: this sentence is the one that has actually
103
+ # been reworded under byte pressure, so it is matched as a topic —
104
+ # Touch and a `bound…` word in one statement. The other prose
105
+ # entries stay literal until one of them needs the same freedom.
106
+ re.compile(r"Touch\b[^\n]*\bbound", re.IGNORECASE),
103
107
  "read-only report/evidence",
104
108
  "native plugin",
105
109
  "keel --init",
@@ -491,9 +495,9 @@ def strip_template_checksum(content: str) -> tuple[str, str | None]:
491
495
  return "".join(kept), checksum
492
496
 
493
497
 
494
- def validate_resident_blocks(errors: list[str]) -> None:
498
+ def validate_resident_blocks(errors: list[str], root: Path = ROOT) -> None:
495
499
  for block in RESIDENT_BLOCKS:
496
- path = ROOT / block["path"]
500
+ path = root / block["path"]
497
501
  if not path.is_file():
498
502
  errors.append(f"{block['name']} is missing: {block['path']}")
499
503
  continue
@@ -519,9 +523,24 @@ def validate_resident_blocks(errors: list[str]) -> None:
519
523
  f"budget is {max_lines}"
520
524
  )
521
525
 
526
+ # A required entry is one of two kinds, and they mean different things.
527
+ # A literal names a command, marker, or identifier: if the block no
528
+ # longer contains it exactly, it is telling a reader to run something
529
+ # that does not exist, so the check must fail. A pattern states a topic
530
+ # in prose: the concepts must remain, the wording may move — which it
531
+ # must be free to, because this block is under a line and byte budget
532
+ # and gets rewritten to fit.
522
533
  for required in block["required"]:
523
- if required not in managed_block:
524
- errors.append(f"{block['name']} missing required topic: {required}")
534
+ if isinstance(required, str):
535
+ if required not in managed_block:
536
+ errors.append(
537
+ f"{block['name']} missing required literal: {required}"
538
+ )
539
+ elif not required.search(managed_block):
540
+ errors.append(
541
+ f"{block['name']} missing required topic: "
542
+ f"{required.pattern}"
543
+ )
525
544
 
526
545
  lowered = managed_block.lower()
527
546
  for forbidden in RESIDENT_FORBIDDEN_SNIPPETS:
@@ -1082,6 +1101,17 @@ def validate_skill_portability_policy_scenario() -> int:
1082
1101
  return 0
1083
1102
 
1084
1103
 
1104
+ def posix_paths(text: str) -> str:
1105
+ """Fold path separators so an assertion does not encode the host's spelling.
1106
+
1107
+ Keel prints these paths through the host's path joiner, so the same doctor
1108
+ line reads `.claude\\commands\\opsx` on Windows and `.claude/commands/opsx`
1109
+ on a POSIX runner. Assertions state the forward-slash form and normalize the
1110
+ captured output, rather than branching on the platform or accepting both.
1111
+ """
1112
+ return (text or "").replace("\\", "/")
1113
+
1114
+
1085
1115
  def validate_target_surface_scenario() -> int:
1086
1116
  with tempfile.TemporaryDirectory(prefix="keel-surface-") as raw_tmp:
1087
1117
  tmp = Path(raw_tmp)
@@ -1098,9 +1128,9 @@ def validate_target_surface_scenario() -> int:
1098
1128
  claude_doctor.returncode != 0
1099
1129
  or "Target surface:" not in claude_doctor.stdout
1100
1130
  or "OpenSpec commands: ok" not in claude_doctor.stdout
1101
- or ".claude\\commands\\opsx" not in claude_doctor.stdout
1131
+ or ".claude/commands/opsx" not in posix_paths(claude_doctor.stdout)
1102
1132
  or "OpenSpec action skills: ok" not in claude_doctor.stdout
1103
- or ".claude\\skills" not in claude_doctor.stdout
1133
+ or ".claude/skills" not in posix_paths(claude_doctor.stdout)
1104
1134
  or "bootstrap: ok" not in claude_doctor.stdout
1105
1135
  or "CLAUDE import: ok" not in claude_doctor.stdout
1106
1136
  or "native plugin runtime: manual" not in claude_doctor.stdout
@@ -1130,9 +1160,9 @@ def validate_target_surface_scenario() -> int:
1130
1160
  if (
1131
1161
  codex_doctor.returncode != 0
1132
1162
  or "OpenSpec commands: ok" not in codex_doctor.stdout
1133
- or codex_prompt_dir not in codex_doctor.stdout
1163
+ or posix_paths(codex_prompt_dir) not in posix_paths(codex_doctor.stdout)
1134
1164
  or "OpenSpec action skills: ok" not in codex_doctor.stdout
1135
- or ".codex\\skills" not in codex_doctor.stdout
1165
+ or ".codex/skills" not in posix_paths(codex_doctor.stdout)
1136
1166
  or "bootstrap: ok" not in codex_doctor.stdout
1137
1167
  or "native plugin runtime: manual" not in codex_doctor.stdout
1138
1168
  or "Target capabilities (codex):" not in codex_doctor.stdout
@@ -1173,9 +1203,9 @@ def validate_target_surface_scenario() -> int:
1173
1203
  if (
1174
1204
  opencode_doctor.returncode != 0
1175
1205
  or "OpenSpec commands: ok" not in opencode_doctor.stdout
1176
- or ".opencode\\commands" not in opencode_doctor.stdout
1206
+ or ".opencode/commands" not in posix_paths(opencode_doctor.stdout)
1177
1207
  or "OpenSpec action skills: ok" not in opencode_doctor.stdout
1178
- or ".opencode\\skills" not in opencode_doctor.stdout
1208
+ or ".opencode/skills" not in posix_paths(opencode_doctor.stdout)
1179
1209
  or "bootstrap: ok" not in opencode_doctor.stdout
1180
1210
  or "native plugin: manual" not in opencode_doctor.stdout
1181
1211
  or "Target capabilities (opencode):" not in opencode_doctor.stdout
@@ -1728,7 +1758,7 @@ def validate_authoring_continuity_scenario() -> int:
1728
1758
  change_root / "specs/demo/spec.md",
1729
1759
  "## ADDED Requirements\n",
1730
1760
  )
1731
- write_text(change_root / "tasks.md", "# Tasks\n\n## Tasks\n")
1761
+ write_text(change_root / "tasks.md", "# Tasks\n\n## Invalidates\n\n- None.\n\n## Tasks\n")
1732
1762
  invalid = run_keel(invalid_repo, "context", "--json")
1733
1763
  invalid_payload = json.loads(invalid.stdout)
1734
1764
  if (
@@ -2497,7 +2527,9 @@ def write_gate_fixture(repo: Path, tasks: str, design: str = "## Context\n\nfixt
2497
2527
  change = repo / "openspec/changes/demo"
2498
2528
  write_text(
2499
2529
  change / "tasks.md",
2500
- "# Tasks\n\n"
2530
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
2531
+ "## Invalidates\n\n"
2532
+ "- None.\n\n"
2501
2533
  "## Expectation Coverage\n\n"
2502
2534
  "- E1:\n"
2503
2535
  " - Covered by: 1.1\n\n"
@@ -2595,7 +2627,7 @@ def validate_cli_scenario() -> int:
2595
2627
 
2596
2628
  write_text(
2597
2629
  repo / "openspec/changes/status-drift/tasks.md",
2598
- "# Tasks\n\n"
2630
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
2599
2631
  "## Tasks\n\n"
2600
2632
  "- [x] A1 implementation **未提交**\n\n"
2601
2633
  "## Execution Status\n\n"
@@ -2618,7 +2650,7 @@ def validate_cli_scenario() -> int:
2618
2650
 
2619
2651
  write_text(
2620
2652
  repo / "openspec/changes/status-drift/tasks.md",
2621
- "# Tasks\n\n"
2653
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
2622
2654
  "> 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"
2623
2655
  "## Tasks\n\n"
2624
2656
  "- [x] A1 implementation\n\n"
@@ -2636,7 +2668,7 @@ def validate_cli_scenario() -> int:
2636
2668
 
2637
2669
  write_text(
2638
2670
  repo / "openspec/changes/archive/2026-07-09-finished/tasks.md",
2639
- "# Tasks\n\n"
2671
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
2640
2672
  "- [x] A1 implementation\n\n"
2641
2673
  "## Evidence\n\n"
2642
2674
  "- Scope check: pre-existing dirty paths were not attributed to this task.\n",
@@ -2832,7 +2864,7 @@ def validate_stateless_continuity_scenario() -> int:
2832
2864
 
2833
2865
  write_text(
2834
2866
  repo / "openspec/changes/other/tasks.md",
2835
- "# Tasks\n\n- [ ] 1.1 Other task\n",
2867
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n- [ ] 1.1 Other task\n",
2836
2868
  )
2837
2869
  write_text(
2838
2870
  repo / "keel/HANDOFF.md",
@@ -2892,7 +2924,7 @@ def validate_stateless_continuity_scenario() -> int:
2892
2924
 
2893
2925
  write_text(
2894
2926
  tasks_path,
2895
- "# Tasks\n\n"
2927
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
2896
2928
  "- [x] 1.1 First slice\n"
2897
2929
  "- [x] 1.2 Second slice\n",
2898
2930
  )
@@ -3012,7 +3044,7 @@ def validate_stateless_continuity_scenario() -> int:
3012
3044
  stale_repo.mkdir()
3013
3045
  write_text(
3014
3046
  stale_repo / "openspec/changes/current/tasks.md",
3015
- "# Tasks\n\n- [ ] 1.1 Current task\n",
3047
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n- [ ] 1.1 Current task\n",
3016
3048
  )
3017
3049
  write_text(
3018
3050
  stale_repo / "keel/HANDOFF.md",
@@ -3037,7 +3069,7 @@ def validate_stateless_continuity_scenario() -> int:
3037
3069
 
3038
3070
  write_text(
3039
3071
  stale_repo / "openspec/changes/current/tasks.md",
3040
- "# Tasks\n\n- [x] 1.1 Current task\n",
3072
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n- [x] 1.1 Current task\n",
3041
3073
  )
3042
3074
  write_text(
3043
3075
  stale_repo / "keel/HANDOFF.md",
@@ -3064,7 +3096,7 @@ def validate_stateless_continuity_scenario() -> int:
3064
3096
 
3065
3097
  write_text(
3066
3098
  stale_repo / "openspec/changes/current/tasks.md",
3067
- "# Tasks\n\n- [ ] 1.1 Current task\n",
3099
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n- [ ] 1.1 Current task\n",
3068
3100
  )
3069
3101
  write_text(
3070
3102
  stale_repo / "keel/HANDOFF.md",
@@ -3409,7 +3441,7 @@ def task_contract_fixture(
3409
3441
  command_lines = "".join(f" - {item}\n" for item in commands)
3410
3442
  evidence_lines = "".join(f" - {item}\n" for item in evidence)
3411
3443
  return (
3412
- "# Tasks\n\n"
3444
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
3413
3445
  "- [ ] 1.1 Exercise task contract\n"
3414
3446
  " - Owner: keel-agent\n"
3415
3447
  f" - Mode: {mode}\n"
@@ -3446,6 +3478,246 @@ def task_contract_fixture(
3446
3478
  )
3447
3479
 
3448
3480
 
3481
+ # task-start requires a change to declare what it invalidates, so every fixture
3482
+ # that expects to start a task carries the cheapest legitimate answer. Scenarios
3483
+ # exercising the declaration itself replace this block.
3484
+ INVALIDATES_NONE = "## Invalidates\n\n- None.\n\n"
3485
+
3486
+
3487
+ INVALIDATION_ENTRIES = (
3488
+ '- I1: "Touch is the write boundary" — assets/bootstrap/AGENTS.md.'
3489
+ " Updated by: 1.1\n"
3490
+ '- I2: "does not accept a GitHub issue URL" —'
3491
+ " keel/archive/follow-ups/note.md."
3492
+ " Discard reason: archive notes are historical evidence.\n"
3493
+ '- I3: "the suite is not portable" — README.md.'
3494
+ " Durable owner: https://example.invalid/issues/1\n"
3495
+ )
3496
+
3497
+
3498
+ def invalidation_repo(root: Path, name: str, section: str | None) -> Path:
3499
+ repo = root / name
3500
+ # A real Contract anchor, so the missing-section case can prove that a
3501
+ # failing authoring gate leaves the anchor untouched rather than merely
3502
+ # failing earlier for want of one.
3503
+ body = task_contract_fixture(evidence=("Contract: pending", "M1: pending"))
3504
+ body = body.replace(INVALIDATES_NONE, "" if section is None else section)
3505
+ write_text(repo / "openspec/changes/demo/tasks.md", body)
3506
+ return repo
3507
+
3508
+
3509
+ def validate_task_start_invalidation_scenario() -> int:
3510
+ label = "task-start-invalidation"
3511
+
3512
+ def gate(repo: Path, *extra: str) -> tuple[subprocess.CompletedProcess[str], dict]:
3513
+ result = run_keel(
3514
+ repo, "gate", "task-start", ".",
3515
+ "--change", "demo", "--task", "1.1", "--json", *extra,
3516
+ )
3517
+ payload = json.loads(result.stdout) if result.stdout.strip() else {}
3518
+ return result, payload
3519
+
3520
+ def codes(payload: dict) -> set[str]:
3521
+ return {item.get("code") for item in payload.get("problems", [])}
3522
+
3523
+ def messages(payload: dict) -> str:
3524
+ return " ".join(item.get("message", "") for item in payload.get("problems", []))
3525
+
3526
+ with tempfile.TemporaryDirectory(
3527
+ prefix="keel-invalidation-", ignore_cleanup_errors=True
3528
+ ) as raw:
3529
+ tmp = Path(raw)
3530
+
3531
+ # A change that never answered the question cannot start, and the
3532
+ # refusal writes nothing — the guard manifest and the Contract anchor
3533
+ # are both withheld, so a failed authoring gate leaves no state behind.
3534
+ missing = invalidation_repo(tmp, "missing", None)
3535
+ tasks_before = (missing / "openspec/changes/demo/tasks.md").read_text(
3536
+ encoding="utf-8"
3537
+ )
3538
+ result, payload = gate(missing, "--record")
3539
+ if (
3540
+ payload.get("status") != "fail"
3541
+ or "invalidation-declaration" not in codes(payload)
3542
+ ):
3543
+ report(f"{label} accepted a change with no invalidation section.")
3544
+ report(repr(payload.get("problems")))
3545
+ return 1
3546
+ if (missing / "keel/guard.json").exists():
3547
+ report(f"{label} wrote a guard manifest for a failing authoring gate.")
3548
+ return 1
3549
+ if (missing / "openspec/changes/demo/tasks.md").read_text(
3550
+ encoding="utf-8"
3551
+ ) != tasks_before:
3552
+ report(f"{label} recorded a Contract anchor for a failing gate.")
3553
+ return 1
3554
+
3555
+ none_repo = invalidation_repo(tmp, "none", INVALIDATES_NONE)
3556
+ _, none_payload = gate(none_repo, "--no-guard")
3557
+ if none_payload.get("status") != "pass":
3558
+ report(f"{label} refused a legitimate declaration of nothing.")
3559
+ report(repr(none_payload.get("problems")))
3560
+ return 1
3561
+
3562
+ full_repo = invalidation_repo(
3563
+ tmp, "full", "## Invalidates\n\n" + INVALIDATION_ENTRIES + "\n"
3564
+ )
3565
+ _, full_payload = gate(full_repo, "--no-guard")
3566
+ if full_payload.get("status") != "pass":
3567
+ report(f"{label} refused well-formed entries covering all three closures.")
3568
+ report(repr(full_payload.get("problems")))
3569
+ return 1
3570
+
3571
+ # The declaration is change-level bookkeeping, not task authority: two
3572
+ # changes whose tasks are byte-identical must compile the same capsule
3573
+ # however differently they answered this question.
3574
+ none_print = none_payload.get("contract", {}).get("fingerprint", {}).get("value")
3575
+ full_print = full_payload.get("contract", {}).get("fingerprint", {}).get("value")
3576
+ if not none_print or none_print != full_print:
3577
+ report(
3578
+ f"{label} let the invalidation section move the capsule "
3579
+ f"fingerprint: {none_print} vs {full_print}"
3580
+ )
3581
+ return 1
3582
+
3583
+ # A location list only ever names files the author already recalled,
3584
+ # which is the failure this section exists to prevent, so an entry
3585
+ # without the searchable wording is refused.
3586
+ no_phrase = invalidation_repo(
3587
+ tmp,
3588
+ "no-phrase",
3589
+ "## Invalidates\n\n- I1: AGENTS.md and README.md. Updated by: 1.1\n\n",
3590
+ )
3591
+ _, no_phrase_payload = gate(no_phrase, "--no-guard")
3592
+ if (
3593
+ no_phrase_payload.get("status") != "fail"
3594
+ or "I1" not in messages(no_phrase_payload)
3595
+ ):
3596
+ report(f"{label} accepted an entry with no searchable phrase.")
3597
+ report(repr(no_phrase_payload.get("problems")))
3598
+ return 1
3599
+
3600
+ unclosed = invalidation_repo(
3601
+ tmp,
3602
+ "unclosed",
3603
+ '## Invalidates\n\n- I1: "Touch is the write boundary" — AGENTS.md.\n\n',
3604
+ )
3605
+ _, unclosed_payload = gate(unclosed, "--no-guard")
3606
+ if (
3607
+ unclosed_payload.get("status") != "fail"
3608
+ or "I1" not in messages(unclosed_payload)
3609
+ ):
3610
+ report(f"{label} accepted an entry that never closed.")
3611
+ report(repr(unclosed_payload.get("problems")))
3612
+ return 1
3613
+
3614
+ unknown_owner = invalidation_repo(
3615
+ tmp,
3616
+ "unknown-task",
3617
+ '## Invalidates\n\n- I1: "Touch is the write boundary" — AGENTS.md.'
3618
+ " Updated by: 9.9\n\n",
3619
+ )
3620
+ _, unknown_payload = gate(unknown_owner, "--no-guard")
3621
+ if unknown_payload.get("status") != "fail":
3622
+ report(f"{label} accepted an updater task that does not exist.")
3623
+ return 1
3624
+
3625
+ if not codes(payload):
3626
+ report(f"{label} reported a failure with no problem code.")
3627
+ return 1
3628
+
3629
+ report(f"{label} scenario passed.")
3630
+ return 0
3631
+
3632
+
3633
+ SCHEMA_COPY_PAIRS = (
3634
+ (
3635
+ "openspec/schemas/keel-spec-driven/templates/tasks.md",
3636
+ "assets/openspec/schemas/keel-spec-driven/templates/tasks.md",
3637
+ ),
3638
+ (
3639
+ "openspec/schemas/keel-spec-driven/schema.yaml",
3640
+ "assets/openspec/schemas/keel-spec-driven/schema.yaml",
3641
+ ),
3642
+ )
3643
+
3644
+
3645
+ def validate_invalidation_authoring_surface_scenario() -> int:
3646
+ label = "invalidation-authoring-surface"
3647
+
3648
+ # The two schema copies are the repo-local one OpenSpec resolves and the
3649
+ # packaged one `keel --init` writes. compact-task-authoring already means to
3650
+ # assert they agree, but its canonical root does not exist in this layout, so
3651
+ # its rglob compares nothing; this states the pair explicitly.
3652
+ for local, packaged in SCHEMA_COPY_PAIRS:
3653
+ local_text = (ROOT / local).read_text(encoding="utf-8")
3654
+ packaged_text = (ROOT / packaged).read_text(encoding="utf-8")
3655
+ if local_text != packaged_text:
3656
+ report(f"{label} schema copies diverge: {local} vs {packaged}")
3657
+ return 1
3658
+
3659
+ template = (
3660
+ ROOT / "openspec/schemas/keel-spec-driven/templates/tasks.md"
3661
+ ).read_text(encoding="utf-8")
3662
+ for marker in ("## Invalidates", "- None.", "- I1:"):
3663
+ if marker not in template:
3664
+ report(f"{label} tasks template lacks the invalidation section: {marker}")
3665
+ return 1
3666
+
3667
+ schema = (
3668
+ ROOT / "openspec/schemas/keel-spec-driven/schema.yaml"
3669
+ ).read_text(encoding="utf-8")
3670
+ for marker in ("## Invalidates", "Updated by:", "Discard reason:"):
3671
+ if marker not in schema:
3672
+ report(
3673
+ f"{label} authoring instruction does not describe the "
3674
+ f"invalidation section: {marker}"
3675
+ )
3676
+ return 1
3677
+
3678
+ resident = resident_session_start_section(ROOT / "AGENTS.md")
3679
+ agents = (ROOT / "AGENTS.md").read_text(encoding="utf-8")
3680
+ if resident is None:
3681
+ report(f"{label} resident AGENTS.md has no Session Start section.")
3682
+ return 1
3683
+ for marker in ("## Invalidates", "## Expectation Coverage"):
3684
+ if marker not in agents:
3685
+ report(f"{label} resident protocol does not name {marker}.")
3686
+ return 1
3687
+
3688
+ # An author who scaffolds and fills in the tasks must not additionally have
3689
+ # to discover this section, so the template's own answer has to satisfy the
3690
+ # gate. Placeholders are filled generically; the assertion is narrow on
3691
+ # purpose — no invalidation problem may survive.
3692
+ filled = re.sub(r"<!--[\s\S]*?-->", "", template)
3693
+ filled = filled.replace("<strategy>", "evidence-first")
3694
+ filled = re.sub(r"<[^<>\n]+>", "concrete authored value", filled)
3695
+ with tempfile.TemporaryDirectory(
3696
+ prefix="keel-invalidation-surface-", ignore_cleanup_errors=True
3697
+ ) as raw:
3698
+ repo = Path(raw) / "scaffold"
3699
+ write_text(repo / "openspec/changes/demo/tasks.md", filled)
3700
+ started = run_keel(
3701
+ repo, "gate", "task-start", ".",
3702
+ "--change", "demo", "--task", "1.1", "--json", "--no-guard",
3703
+ )
3704
+ payload = json.loads(started.stdout) if started.stdout.strip() else {}
3705
+ offenders = [
3706
+ item for item in payload.get("problems", [])
3707
+ if str(item.get("code", "")).startswith("invalidation-")
3708
+ ]
3709
+ if offenders:
3710
+ report(
3711
+ f"{label} a filled-in scaffold still fails the invalidation "
3712
+ "gate, so the template's own answer is not usable."
3713
+ )
3714
+ report(repr(offenders))
3715
+ return 1
3716
+
3717
+ report(f"{label} scenario passed.")
3718
+ return 0
3719
+
3720
+
3449
3721
  def validate_task_contract_core_scenario() -> int:
3450
3722
  with tempfile.TemporaryDirectory(prefix="keel-task-contract-") as raw_tmp:
3451
3723
  repo = Path(raw_tmp)
@@ -3690,7 +3962,7 @@ def task_capsule_expanded_fixture() -> str:
3690
3962
 
3691
3963
  def task_capsule_compact_fixture() -> str:
3692
3964
  return (
3693
- "# Tasks\n\n"
3965
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
3694
3966
  "- [ ] 1.1 Exercise task contract\n"
3695
3967
  " - Covers:\n"
3696
3968
  " - E1: Public behavior passes.\n"
@@ -4233,7 +4505,7 @@ TRACKER_OWNER = "https://github.com/TanglmChris/keel/issues/12"
4233
4505
  def tracker_owner_tasks(findings: str, closure: str) -> str:
4234
4506
  """One complete, checked task plus one Expectation Coverage closure line."""
4235
4507
  return (
4236
- "# Tasks\n\n"
4508
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
4237
4509
  "## Expectation Coverage\n\n"
4238
4510
  "- E1:\n"
4239
4511
  f" - {closure}\n\n"
@@ -5137,7 +5409,7 @@ def validate_task_capsule_scenario() -> int:
5137
5409
 
5138
5410
  close_task = (
5139
5411
  completion_task
5140
- .replace("# Tasks\n\n", "# Tasks\n\n## Expectation Coverage\n\n"
5412
+ .replace("# Tasks\n\n## Invalidates\n\n- None.\n\n", "# Tasks\n\n## Invalidates\n\n- None.\n\n## Expectation Coverage\n\n"
5141
5413
  "- E1:\n - Covered by: 1.1\n\n## 1. Work\n\n")
5142
5414
  .replace("- [ ] 1.1", "- [x] 1.1")
5143
5415
  )
@@ -5197,7 +5469,7 @@ def validate_core_gates_scenario() -> int:
5197
5469
  tasks_path = repo / "openspec/changes/demo/tasks.md"
5198
5470
  write_text(
5199
5471
  tasks_path,
5200
- "# Tasks\n\n"
5472
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
5201
5473
  "- [ ] 1.1 Incomplete task\n"
5202
5474
  " - Owner: keel-agent\n",
5203
5475
  )
@@ -5232,7 +5504,7 @@ def validate_core_gates_scenario() -> int:
5232
5504
 
5233
5505
  write_text(
5234
5506
  tasks_path,
5235
- "# Tasks\n\n"
5507
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
5236
5508
  "- [ ] 1.1 Complete executable task\n"
5237
5509
  " - Owner: keel-agent\n"
5238
5510
  " - Mode: implementation\n"
@@ -5382,7 +5654,7 @@ def validate_core_gates_scenario() -> int:
5382
5654
  findings: str = "none",
5383
5655
  ) -> str:
5384
5656
  return (
5385
- "# Tasks\n\n"
5657
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
5386
5658
  "- [ ] 1.1 Complete behavior\n"
5387
5659
  " - Owner: keel-agent\n"
5388
5660
  " - Mode: implementation\n"
@@ -5501,7 +5773,7 @@ def validate_core_gates_scenario() -> int:
5501
5773
  )
5502
5774
  write_text(
5503
5775
  completion_repo / "openspec/changes/follow-up/tasks.md",
5504
- "# Tasks\n\n- [ ] 1.1 Own the finding\n",
5776
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n- [ ] 1.1 Own the finding\n",
5505
5777
  )
5506
5778
  owned_finding = run_keel(
5507
5779
  completion_repo,
@@ -5819,7 +6091,7 @@ def validate_core_gates_scenario() -> int:
5819
6091
  if extra_touch:
5820
6092
  touch += " - src/extra.js\n"
5821
6093
  return (
5822
- "# Tasks\n\n"
6094
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
5823
6095
  "- [ ] 1.1 Record behavior\n"
5824
6096
  " - Owner: keel-agent\n"
5825
6097
  " - Mode: implementation\n"
@@ -6037,7 +6309,7 @@ def validate_core_gates_scenario() -> int:
6037
6309
  def close_task(checked: bool, review_status: str = "pass") -> str:
6038
6310
  mark = "x" if checked else " "
6039
6311
  return (
6040
- "# Tasks\n\n"
6312
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
6041
6313
  "## Expectation Coverage\n\n"
6042
6314
  "- E1:\n"
6043
6315
  " - Covered by: 1.1\n\n"
@@ -6196,7 +6468,7 @@ def validate_core_gates_scenario() -> int:
6196
6468
 
6197
6469
  def validate_scope_rename_attribution_scenario() -> int:
6198
6470
  rename_task = (
6199
- "# Tasks\n\n"
6471
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
6200
6472
  "- [ ] 1.1 Complete behavior\n"
6201
6473
  " - Owner: keel-agent\n"
6202
6474
  " - Mode: implementation\n"
@@ -6399,7 +6671,7 @@ def validate_target_capability_adapters_scenario() -> int:
6399
6671
 
6400
6672
  write_text(
6401
6673
  repo / "openspec/changes/demo/tasks.md",
6402
- "# Tasks\n\n- [ ] 1.1 Incomplete\n - Owner: keel-agent\n",
6674
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n- [ ] 1.1 Incomplete\n - Owner: keel-agent\n",
6403
6675
  )
6404
6676
  gate = run_keel(
6405
6677
  repo,
@@ -6825,6 +7097,31 @@ def session_start_context(result: subprocess.CompletedProcess[str]) -> str | Non
6825
7097
  return output.get("additionalContext")
6826
7098
 
6827
7099
 
7100
+ # The projection is delivered through additionalContext, which the host injects
7101
+ # into the agent and never renders for the human. Every branch must therefore
7102
+ # carry the instruction to relay it, including — especially — the degraded ones,
7103
+ # because a projection nobody sees is a projection nobody checks.
7104
+ SESSION_START_DISCLOSURE = "to the user in your first reply"
7105
+
7106
+ # A host loads its plugins once per session, so the projection can be absent for
7107
+ # reasons no repository check can see. The resident protocol is the carrier of
7108
+ # last resort and must state the same obligation without trading away the
7109
+ # continuity rules it already carried.
7110
+ RESIDENT_SESSION_START_REQUIRED = (
7111
+ SESSION_START_DISCLOSURE,
7112
+ "keel context",
7113
+ "never infer continuity from native memory",
7114
+ )
7115
+
7116
+
7117
+ def resident_session_start_section(path: Path) -> str | None:
7118
+ text = path.read_text(encoding="utf-8")
7119
+ match = re.search(
7120
+ r"^## Session Start$(.*?)^## ", text, re.MULTILINE | re.DOTALL
7121
+ )
7122
+ return match.group(1) if match else None
7123
+
7124
+
6828
7125
  def validate_native_plugin_session_start_scenario() -> int:
6829
7126
  real_cli = f'node "{ROOT / "bin/keel.js"}"'
6830
7127
  codex_event = {"hook_event_name": "SessionStart", "source": "startup"}
@@ -6872,6 +7169,7 @@ def validate_native_plugin_session_start_scenario() -> int:
6872
7169
  or "demo#1.1" not in codex_context
6873
7170
  or "task-start" not in codex_context
6874
7171
  or "disposable" not in codex_context
7172
+ or SESSION_START_DISCLOSURE not in codex_context
6875
7173
  ):
6876
7174
  report(
6877
7175
  "native-plugin-session-start ready projection lacks concise "
@@ -6883,6 +7181,24 @@ def validate_native_plugin_session_start_scenario() -> int:
6883
7181
  report("native-plugin-session-start projection overstepped.")
6884
7182
  return 1
6885
7183
 
7184
+ idle_repo = tmp / "idle"
7185
+ (idle_repo / "openspec/changes").mkdir(parents=True)
7186
+ idle_result = run_session_start_hook(
7187
+ idle_repo, codex_event, keel_cli=real_cli
7188
+ )
7189
+ idle_context = session_start_context(idle_result)
7190
+ if (
7191
+ idle_result.returncode != 0
7192
+ or not idle_context
7193
+ or "idle" not in idle_context
7194
+ or SESSION_START_DISCLOSURE not in idle_context
7195
+ ):
7196
+ report(
7197
+ "native-plugin-session-start idle projection did not disclose "
7198
+ "its status to the user: " + repr(idle_context)
7199
+ )
7200
+ return 1
7201
+
6886
7202
  ambiguous_repo = tmp / "ambiguous"
6887
7203
  ambiguous_repo.mkdir()
6888
7204
  for change in ("alpha", "beta"):
@@ -6899,6 +7215,7 @@ def validate_native_plugin_session_start_scenario() -> int:
6899
7215
  or not ambiguous_context
6900
7216
  or "ambiguous" not in ambiguous_context
6901
7217
  or "keel context" not in ambiguous_context
7218
+ or SESSION_START_DISCLOSURE not in ambiguous_context
6902
7219
  or "alpha#1.1" in ambiguous_context
6903
7220
  ):
6904
7221
  report(
@@ -6928,6 +7245,7 @@ def validate_native_plugin_session_start_scenario() -> int:
6928
7245
  or not missing_context
6929
7246
  or "missing or incompatible" not in missing_context
6930
7247
  or "keel context" not in missing_context
7248
+ or SESSION_START_DISCLOSURE not in missing_context
6931
7249
  ):
6932
7250
  report("native-plugin-session-start missing-CLI fallback failed.")
6933
7251
  report(repr(missing_context))
@@ -6950,6 +7268,7 @@ def validate_native_plugin_session_start_scenario() -> int:
6950
7268
  malformed_result.returncode != 0
6951
7269
  or not malformed_context
6952
7270
  or "malformed" not in malformed_context
7271
+ or SESSION_START_DISCLOSURE not in malformed_context
6953
7272
  ):
6954
7273
  report("native-plugin-session-start malformed-output fallback failed.")
6955
7274
  report(repr(malformed_context))
@@ -6975,6 +7294,7 @@ def validate_native_plugin_session_start_scenario() -> int:
6975
7294
  hang_result.returncode != 0
6976
7295
  or not hang_context
6977
7296
  or "failed or timed out" not in hang_context
7297
+ or SESSION_START_DISCLOSURE not in hang_context
6978
7298
  ):
6979
7299
  report("native-plugin-session-start timeout fallback failed.")
6980
7300
  report(repr(hang_context))
@@ -6995,6 +7315,21 @@ def validate_native_plugin_session_start_scenario() -> int:
6995
7315
  )
6996
7316
  return 1
6997
7317
 
7318
+ resident = resident_session_start_section(ROOT / "AGENTS.md")
7319
+ if resident is None:
7320
+ report(
7321
+ "native-plugin-session-start resident AGENTS.md has no Session "
7322
+ "Start section."
7323
+ )
7324
+ return 1
7325
+ for needle in RESIDENT_SESSION_START_REQUIRED:
7326
+ if needle not in resident:
7327
+ report(
7328
+ "native-plugin-session-start resident Session Start section is "
7329
+ f"missing: {needle}"
7330
+ )
7331
+ return 1
7332
+
6998
7333
  report("native-plugin-session-start scenario passed.")
6999
7334
  return 0
7000
7335
 
@@ -7024,8 +7359,11 @@ def validate_native_plugin_marketplaces_scenario() -> int:
7024
7359
  codex = shutil.which("codex")
7025
7360
  claude = claude_cli()
7026
7361
  if codex is None or claude is None:
7027
- report("native-plugin-marketplaces requires codex and claude CLIs.")
7028
- return 1
7362
+ return skip_scenario(
7363
+ "native-plugin-marketplaces",
7364
+ "requires the codex and claude CLIs, which are not installed; it "
7365
+ "probes native marketplace behavior no CI runner provides",
7366
+ )
7029
7367
 
7030
7368
  with tempfile.TemporaryDirectory(prefix="keel-native-market-") as raw_tmp:
7031
7369
  tmp = Path(raw_tmp)
@@ -7680,7 +8018,7 @@ def validate_native_runtime_projection_scenario() -> int:
7680
8018
 
7681
8019
  def projection_task(acceptance: str = "observable result") -> str:
7682
8020
  return (
7683
- "# Tasks\n\n"
8021
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
7684
8022
  "- [ ] 1.1 Project selected behavior\n"
7685
8023
  " - Owner: keel-agent\n"
7686
8024
  " - Mode: implementation\n"
@@ -8274,7 +8612,7 @@ NATIVE_GOAL_VERSION = "keel-native-goal/v1"
8274
8612
 
8275
8613
 
8276
8614
  def _goal_tasks_file(blocks: list[str]) -> str:
8277
- return "# Tasks\n\n" + "\n\n".join(blocks) + "\n"
8615
+ return "# Tasks\n\n## Invalidates\n\n- None.\n\n" + "\n\n".join(blocks) + "\n"
8278
8616
 
8279
8617
 
8280
8618
  def _goal_task_block(
@@ -8893,7 +9231,7 @@ def validate_fast_pre_push_doctor_scenario() -> int:
8893
9231
 
8894
9232
  def validate_verify_layer_tag_scenario() -> int:
8895
9233
  fixture = (
8896
- "# Tasks\n\n"
9234
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
8897
9235
  "- [ ] 1.1 Exercise the verification-layer tag\n"
8898
9236
  " - Covers:\n"
8899
9237
  " - E1: Public behavior passes.\n"
@@ -10129,8 +10467,11 @@ def validate_native_plugin_install_matrix_scenario() -> int:
10129
10467
  codex = shutil.which("codex")
10130
10468
  claude = claude_cli()
10131
10469
  if codex is None or claude is None:
10132
- report("native-plugin-install-matrix requires codex and claude CLIs.")
10133
- return 1
10470
+ return skip_scenario(
10471
+ "native-plugin-install-matrix",
10472
+ "requires the codex and claude CLIs, which are not installed; it "
10473
+ "probes native install behavior no CI runner provides",
10474
+ )
10134
10475
 
10135
10476
  expected_version = json.loads(
10136
10477
  (ROOT / "package.json").read_text(encoding="utf-8")
@@ -10283,7 +10624,7 @@ def validate_native_plugin_install_matrix_scenario() -> int:
10283
10624
  def guard_task_fixture(checked: bool = False) -> str:
10284
10625
  box = "x" if checked else " "
10285
10626
  return (
10286
- "# Tasks\n\n"
10627
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
10287
10628
  f"- [{box}] 1.1 Exercise guarded feature\n"
10288
10629
  " - Covers:\n"
10289
10630
  " - E1: Guarded public behavior passes.\n"
@@ -10383,7 +10724,7 @@ RECORD_LAYER_SPEC = (
10383
10724
  def record_layer_tasks(checked: bool = False, touch: str = "src/feature.js") -> str:
10384
10725
  box = "x" if checked else " "
10385
10726
  return (
10386
- "# Tasks\n\n"
10727
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
10387
10728
  f"- [{box}] 1.1 Exercise guarded feature\n"
10388
10729
  " - Covers:\n"
10389
10730
  " - demo-cap / Guarded behavior holds / Guarded public behavior passes\n"
@@ -10399,7 +10740,7 @@ def record_layer_tasks(checked: bool = False, touch: str = "src/feature.js") ->
10399
10740
 
10400
10741
  def mode_fixture_tasks(mode: str, touch: str) -> str:
10401
10742
  return (
10402
- "# Tasks\n\n"
10743
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
10403
10744
  "- [ ] 1.1 Establish the version baseline\n"
10404
10745
  f" - Mode: {mode}\n"
10405
10746
  " - Covers:\n"
@@ -10415,6 +10756,335 @@ def mode_fixture_tasks(mode: str, touch: str) -> str:
10415
10756
  )
10416
10757
 
10417
10758
 
10759
+ def validate_runner_skip_accounting_scenario() -> int:
10760
+ """Issue #10: the suite could not pass anywhere the native CLIs are absent.
10761
+
10762
+ Two of seventy scenarios probe native runtimes and used to `return 1` when
10763
+ the CLI was missing, so no CI runner could ever go green. A skip must be
10764
+ reported and counted, never conflated with a pass or a failure.
10765
+ """
10766
+ label = "runner-skip-accounting"
10767
+ runner = str(ROOT / "scripts/validate_plugin.py")
10768
+
10769
+ def run_registry(results: str) -> subprocess.CompletedProcess[str]:
10770
+ """Drive run_all over synthetic scenario results in a child process.
10771
+
10772
+ run_all dispatches each scenario as its own subprocess, which reads the
10773
+ real registry from disk, so a substituted registry would be ignored.
10774
+ The accounting is the behavior under test, so the process fan-out is
10775
+ replaced with fixed (name, code, output) triples instead.
10776
+ """
10777
+ program = (
10778
+ "import sys\n"
10779
+ f"src = open({runner!r}, encoding='utf-8').read()\n"
10780
+ "ns = {'__name__': 'v', '__file__': %r}\n" % runner
10781
+ + "exec(compile(src, %r, 'exec'), ns)\n" % runner
10782
+ + f"results = {results}\n"
10783
+ "ns['SCENARIOS'] = tuple((n, None) for n, _, _ in results)\n"
10784
+ "ns['run_baseline'] = lambda: 0\n"
10785
+ "ns['run_scenario_processes'] = lambda names, jobs: results\n"
10786
+ "sys.exit(ns['run_all'](2))\n"
10787
+ )
10788
+ return subprocess.run(
10789
+ [sys.executable, "-c", program],
10790
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
10791
+ cwd=str(ROOT),
10792
+ )
10793
+
10794
+ # A skipping scenario must not fail the run, must be named, and must be
10795
+ # excluded from the verified count; a failing one must still fail it.
10796
+ both = run_registry(
10797
+ "[('fake-skip', 3, 'fake-skip scenario skipped: the frob CLI\\n'),"
10798
+ " ('fake-pass', 0, 'fake-pass scenario passed.\\n')]"
10799
+ )
10800
+ out = (both.stdout or "") + (both.stderr or "")
10801
+ if both.returncode != 0:
10802
+ report(f"{label}: a skipping scenario must not fail the run.")
10803
+ report(out.strip())
10804
+ return 1
10805
+ for needle in ("fake-skip", "skipped", "the frob CLI", "plus 1 scenario"):
10806
+ if needle not in out:
10807
+ report(f"{label}: the summary must report {needle!r}; got:\n{out.strip()}")
10808
+ return 1
10809
+ if "fake-pass" in out.split("passed:")[-1]:
10810
+ report(f"{label}: a passing scenario must not be listed as skipped.")
10811
+ report(out.strip())
10812
+ return 1
10813
+
10814
+ mixed = run_registry(
10815
+ "[('fake-skip', 3, 'fake-skip scenario skipped: the frob CLI\\n'),"
10816
+ " ('fake-fail', 1, 'fake-fail scenario failed.\\n')]"
10817
+ )
10818
+ mixed_out = (mixed.stdout or "") + (mixed.stderr or "")
10819
+ if mixed.returncode == 0 or "failed for: fake-fail" not in mixed_out:
10820
+ report(
10821
+ f"{label}: a skip beside a failure must still fail the run and name "
10822
+ "the failure."
10823
+ )
10824
+ report(mixed_out.strip())
10825
+ return 1
10826
+ if "fake-skip" in mixed_out.split("failed for:")[-1]:
10827
+ report(f"{label}: a skipped scenario must not be named as a failure.")
10828
+ report(mixed_out.strip())
10829
+ return 1
10830
+
10831
+ # The two real native-runtime scenarios must take the skip path, not fail,
10832
+ # when their CLI cannot be resolved.
10833
+ for name in ("native-plugin-marketplaces", "native-plugin-install-matrix"):
10834
+ blinded = subprocess.run(
10835
+ [sys.executable, runner, "--scenario", name],
10836
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
10837
+ cwd=str(ROOT),
10838
+ env={**os.environ, "PATH": str(ROOT), "PATHEXT": ""},
10839
+ )
10840
+ blinded_out = (blinded.stdout or "") + (blinded.stderr or "")
10841
+ if blinded.returncode != 3 or "skipped" not in blinded_out:
10842
+ report(
10843
+ f"{label}: {name} must exit 3 with a reported skip when its CLI "
10844
+ f"cannot be resolved; got {blinded.returncode}."
10845
+ )
10846
+ report(blinded_out.strip())
10847
+ return 1
10848
+ if "codex" not in blinded_out:
10849
+ report(f"{label}: {name}'s skip does not name the runtime it needed.")
10850
+ report(blinded_out.strip())
10851
+ return 1
10852
+
10853
+ if label not in {name for name, _ in SCENARIOS}:
10854
+ report(f"{label}: the scenario registry does not include it.")
10855
+ return 1
10856
+ report(f"{label} scenario passed.")
10857
+ return 0
10858
+
10859
+
10860
+ def sibling_scope_tasks(sibling_checked: bool, sibling_touch: str) -> str:
10861
+ """Two tasks: 1.1 owns `shared.js`, 1.2 is the one being completed."""
10862
+
10863
+ def task(task_id: str, title: str, checked: bool, touch: str) -> str:
10864
+ mark = "x" if checked else " "
10865
+ return (
10866
+ f"- [{mark}] {task_id} {title}\n"
10867
+ " - Covers:\n"
10868
+ " - E1: public behavior\n"
10869
+ " - Touch:\n"
10870
+ + "".join(f" - {entry}\n" for entry in touch.split(","))
10871
+ + " - Verify:\n"
10872
+ " - Strategy: evidence-first\n"
10873
+ " - M1: node test.js\n"
10874
+ " - Evidence:\n"
10875
+ " - M1: verified\n"
10876
+ " - Review:\n"
10877
+ " - Status: pass\n"
10878
+ " - Acceptance check: reviewed\n"
10879
+ " - Scope check: reviewed\n"
10880
+ " - Findings: none\n"
10881
+ " - Blocker: none\n"
10882
+ )
10883
+
10884
+ return (
10885
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
10886
+ "## 1. Work\n\n"
10887
+ + task("1.1", "Own the shared file", sibling_checked, sibling_touch)
10888
+ + "\n"
10889
+ + task("1.2", "Own its own file", False, "src/mine.js")
10890
+ + "\n## Expectation Coverage\n\n- None.\n"
10891
+ )
10892
+
10893
+
10894
+ def validate_completed_sibling_attribution_scenario() -> int:
10895
+ """Issue #13 item 2: a finished task's uncommitted work blamed the next one.
10896
+
10897
+ `--base HEAD` cannot tell who wrote a path, so a sibling that already passed
10898
+ its own completion gate had its files attributed to whoever ran next. The
10899
+ workaround — commit per task — was correct but implicit, and the diagnostic
10900
+ named a file the author never touched.
10901
+ """
10902
+ label = "completed-sibling-attribution"
10903
+
10904
+ def complete(sibling_checked: bool, sibling_touch: str = "src/shared.js"):
10905
+ with tempfile.TemporaryDirectory(prefix="keel-sibling-scope-") as raw:
10906
+ repo = Path(raw)
10907
+ for name in ("src/shared.js", "src/mine.js", "src/stray.js"):
10908
+ write_text(repo / name, "// base\n")
10909
+ write_text(
10910
+ repo / "openspec/changes/demo/tasks.md",
10911
+ sibling_scope_tasks(sibling_checked, sibling_touch),
10912
+ )
10913
+ for args in (
10914
+ ["init", "--quiet"],
10915
+ ["-c", "user.email=t@e", "-c", "user.name=t", "add", "-A"],
10916
+ [
10917
+ "-c", "user.email=t@e", "-c", "user.name=t",
10918
+ "commit", "--quiet", "-m", "base",
10919
+ ],
10920
+ ):
10921
+ done = subprocess.run(
10922
+ ["git", *args], cwd=repo, capture_output=True, text=True
10923
+ )
10924
+ if done.returncode != 0:
10925
+ report(f"{label}: git {args[0]} failed: {done.stderr}")
10926
+ return None
10927
+ # The sibling's work and an undeclared stray, both uncommitted.
10928
+ write_text(repo / "src/shared.js", "// sibling's uncommitted work\n")
10929
+ write_text(repo / "src/stray.js", "// nobody declared this\n")
10930
+ result = run_keel(
10931
+ repo, "gate", "task-complete",
10932
+ "--change", "demo", "--task", "1.2", "--base", "HEAD", "--json",
10933
+ )
10934
+ return json.loads(result.stdout) if result.stdout else {}
10935
+
10936
+ owned = complete(sibling_checked=True)
10937
+ if owned is None:
10938
+ return 1
10939
+ outside = [
10940
+ item.get("message", "")
10941
+ for item in owned.get("problems", [])
10942
+ if item.get("code") == "outside-touch"
10943
+ ]
10944
+ if any("src/shared.js" in message for message in outside):
10945
+ report(
10946
+ f"{label}: a completed sibling's declared file was still attributed "
10947
+ f"to the selected task: {outside}"
10948
+ )
10949
+ return 1
10950
+ if not any("src/stray.js" in message for message in outside):
10951
+ report(
10952
+ f"{label}: a path no task declares must still fail: {outside}"
10953
+ )
10954
+ return 1
10955
+ warnings = " ".join(owned.get("warnings", []))
10956
+ if "src/shared.js" not in warnings or "1.1" not in warnings:
10957
+ report(
10958
+ f"{label}: the exclusion must be reported, naming the path and the "
10959
+ f"completed task that declares it; got {owned.get('warnings')}"
10960
+ )
10961
+ return 1
10962
+
10963
+ unchecked = complete(sibling_checked=False)
10964
+ if unchecked is None:
10965
+ return 1
10966
+ unchecked_outside = [
10967
+ item.get("message", "")
10968
+ for item in unchecked.get("problems", [])
10969
+ if item.get("code") == "outside-touch"
10970
+ ]
10971
+ if not any("src/shared.js" in message for message in unchecked_outside):
10972
+ report(
10973
+ f"{label}: an unchecked sibling's Touch must grant nothing: "
10974
+ f"{unchecked_outside}"
10975
+ )
10976
+ return 1
10977
+
10978
+ no_touch = complete(sibling_checked=True, sibling_touch="none")
10979
+ if no_touch is None:
10980
+ return 1
10981
+ none_outside = [
10982
+ item.get("message", "")
10983
+ for item in no_touch.get("problems", [])
10984
+ if item.get("code") == "outside-touch"
10985
+ ]
10986
+ if not any("src/shared.js" in message for message in none_outside):
10987
+ report(
10988
+ f"{label}: a sibling whose Touch is none must contribute no claim: "
10989
+ f"{none_outside}"
10990
+ )
10991
+ return 1
10992
+
10993
+ if label not in {name for name, _ in SCENARIOS}:
10994
+ report(f"{label}: the scenario registry does not include it.")
10995
+ return 1
10996
+ report(f"{label} scenario passed.")
10997
+ return 0
10998
+
10999
+
11000
+ def validate_resident_topic_matching_scenario() -> int:
11001
+ """Issue #15 item 1: required entries were named topics, matched as prose.
11002
+
11003
+ The bootstrap is under a line and byte budget, so its wording gets rewritten
11004
+ to fit — and every rewrite of a pinned sentence failed the check that was
11005
+ supposed to prove only that the topic was still covered.
11006
+ """
11007
+ label = "resident-topic-matching"
11008
+ source = (ROOT / "assets/bootstrap/AGENTS.md").read_text(encoding="utf-8")
11009
+ original = "Touch is the write boundary for product files;"
11010
+ if original not in source:
11011
+ report(
11012
+ f"{label}: the fixture's anchor sentence is not in the bootstrap; "
11013
+ "update this scenario alongside the wording."
11014
+ )
11015
+ return 1
11016
+
11017
+ def errors_for(text: str) -> list[str]:
11018
+ with tempfile.TemporaryDirectory(prefix="keel-resident-topic-") as raw:
11019
+ root = Path(raw)
11020
+ write_text(root / "assets/bootstrap/AGENTS.md", text)
11021
+ found: list[str] = []
11022
+ validate_resident_blocks(found, root)
11023
+ return found
11024
+
11025
+ def touch_errors(text: str) -> list[str]:
11026
+ return [item for item in errors_for(text) if "Touch" in item or "bound" in item]
11027
+
11028
+ baseline = errors_for(source)
11029
+ if baseline:
11030
+ report(f"{label}: the unmodified bootstrap must pass: {baseline}")
11031
+ return 1
11032
+
11033
+ # A rewording that keeps both concepts in one statement must pass.
11034
+ reworded = source.replace(
11035
+ original, "Touch bounds product writes, not the task's own records;"
11036
+ )
11037
+ if touch_errors(reworded):
11038
+ report(
11039
+ f"{label}: a rewording that keeps the topic was rejected: "
11040
+ f"{touch_errors(reworded)}"
11041
+ )
11042
+ return 1
11043
+
11044
+ # Deleting the statement must still fail.
11045
+ deleted = source.replace(original, "")
11046
+ if not touch_errors(deleted):
11047
+ report(f"{label}: deleting the boundary statement did not fail the check.")
11048
+ return 1
11049
+
11050
+ # Mentioning only one of the topic's words must not satisfy it.
11051
+ partial = source.replace(original, "Touch the files you declared;")
11052
+ if not touch_errors(partial):
11053
+ report(
11054
+ f"{label}: a statement mentioning only Touch, with no boundary "
11055
+ "concept, satisfied the topic."
11056
+ )
11057
+ return 1
11058
+
11059
+ # A renamed command must still fail, and be reported as a literal.
11060
+ renamed = source.replace("keel context", "keel status")
11061
+ literal_errors = [item for item in errors_for(renamed) if "keel context" in item]
11062
+ if not literal_errors:
11063
+ report(f"{label}: renaming a required command did not fail the check.")
11064
+ return 1
11065
+ if not any("literal" in item for item in literal_errors):
11066
+ report(
11067
+ f"{label}: a missing command must be reported as a missing literal, "
11068
+ f"distinguishably from a missing topic: {literal_errors}"
11069
+ )
11070
+ return 1
11071
+ if any("literal" in item for item in touch_errors(deleted)):
11072
+ report(
11073
+ f"{label}: a missing topic must not be reported as a missing "
11074
+ f"literal: {touch_errors(deleted)}"
11075
+ )
11076
+ return 1
11077
+
11078
+ if (ROOT / "assets/bootstrap/AGENTS.md").read_text(encoding="utf-8") != source:
11079
+ report(f"{label}: the shipped bootstrap was left modified.")
11080
+ return 1
11081
+ if label not in {name for name, _ in SCENARIOS}:
11082
+ report(f"{label}: the scenario registry does not include it.")
11083
+ return 1
11084
+ report(f"{label} scenario passed.")
11085
+ return 0
11086
+
11087
+
10418
11088
  def validate_repo_action_mode_scenario() -> int:
10419
11089
  """Issue #8 example 2: a repository action had no legal contract.
10420
11090
 
@@ -10893,7 +11563,7 @@ def validate_touch_write_guard_scenario() -> int:
10893
11563
 
10894
11564
  def compaction_task_fixture() -> str:
10895
11565
  return (
10896
- "# Tasks\n\n"
11566
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
10897
11567
  "- [ ] 1.1 Exercise compaction continuity\n"
10898
11568
  " - Covers:\n"
10899
11569
  " - E1: Continuity survives compaction.\n"
@@ -11603,6 +12273,33 @@ def validate_validation_runner_scenario() -> int:
11603
12273
  report("validation-runner: README does not document the parallel runner.")
11604
12274
  return 1
11605
12275
 
12276
+ # The full gate must actually run somewhere other than one author's machine:
12277
+ # a workflow drives the same single entry point on push and pull request,
12278
+ # and the release workflow keeps its own tag guard rather than becoming the
12279
+ # suite's only runner.
12280
+ workflow_path = ROOT / ".github/workflows/test.yml"
12281
+ if not workflow_path.is_file():
12282
+ report(
12283
+ "validation-runner: no .github/workflows/test.yml, so the full gate "
12284
+ "runs only in the local pre-push hook."
12285
+ )
12286
+ return 1
12287
+ workflow = workflow_path.read_text(encoding="utf-8")
12288
+ for needle in ("npm test", "npm ci", "pull_request", "push:", "ubuntu-latest"):
12289
+ if needle not in workflow:
12290
+ report(
12291
+ "validation-runner: the full-gate workflow does not declare "
12292
+ f"{needle!r}."
12293
+ )
12294
+ report(workflow)
12295
+ return 1
12296
+ publish = (ROOT / ".github/workflows/publish.yml").read_text(encoding="utf-8")
12297
+ if "does not match package.json version" not in publish:
12298
+ report(
12299
+ "validation-runner: the release workflow lost its tag/version guard."
12300
+ )
12301
+ return 1
12302
+
11606
12303
  # Behavioral: the parallel machinery preserves registry order, keeps a
11607
12304
  # passing scenario's buffered output, and fails loudly on a bad entry.
11608
12305
  ordered = run_scenario_processes(
@@ -11752,6 +12449,17 @@ SCENARIOS: tuple = (
11752
12449
  ("touch-write-guard", validate_touch_write_guard_scenario),
11753
12450
  ("touch-guard-record-layer", validate_touch_guard_record_layer_scenario),
11754
12451
  ("repo-action-mode", validate_repo_action_mode_scenario),
12452
+ ("runner-skip-accounting", validate_runner_skip_accounting_scenario),
12453
+ ("resident-topic-matching", validate_resident_topic_matching_scenario),
12454
+ ("task-start-invalidation", validate_task_start_invalidation_scenario),
12455
+ (
12456
+ "invalidation-authoring-surface",
12457
+ validate_invalidation_authoring_surface_scenario,
12458
+ ),
12459
+ (
12460
+ "completed-sibling-attribution",
12461
+ validate_completed_sibling_attribution_scenario,
12462
+ ),
11755
12463
  ("touch-guard-drift", validate_touch_guard_drift_scenario),
11756
12464
  ("touch-guard-surface", validate_touch_guard_surface_scenario),
11757
12465
  ("plugin-compaction-continuity", validate_plugin_compaction_continuity_scenario),
@@ -11868,6 +12576,19 @@ def validate_archive_overlay_hygiene(errors: list[str]) -> None:
11868
12576
  )
11869
12577
 
11870
12578
 
12579
+ # Exit code 3 means "this scenario did not run because an external runtime it
12580
+ # probes is absent". 0 is pass, 1 is fail, 2 is an unknown scenario or a usage
12581
+ # error, so conflating an unavailable runtime with either would hide both. The
12582
+ # reason is narrow on purpose: an inconvenient assertion, a hard fixture, or a
12583
+ # platform difference is a failure, never a skip.
12584
+ SKIPPED = 3
12585
+
12586
+
12587
+ def skip_scenario(label: str, reason: str) -> int:
12588
+ report(f"{label} scenario skipped: {reason}")
12589
+ return SKIPPED
12590
+
12591
+
11871
12592
  def run_baseline() -> int:
11872
12593
  errors: list[str] = []
11873
12594
  validate_manifest(errors)
@@ -11899,19 +12620,29 @@ def run_all(jobs: int) -> int:
11899
12620
  # completion, buffered output is replayed in registry order, and every
11900
12621
  # failure is named in one summary.
11901
12622
  failures = []
12623
+ skipped = []
11902
12624
  if run_baseline() != 0:
11903
12625
  failures.append("baseline")
11904
12626
  ordered = run_scenario_processes([name for name, _ in SCENARIOS], jobs)
11905
12627
  for name, code, output in ordered:
11906
12628
  sys.stdout.write(output)
11907
- if code != 0:
12629
+ if code == SKIPPED:
12630
+ skipped.append(name)
12631
+ elif code != 0:
11908
12632
  failures.append(name)
11909
12633
  if failures:
11910
12634
  report(f"validation --all failed for: {', '.join(failures)}")
11911
12635
  return 1
11912
- report(
11913
- f"validation --all passed: baseline plus {len(SCENARIOS)} scenarios."
12636
+ # The verified count excludes skips, so the number that lands in evidence is
12637
+ # the number actually run, and every skip is named with the run.
12638
+ verified = len(SCENARIOS) - len(skipped)
12639
+ summary = (
12640
+ f"validation --all passed: baseline plus {verified} "
12641
+ f"scenario{'' if verified == 1 else 's'}"
11914
12642
  )
12643
+ if skipped:
12644
+ summary += f", {len(skipped)} skipped: {', '.join(skipped)}"
12645
+ report(f"{summary}.")
11915
12646
  return 0
11916
12647
 
11917
12648