@christang/keel 5.3.0 → 5.3.1

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.
@@ -1,8 +1,8 @@
1
- <!-- keel:start version=5.3.0 -->
1
+ <!-- keel:start version=5.3.1 -->
2
2
  ## Keel Bootstrap
3
3
 
4
4
  - Start every session with `keel context`; OpenSpec artifacts and Git are the only durable authority — never native memory, goals, or transcripts.
5
- - Obey the selected task capsule: `keel gate task-start` before implementing, record its fingerprint in the task Evidence `Contract` line, and pass `keel gate task-complete` before checking complete. Touch is the write boundary; on Claude a passing `task-start` guards it by default (`--no-guard`/`keel guard clear` opt out).
5
+ - Obey the selected task capsule: `keel gate task-start` before implementing, record its fingerprint in Evidence `Contract`, and pass `keel gate task-complete` before checking complete. Touch is the write boundary for product files; on Claude a passing `task-start` guards it by default (`--no-guard`/`keel guard clear` opt out).
6
6
  - One current agent owns writes; helpers return read-only report/evidence only. No commit, sync, or archive without explicit authorization.
7
7
  - Native plugin projections (SessionStart context) are disposable views, never authority; without the plugin or hook, run the commands manually.
8
8
  - Keel skills and hooks come from the `keel` native plugin (`codex plugin add` / `claude plugin install`); `keel --init` owns only the OpenSpec schema, overlays, and this bootstrap.
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@christang/keel",
3
3
  "displayName": "Keel",
4
4
  "description": "Keel OpenSpec execution discipline CLI for Claude Code, Codex, and OpenCode.",
5
- "version": "5.3.0",
5
+ "version": "5.3.1",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keel",
3
- "version": "5.3.0",
3
+ "version": "5.3.1",
4
4
  "description": "Keel OpenSpec execution discipline: stateless continuity, task capsules, deterministic gates, and expectation alignment for Codex and Claude Code.",
5
5
  "author": {
6
6
  "name": "TanglmChris",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keel",
3
- "version": "5.3.0",
3
+ "version": "5.3.1",
4
4
  "description": "Keel OpenSpec execution discipline: stateless continuity, task capsules, deterministic gates, and expectation alignment for Codex and Claude Code.",
5
5
  "author": {
6
6
  "name": "TanglmChris",
@@ -37,8 +37,8 @@ REQUIRED_SCRIPTS = [
37
37
  "scripts/validate_plugin.py",
38
38
  ]
39
39
 
40
- PACKAGE_VERSION = "5.3.0"
41
- PROTOCOL_VERSION = "5.3.0"
40
+ PACKAGE_VERSION = "5.3.1"
41
+ PROTOCOL_VERSION = "5.3.1"
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
@@ -7024,8 +7054,11 @@ def validate_native_plugin_marketplaces_scenario() -> int:
7024
7054
  codex = shutil.which("codex")
7025
7055
  claude = claude_cli()
7026
7056
  if codex is None or claude is None:
7027
- report("native-plugin-marketplaces requires codex and claude CLIs.")
7028
- return 1
7057
+ return skip_scenario(
7058
+ "native-plugin-marketplaces",
7059
+ "requires the codex and claude CLIs, which are not installed; it "
7060
+ "probes native marketplace behavior no CI runner provides",
7061
+ )
7029
7062
 
7030
7063
  with tempfile.TemporaryDirectory(prefix="keel-native-market-") as raw_tmp:
7031
7064
  tmp = Path(raw_tmp)
@@ -10129,8 +10162,11 @@ def validate_native_plugin_install_matrix_scenario() -> int:
10129
10162
  codex = shutil.which("codex")
10130
10163
  claude = claude_cli()
10131
10164
  if codex is None or claude is None:
10132
- report("native-plugin-install-matrix requires codex and claude CLIs.")
10133
- return 1
10165
+ return skip_scenario(
10166
+ "native-plugin-install-matrix",
10167
+ "requires the codex and claude CLIs, which are not installed; it "
10168
+ "probes native install behavior no CI runner provides",
10169
+ )
10134
10170
 
10135
10171
  expected_version = json.loads(
10136
10172
  (ROOT / "package.json").read_text(encoding="utf-8")
@@ -10415,6 +10451,335 @@ def mode_fixture_tasks(mode: str, touch: str) -> str:
10415
10451
  )
10416
10452
 
10417
10453
 
10454
+ def validate_runner_skip_accounting_scenario() -> int:
10455
+ """Issue #10: the suite could not pass anywhere the native CLIs are absent.
10456
+
10457
+ Two of seventy scenarios probe native runtimes and used to `return 1` when
10458
+ the CLI was missing, so no CI runner could ever go green. A skip must be
10459
+ reported and counted, never conflated with a pass or a failure.
10460
+ """
10461
+ label = "runner-skip-accounting"
10462
+ runner = str(ROOT / "scripts/validate_plugin.py")
10463
+
10464
+ def run_registry(results: str) -> subprocess.CompletedProcess[str]:
10465
+ """Drive run_all over synthetic scenario results in a child process.
10466
+
10467
+ run_all dispatches each scenario as its own subprocess, which reads the
10468
+ real registry from disk, so a substituted registry would be ignored.
10469
+ The accounting is the behavior under test, so the process fan-out is
10470
+ replaced with fixed (name, code, output) triples instead.
10471
+ """
10472
+ program = (
10473
+ "import sys\n"
10474
+ f"src = open({runner!r}, encoding='utf-8').read()\n"
10475
+ "ns = {'__name__': 'v', '__file__': %r}\n" % runner
10476
+ + "exec(compile(src, %r, 'exec'), ns)\n" % runner
10477
+ + f"results = {results}\n"
10478
+ "ns['SCENARIOS'] = tuple((n, None) for n, _, _ in results)\n"
10479
+ "ns['run_baseline'] = lambda: 0\n"
10480
+ "ns['run_scenario_processes'] = lambda names, jobs: results\n"
10481
+ "sys.exit(ns['run_all'](2))\n"
10482
+ )
10483
+ return subprocess.run(
10484
+ [sys.executable, "-c", program],
10485
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
10486
+ cwd=str(ROOT),
10487
+ )
10488
+
10489
+ # A skipping scenario must not fail the run, must be named, and must be
10490
+ # excluded from the verified count; a failing one must still fail it.
10491
+ both = run_registry(
10492
+ "[('fake-skip', 3, 'fake-skip scenario skipped: the frob CLI\\n'),"
10493
+ " ('fake-pass', 0, 'fake-pass scenario passed.\\n')]"
10494
+ )
10495
+ out = (both.stdout or "") + (both.stderr or "")
10496
+ if both.returncode != 0:
10497
+ report(f"{label}: a skipping scenario must not fail the run.")
10498
+ report(out.strip())
10499
+ return 1
10500
+ for needle in ("fake-skip", "skipped", "the frob CLI", "plus 1 scenario"):
10501
+ if needle not in out:
10502
+ report(f"{label}: the summary must report {needle!r}; got:\n{out.strip()}")
10503
+ return 1
10504
+ if "fake-pass" in out.split("passed:")[-1]:
10505
+ report(f"{label}: a passing scenario must not be listed as skipped.")
10506
+ report(out.strip())
10507
+ return 1
10508
+
10509
+ mixed = run_registry(
10510
+ "[('fake-skip', 3, 'fake-skip scenario skipped: the frob CLI\\n'),"
10511
+ " ('fake-fail', 1, 'fake-fail scenario failed.\\n')]"
10512
+ )
10513
+ mixed_out = (mixed.stdout or "") + (mixed.stderr or "")
10514
+ if mixed.returncode == 0 or "failed for: fake-fail" not in mixed_out:
10515
+ report(
10516
+ f"{label}: a skip beside a failure must still fail the run and name "
10517
+ "the failure."
10518
+ )
10519
+ report(mixed_out.strip())
10520
+ return 1
10521
+ if "fake-skip" in mixed_out.split("failed for:")[-1]:
10522
+ report(f"{label}: a skipped scenario must not be named as a failure.")
10523
+ report(mixed_out.strip())
10524
+ return 1
10525
+
10526
+ # The two real native-runtime scenarios must take the skip path, not fail,
10527
+ # when their CLI cannot be resolved.
10528
+ for name in ("native-plugin-marketplaces", "native-plugin-install-matrix"):
10529
+ blinded = subprocess.run(
10530
+ [sys.executable, runner, "--scenario", name],
10531
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
10532
+ cwd=str(ROOT),
10533
+ env={**os.environ, "PATH": str(ROOT), "PATHEXT": ""},
10534
+ )
10535
+ blinded_out = (blinded.stdout or "") + (blinded.stderr or "")
10536
+ if blinded.returncode != 3 or "skipped" not in blinded_out:
10537
+ report(
10538
+ f"{label}: {name} must exit 3 with a reported skip when its CLI "
10539
+ f"cannot be resolved; got {blinded.returncode}."
10540
+ )
10541
+ report(blinded_out.strip())
10542
+ return 1
10543
+ if "codex" not in blinded_out:
10544
+ report(f"{label}: {name}'s skip does not name the runtime it needed.")
10545
+ report(blinded_out.strip())
10546
+ return 1
10547
+
10548
+ if label not in {name for name, _ in SCENARIOS}:
10549
+ report(f"{label}: the scenario registry does not include it.")
10550
+ return 1
10551
+ report(f"{label} scenario passed.")
10552
+ return 0
10553
+
10554
+
10555
+ def sibling_scope_tasks(sibling_checked: bool, sibling_touch: str) -> str:
10556
+ """Two tasks: 1.1 owns `shared.js`, 1.2 is the one being completed."""
10557
+
10558
+ def task(task_id: str, title: str, checked: bool, touch: str) -> str:
10559
+ mark = "x" if checked else " "
10560
+ return (
10561
+ f"- [{mark}] {task_id} {title}\n"
10562
+ " - Covers:\n"
10563
+ " - E1: public behavior\n"
10564
+ " - Touch:\n"
10565
+ + "".join(f" - {entry}\n" for entry in touch.split(","))
10566
+ + " - Verify:\n"
10567
+ " - Strategy: evidence-first\n"
10568
+ " - M1: node test.js\n"
10569
+ " - Evidence:\n"
10570
+ " - M1: verified\n"
10571
+ " - Review:\n"
10572
+ " - Status: pass\n"
10573
+ " - Acceptance check: reviewed\n"
10574
+ " - Scope check: reviewed\n"
10575
+ " - Findings: none\n"
10576
+ " - Blocker: none\n"
10577
+ )
10578
+
10579
+ return (
10580
+ "# Tasks\n\n"
10581
+ "## 1. Work\n\n"
10582
+ + task("1.1", "Own the shared file", sibling_checked, sibling_touch)
10583
+ + "\n"
10584
+ + task("1.2", "Own its own file", False, "src/mine.js")
10585
+ + "\n## Expectation Coverage\n\n- None.\n"
10586
+ )
10587
+
10588
+
10589
+ def validate_completed_sibling_attribution_scenario() -> int:
10590
+ """Issue #13 item 2: a finished task's uncommitted work blamed the next one.
10591
+
10592
+ `--base HEAD` cannot tell who wrote a path, so a sibling that already passed
10593
+ its own completion gate had its files attributed to whoever ran next. The
10594
+ workaround — commit per task — was correct but implicit, and the diagnostic
10595
+ named a file the author never touched.
10596
+ """
10597
+ label = "completed-sibling-attribution"
10598
+
10599
+ def complete(sibling_checked: bool, sibling_touch: str = "src/shared.js"):
10600
+ with tempfile.TemporaryDirectory(prefix="keel-sibling-scope-") as raw:
10601
+ repo = Path(raw)
10602
+ for name in ("src/shared.js", "src/mine.js", "src/stray.js"):
10603
+ write_text(repo / name, "// base\n")
10604
+ write_text(
10605
+ repo / "openspec/changes/demo/tasks.md",
10606
+ sibling_scope_tasks(sibling_checked, sibling_touch),
10607
+ )
10608
+ for args in (
10609
+ ["init", "--quiet"],
10610
+ ["-c", "user.email=t@e", "-c", "user.name=t", "add", "-A"],
10611
+ [
10612
+ "-c", "user.email=t@e", "-c", "user.name=t",
10613
+ "commit", "--quiet", "-m", "base",
10614
+ ],
10615
+ ):
10616
+ done = subprocess.run(
10617
+ ["git", *args], cwd=repo, capture_output=True, text=True
10618
+ )
10619
+ if done.returncode != 0:
10620
+ report(f"{label}: git {args[0]} failed: {done.stderr}")
10621
+ return None
10622
+ # The sibling's work and an undeclared stray, both uncommitted.
10623
+ write_text(repo / "src/shared.js", "// sibling's uncommitted work\n")
10624
+ write_text(repo / "src/stray.js", "// nobody declared this\n")
10625
+ result = run_keel(
10626
+ repo, "gate", "task-complete",
10627
+ "--change", "demo", "--task", "1.2", "--base", "HEAD", "--json",
10628
+ )
10629
+ return json.loads(result.stdout) if result.stdout else {}
10630
+
10631
+ owned = complete(sibling_checked=True)
10632
+ if owned is None:
10633
+ return 1
10634
+ outside = [
10635
+ item.get("message", "")
10636
+ for item in owned.get("problems", [])
10637
+ if item.get("code") == "outside-touch"
10638
+ ]
10639
+ if any("src/shared.js" in message for message in outside):
10640
+ report(
10641
+ f"{label}: a completed sibling's declared file was still attributed "
10642
+ f"to the selected task: {outside}"
10643
+ )
10644
+ return 1
10645
+ if not any("src/stray.js" in message for message in outside):
10646
+ report(
10647
+ f"{label}: a path no task declares must still fail: {outside}"
10648
+ )
10649
+ return 1
10650
+ warnings = " ".join(owned.get("warnings", []))
10651
+ if "src/shared.js" not in warnings or "1.1" not in warnings:
10652
+ report(
10653
+ f"{label}: the exclusion must be reported, naming the path and the "
10654
+ f"completed task that declares it; got {owned.get('warnings')}"
10655
+ )
10656
+ return 1
10657
+
10658
+ unchecked = complete(sibling_checked=False)
10659
+ if unchecked is None:
10660
+ return 1
10661
+ unchecked_outside = [
10662
+ item.get("message", "")
10663
+ for item in unchecked.get("problems", [])
10664
+ if item.get("code") == "outside-touch"
10665
+ ]
10666
+ if not any("src/shared.js" in message for message in unchecked_outside):
10667
+ report(
10668
+ f"{label}: an unchecked sibling's Touch must grant nothing: "
10669
+ f"{unchecked_outside}"
10670
+ )
10671
+ return 1
10672
+
10673
+ no_touch = complete(sibling_checked=True, sibling_touch="none")
10674
+ if no_touch is None:
10675
+ return 1
10676
+ none_outside = [
10677
+ item.get("message", "")
10678
+ for item in no_touch.get("problems", [])
10679
+ if item.get("code") == "outside-touch"
10680
+ ]
10681
+ if not any("src/shared.js" in message for message in none_outside):
10682
+ report(
10683
+ f"{label}: a sibling whose Touch is none must contribute no claim: "
10684
+ f"{none_outside}"
10685
+ )
10686
+ return 1
10687
+
10688
+ if label not in {name for name, _ in SCENARIOS}:
10689
+ report(f"{label}: the scenario registry does not include it.")
10690
+ return 1
10691
+ report(f"{label} scenario passed.")
10692
+ return 0
10693
+
10694
+
10695
+ def validate_resident_topic_matching_scenario() -> int:
10696
+ """Issue #15 item 1: required entries were named topics, matched as prose.
10697
+
10698
+ The bootstrap is under a line and byte budget, so its wording gets rewritten
10699
+ to fit — and every rewrite of a pinned sentence failed the check that was
10700
+ supposed to prove only that the topic was still covered.
10701
+ """
10702
+ label = "resident-topic-matching"
10703
+ source = (ROOT / "assets/bootstrap/AGENTS.md").read_text(encoding="utf-8")
10704
+ original = "Touch is the write boundary for product files;"
10705
+ if original not in source:
10706
+ report(
10707
+ f"{label}: the fixture's anchor sentence is not in the bootstrap; "
10708
+ "update this scenario alongside the wording."
10709
+ )
10710
+ return 1
10711
+
10712
+ def errors_for(text: str) -> list[str]:
10713
+ with tempfile.TemporaryDirectory(prefix="keel-resident-topic-") as raw:
10714
+ root = Path(raw)
10715
+ write_text(root / "assets/bootstrap/AGENTS.md", text)
10716
+ found: list[str] = []
10717
+ validate_resident_blocks(found, root)
10718
+ return found
10719
+
10720
+ def touch_errors(text: str) -> list[str]:
10721
+ return [item for item in errors_for(text) if "Touch" in item or "bound" in item]
10722
+
10723
+ baseline = errors_for(source)
10724
+ if baseline:
10725
+ report(f"{label}: the unmodified bootstrap must pass: {baseline}")
10726
+ return 1
10727
+
10728
+ # A rewording that keeps both concepts in one statement must pass.
10729
+ reworded = source.replace(
10730
+ original, "Touch bounds product writes, not the task's own records;"
10731
+ )
10732
+ if touch_errors(reworded):
10733
+ report(
10734
+ f"{label}: a rewording that keeps the topic was rejected: "
10735
+ f"{touch_errors(reworded)}"
10736
+ )
10737
+ return 1
10738
+
10739
+ # Deleting the statement must still fail.
10740
+ deleted = source.replace(original, "")
10741
+ if not touch_errors(deleted):
10742
+ report(f"{label}: deleting the boundary statement did not fail the check.")
10743
+ return 1
10744
+
10745
+ # Mentioning only one of the topic's words must not satisfy it.
10746
+ partial = source.replace(original, "Touch the files you declared;")
10747
+ if not touch_errors(partial):
10748
+ report(
10749
+ f"{label}: a statement mentioning only Touch, with no boundary "
10750
+ "concept, satisfied the topic."
10751
+ )
10752
+ return 1
10753
+
10754
+ # A renamed command must still fail, and be reported as a literal.
10755
+ renamed = source.replace("keel context", "keel status")
10756
+ literal_errors = [item for item in errors_for(renamed) if "keel context" in item]
10757
+ if not literal_errors:
10758
+ report(f"{label}: renaming a required command did not fail the check.")
10759
+ return 1
10760
+ if not any("literal" in item for item in literal_errors):
10761
+ report(
10762
+ f"{label}: a missing command must be reported as a missing literal, "
10763
+ f"distinguishably from a missing topic: {literal_errors}"
10764
+ )
10765
+ return 1
10766
+ if any("literal" in item for item in touch_errors(deleted)):
10767
+ report(
10768
+ f"{label}: a missing topic must not be reported as a missing "
10769
+ f"literal: {touch_errors(deleted)}"
10770
+ )
10771
+ return 1
10772
+
10773
+ if (ROOT / "assets/bootstrap/AGENTS.md").read_text(encoding="utf-8") != source:
10774
+ report(f"{label}: the shipped bootstrap was left modified.")
10775
+ return 1
10776
+ if label not in {name for name, _ in SCENARIOS}:
10777
+ report(f"{label}: the scenario registry does not include it.")
10778
+ return 1
10779
+ report(f"{label} scenario passed.")
10780
+ return 0
10781
+
10782
+
10418
10783
  def validate_repo_action_mode_scenario() -> int:
10419
10784
  """Issue #8 example 2: a repository action had no legal contract.
10420
10785
 
@@ -11603,6 +11968,33 @@ def validate_validation_runner_scenario() -> int:
11603
11968
  report("validation-runner: README does not document the parallel runner.")
11604
11969
  return 1
11605
11970
 
11971
+ # The full gate must actually run somewhere other than one author's machine:
11972
+ # a workflow drives the same single entry point on push and pull request,
11973
+ # and the release workflow keeps its own tag guard rather than becoming the
11974
+ # suite's only runner.
11975
+ workflow_path = ROOT / ".github/workflows/test.yml"
11976
+ if not workflow_path.is_file():
11977
+ report(
11978
+ "validation-runner: no .github/workflows/test.yml, so the full gate "
11979
+ "runs only in the local pre-push hook."
11980
+ )
11981
+ return 1
11982
+ workflow = workflow_path.read_text(encoding="utf-8")
11983
+ for needle in ("npm test", "npm ci", "pull_request", "push:", "ubuntu-latest"):
11984
+ if needle not in workflow:
11985
+ report(
11986
+ "validation-runner: the full-gate workflow does not declare "
11987
+ f"{needle!r}."
11988
+ )
11989
+ report(workflow)
11990
+ return 1
11991
+ publish = (ROOT / ".github/workflows/publish.yml").read_text(encoding="utf-8")
11992
+ if "does not match package.json version" not in publish:
11993
+ report(
11994
+ "validation-runner: the release workflow lost its tag/version guard."
11995
+ )
11996
+ return 1
11997
+
11606
11998
  # Behavioral: the parallel machinery preserves registry order, keeps a
11607
11999
  # passing scenario's buffered output, and fails loudly on a bad entry.
11608
12000
  ordered = run_scenario_processes(
@@ -11752,6 +12144,12 @@ SCENARIOS: tuple = (
11752
12144
  ("touch-write-guard", validate_touch_write_guard_scenario),
11753
12145
  ("touch-guard-record-layer", validate_touch_guard_record_layer_scenario),
11754
12146
  ("repo-action-mode", validate_repo_action_mode_scenario),
12147
+ ("runner-skip-accounting", validate_runner_skip_accounting_scenario),
12148
+ ("resident-topic-matching", validate_resident_topic_matching_scenario),
12149
+ (
12150
+ "completed-sibling-attribution",
12151
+ validate_completed_sibling_attribution_scenario,
12152
+ ),
11755
12153
  ("touch-guard-drift", validate_touch_guard_drift_scenario),
11756
12154
  ("touch-guard-surface", validate_touch_guard_surface_scenario),
11757
12155
  ("plugin-compaction-continuity", validate_plugin_compaction_continuity_scenario),
@@ -11868,6 +12266,19 @@ def validate_archive_overlay_hygiene(errors: list[str]) -> None:
11868
12266
  )
11869
12267
 
11870
12268
 
12269
+ # Exit code 3 means "this scenario did not run because an external runtime it
12270
+ # probes is absent". 0 is pass, 1 is fail, 2 is an unknown scenario or a usage
12271
+ # error, so conflating an unavailable runtime with either would hide both. The
12272
+ # reason is narrow on purpose: an inconvenient assertion, a hard fixture, or a
12273
+ # platform difference is a failure, never a skip.
12274
+ SKIPPED = 3
12275
+
12276
+
12277
+ def skip_scenario(label: str, reason: str) -> int:
12278
+ report(f"{label} scenario skipped: {reason}")
12279
+ return SKIPPED
12280
+
12281
+
11871
12282
  def run_baseline() -> int:
11872
12283
  errors: list[str] = []
11873
12284
  validate_manifest(errors)
@@ -11899,19 +12310,29 @@ def run_all(jobs: int) -> int:
11899
12310
  # completion, buffered output is replayed in registry order, and every
11900
12311
  # failure is named in one summary.
11901
12312
  failures = []
12313
+ skipped = []
11902
12314
  if run_baseline() != 0:
11903
12315
  failures.append("baseline")
11904
12316
  ordered = run_scenario_processes([name for name, _ in SCENARIOS], jobs)
11905
12317
  for name, code, output in ordered:
11906
12318
  sys.stdout.write(output)
11907
- if code != 0:
12319
+ if code == SKIPPED:
12320
+ skipped.append(name)
12321
+ elif code != 0:
11908
12322
  failures.append(name)
11909
12323
  if failures:
11910
12324
  report(f"validation --all failed for: {', '.join(failures)}")
11911
12325
  return 1
11912
- report(
11913
- f"validation --all passed: baseline plus {len(SCENARIOS)} scenarios."
12326
+ # The verified count excludes skips, so the number that lands in evidence is
12327
+ # the number actually run, and every skip is named with the run.
12328
+ verified = len(SCENARIOS) - len(skipped)
12329
+ summary = (
12330
+ f"validation --all passed: baseline plus {verified} "
12331
+ f"scenario{'' if verified == 1 else 's'}"
11914
12332
  )
12333
+ if skipped:
12334
+ summary += f", {len(skipped)} skipped: {', '.join(skipped)}"
12335
+ report(f"{summary}.")
11915
12336
  return 0
11916
12337
 
11917
12338
 
package/src/core/gates.js CHANGED
@@ -316,7 +316,30 @@ function pathAllowed(candidate, touch) {
316
316
  });
317
317
  }
318
318
 
319
- function scopeEvidence(repo, task, base, contract = null, change = null) {
319
+ // A base comparison shows that a path changed, never who changed it. When a
320
+ // task of the same change is already checked complete and declares the path in
321
+ // its own Touch, blaming the selected task is a guess — and the wrong one often
322
+ // enough that the per-task commit habit became an implicit requirement whose
323
+ // diagnostic named a file the author never touched. Only a checked sibling
324
+ // counts: an unchecked task's Touch is a plan, not a record.
325
+ function completedSiblingOwners(tasks, selected) {
326
+ const owners = [];
327
+ for (const item of tasks || []) {
328
+ if (item.id === selected.id || !item.checked) continue;
329
+ const declared = touchEntries(item);
330
+ if (declared.length > 0) owners.push({ id: item.id, touch: declared });
331
+ }
332
+ return owners;
333
+ }
334
+
335
+ function scopeEvidence(
336
+ repo,
337
+ task,
338
+ base,
339
+ contract = null,
340
+ change = null,
341
+ tasks = null
342
+ ) {
320
343
  const dirtyPaths = gitPaths(repo);
321
344
  if (!base) {
322
345
  return {
@@ -358,17 +381,35 @@ function scopeEvidence(repo, task, base, contract = null, change = null) {
358
381
  // attributed as outside Touch. Other changes' directories, the archive tree,
359
382
  // and the specs/schemas trees stay attributable.
360
383
  const authoringPrefix = change ? `openspec/changes/${change}/` : null;
361
- const outside = [...changed]
384
+ const owners = completedSiblingOwners(tasks, task);
385
+ const warnings = [];
386
+ const outside = [];
387
+ const candidates = [...changed]
362
388
  .map((item) => item.replace(/\\/g, "/"))
363
389
  .filter((item) => item !== "keel/guard.json")
364
390
  .filter((item) => !(authoringPrefix && item.startsWith(authoringPrefix)))
365
391
  .filter((item) => !pathAllowed(item, touch))
366
392
  .sort();
393
+ for (const item of candidates) {
394
+ const owner = owners.find((entry) => pathAllowed(item, entry.touch));
395
+ if (owner) {
396
+ // Reported, not silent: the comparison could not establish authorship,
397
+ // and resolving that in the selected task's favour is a judgment the
398
+ // author should see rather than a fact the gate discovered.
399
+ warnings.push(
400
+ `${item} is not attributed to this task: task ${owner.id} of the same `
401
+ + "change is checked complete and declares it in Touch. A base "
402
+ + "comparison cannot establish which task wrote it."
403
+ );
404
+ continue;
405
+ }
406
+ outside.push(item);
407
+ }
367
408
  return {
368
409
  problems: outside.map((item) =>
369
410
  problem("outside-touch", `Changed path is outside Touch: ${item}`)
370
411
  ),
371
- warnings: [],
412
+ warnings,
372
413
  };
373
414
  }
374
415
 
@@ -481,7 +522,8 @@ function taskComplete(repo, options) {
481
522
  task,
482
523
  options.base,
483
524
  usableContract,
484
- selection.change
525
+ selection.change,
526
+ selection.tasks
485
527
  );
486
528
  checks.problems.push(...scope.problems);
487
529
  const status =