@christang/keel 5.3.1 → 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.
@@ -1,4 +1,4 @@
1
- <!-- keel:start version=5.3.1 -->
1
+ <!-- keel:start version=5.3.3 -->
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.
@@ -139,8 +139,18 @@ artifacts:
139
139
  are `none` or carry a durable owner — a `Discard reason:`/`Discard
140
140
  rationale:` prefix, a `keel/archive/…` path, or an existing
141
141
  `openspec/changes/…` artifact; `keel/HANDOFF.md` is never the owner.
142
- tasks.md ends with a `## Expectation Coverage` section that change-close
143
- requires: one `- E<n>: <expectation> Covered by: <task ids>` line per
142
+ tasks.md carries two change-level sections beside its tasks.
143
+ `## Invalidates` is required by task-start, before any task of the
144
+ change runs: one `- I<n>: "the wording that is now wrong" — where it lives.`
145
+ line per statement this change makes stale, closed by `Updated by: <task
146
+ ids>` naming tasks of this change, a `Durable owner:`, or a `Discard
147
+ reason:`, or `- None.` when the change makes no existing statement wrong.
148
+ Quote the wording a reader would search for rather than only listing
149
+ files, because the text that goes stale is the text the author was not
150
+ already holding in mind; naming it while tasks are authored is what lets
151
+ the affected paths be declared in Touch instead of forcing a
152
+ reauthorization mid-task. `## Expectation Coverage` is required by
153
+ change-close: one `- E<n>: <expectation> Covered by: <task ids>` line per
144
154
  critical expectation (or a `Durable owner:` / `Discard reason:` closure),
145
155
  or `- None.` when the change has no critical expectations.
146
156
  requires:
@@ -76,6 +76,22 @@
76
76
  - Findings: pending
77
77
  - Blocker: none
78
78
 
79
+ ## Invalidates
80
+
81
+ <!-- task-start requires this section before any task of this change runs, so
82
+ the statements this change makes stale can be named while their paths can
83
+ still be declared in Touch instead of forcing a reauthorization later.
84
+ Each entry quotes the wording a reader would SEARCH for — not just the
85
+ files you already remembered, because the text that goes stale is the text
86
+ you were not thinking about — then says where it lives, then closes:
87
+ `Updated by: 1.1` (tasks of this change), `Durable owner: <url or path>`,
88
+ or `Discard reason: why it stands`. Use `- None.` when this change makes
89
+ no existing statement wrong.
90
+
91
+ - I1: "the exact wording that is now wrong" — where that wording lives. Updated by: 1.1
92
+ -->
93
+ - None.
94
+
79
95
  ## Expectation Coverage
80
96
 
81
97
  <!-- change-close requires this section. One line per critical expectation:
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.1",
5
+ "version": "5.3.3",
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.1",
3
+ "version": "5.3.3",
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.1",
3
+ "version": "5.3.3",
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",
@@ -15,6 +15,11 @@ const fs = require("fs");
15
15
  const path = require("path");
16
16
  const { spawnSync } = require("child_process");
17
17
 
18
+ // This text is injected into the agent and never rendered for the human, so
19
+ // without an explicit instruction the projection reaches nobody who can catch
20
+ // it being wrong. Every branch carries the same phrase, degraded ones included.
21
+ const DISCLOSURE = "to the user in your first reply";
22
+
18
23
  const TIMEOUT_MS = Number(process.env.KEEL_HOOK_TIMEOUT_MS || 8000) || 8000;
19
24
  const MAX_REASONS = 3;
20
25
  const MAX_REASON_LENGTH = 300;
@@ -51,7 +56,8 @@ function runKeel(cwd, args) {
51
56
  function fallback(reason) {
52
57
  emit(
53
58
  `Keel hook fallback: ${reason} Run \`keel context\` manually; `
54
- + "OpenSpec and Git remain the durable authority."
59
+ + "OpenSpec and Git remain the durable authority. Report this failure "
60
+ + `and that command ${DISCLOSURE}.`
55
61
  );
56
62
  }
57
63
 
@@ -145,6 +151,7 @@ function main() {
145
151
  + "does not guess among candidates."
146
152
  );
147
153
  }
154
+ lines.push(`- report this state ${DISCLOSURE}; it authorizes nothing.`);
148
155
  emit(lines.join("\n"));
149
156
  return 0;
150
157
  }
@@ -37,8 +37,8 @@ REQUIRED_SCRIPTS = [
37
37
  "scripts/validate_plugin.py",
38
38
  ]
39
39
 
40
- PACKAGE_VERSION = "5.3.1"
41
- PROTOCOL_VERSION = "5.3.1"
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")
@@ -1758,7 +1758,7 @@ def validate_authoring_continuity_scenario() -> int:
1758
1758
  change_root / "specs/demo/spec.md",
1759
1759
  "## ADDED Requirements\n",
1760
1760
  )
1761
- 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")
1762
1762
  invalid = run_keel(invalid_repo, "context", "--json")
1763
1763
  invalid_payload = json.loads(invalid.stdout)
1764
1764
  if (
@@ -2527,7 +2527,9 @@ def write_gate_fixture(repo: Path, tasks: str, design: str = "## Context\n\nfixt
2527
2527
  change = repo / "openspec/changes/demo"
2528
2528
  write_text(
2529
2529
  change / "tasks.md",
2530
- "# Tasks\n\n"
2530
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
2531
+ "## Invalidates\n\n"
2532
+ "- None.\n\n"
2531
2533
  "## Expectation Coverage\n\n"
2532
2534
  "- E1:\n"
2533
2535
  " - Covered by: 1.1\n\n"
@@ -2625,7 +2627,7 @@ def validate_cli_scenario() -> int:
2625
2627
 
2626
2628
  write_text(
2627
2629
  repo / "openspec/changes/status-drift/tasks.md",
2628
- "# Tasks\n\n"
2630
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
2629
2631
  "## Tasks\n\n"
2630
2632
  "- [x] A1 implementation **未提交**\n\n"
2631
2633
  "## Execution Status\n\n"
@@ -2648,7 +2650,7 @@ def validate_cli_scenario() -> int:
2648
2650
 
2649
2651
  write_text(
2650
2652
  repo / "openspec/changes/status-drift/tasks.md",
2651
- "# Tasks\n\n"
2653
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
2652
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"
2653
2655
  "## Tasks\n\n"
2654
2656
  "- [x] A1 implementation\n\n"
@@ -2666,7 +2668,7 @@ def validate_cli_scenario() -> int:
2666
2668
 
2667
2669
  write_text(
2668
2670
  repo / "openspec/changes/archive/2026-07-09-finished/tasks.md",
2669
- "# Tasks\n\n"
2671
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
2670
2672
  "- [x] A1 implementation\n\n"
2671
2673
  "## Evidence\n\n"
2672
2674
  "- Scope check: pre-existing dirty paths were not attributed to this task.\n",
@@ -2862,7 +2864,7 @@ def validate_stateless_continuity_scenario() -> int:
2862
2864
 
2863
2865
  write_text(
2864
2866
  repo / "openspec/changes/other/tasks.md",
2865
- "# Tasks\n\n- [ ] 1.1 Other task\n",
2867
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n- [ ] 1.1 Other task\n",
2866
2868
  )
2867
2869
  write_text(
2868
2870
  repo / "keel/HANDOFF.md",
@@ -2922,7 +2924,7 @@ def validate_stateless_continuity_scenario() -> int:
2922
2924
 
2923
2925
  write_text(
2924
2926
  tasks_path,
2925
- "# Tasks\n\n"
2927
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
2926
2928
  "- [x] 1.1 First slice\n"
2927
2929
  "- [x] 1.2 Second slice\n",
2928
2930
  )
@@ -3042,7 +3044,7 @@ def validate_stateless_continuity_scenario() -> int:
3042
3044
  stale_repo.mkdir()
3043
3045
  write_text(
3044
3046
  stale_repo / "openspec/changes/current/tasks.md",
3045
- "# Tasks\n\n- [ ] 1.1 Current task\n",
3047
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n- [ ] 1.1 Current task\n",
3046
3048
  )
3047
3049
  write_text(
3048
3050
  stale_repo / "keel/HANDOFF.md",
@@ -3067,7 +3069,7 @@ def validate_stateless_continuity_scenario() -> int:
3067
3069
 
3068
3070
  write_text(
3069
3071
  stale_repo / "openspec/changes/current/tasks.md",
3070
- "# 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",
3071
3073
  )
3072
3074
  write_text(
3073
3075
  stale_repo / "keel/HANDOFF.md",
@@ -3094,7 +3096,7 @@ def validate_stateless_continuity_scenario() -> int:
3094
3096
 
3095
3097
  write_text(
3096
3098
  stale_repo / "openspec/changes/current/tasks.md",
3097
- "# Tasks\n\n- [ ] 1.1 Current task\n",
3099
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n- [ ] 1.1 Current task\n",
3098
3100
  )
3099
3101
  write_text(
3100
3102
  stale_repo / "keel/HANDOFF.md",
@@ -3439,7 +3441,7 @@ def task_contract_fixture(
3439
3441
  command_lines = "".join(f" - {item}\n" for item in commands)
3440
3442
  evidence_lines = "".join(f" - {item}\n" for item in evidence)
3441
3443
  return (
3442
- "# Tasks\n\n"
3444
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
3443
3445
  "- [ ] 1.1 Exercise task contract\n"
3444
3446
  " - Owner: keel-agent\n"
3445
3447
  f" - Mode: {mode}\n"
@@ -3476,6 +3478,246 @@ def task_contract_fixture(
3476
3478
  )
3477
3479
 
3478
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
+
3479
3721
  def validate_task_contract_core_scenario() -> int:
3480
3722
  with tempfile.TemporaryDirectory(prefix="keel-task-contract-") as raw_tmp:
3481
3723
  repo = Path(raw_tmp)
@@ -3720,7 +3962,7 @@ def task_capsule_expanded_fixture() -> str:
3720
3962
 
3721
3963
  def task_capsule_compact_fixture() -> str:
3722
3964
  return (
3723
- "# Tasks\n\n"
3965
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
3724
3966
  "- [ ] 1.1 Exercise task contract\n"
3725
3967
  " - Covers:\n"
3726
3968
  " - E1: Public behavior passes.\n"
@@ -4263,7 +4505,7 @@ TRACKER_OWNER = "https://github.com/TanglmChris/keel/issues/12"
4263
4505
  def tracker_owner_tasks(findings: str, closure: str) -> str:
4264
4506
  """One complete, checked task plus one Expectation Coverage closure line."""
4265
4507
  return (
4266
- "# Tasks\n\n"
4508
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
4267
4509
  "## Expectation Coverage\n\n"
4268
4510
  "- E1:\n"
4269
4511
  f" - {closure}\n\n"
@@ -5167,7 +5409,7 @@ def validate_task_capsule_scenario() -> int:
5167
5409
 
5168
5410
  close_task = (
5169
5411
  completion_task
5170
- .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"
5171
5413
  "- E1:\n - Covered by: 1.1\n\n## 1. Work\n\n")
5172
5414
  .replace("- [ ] 1.1", "- [x] 1.1")
5173
5415
  )
@@ -5227,7 +5469,7 @@ def validate_core_gates_scenario() -> int:
5227
5469
  tasks_path = repo / "openspec/changes/demo/tasks.md"
5228
5470
  write_text(
5229
5471
  tasks_path,
5230
- "# Tasks\n\n"
5472
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
5231
5473
  "- [ ] 1.1 Incomplete task\n"
5232
5474
  " - Owner: keel-agent\n",
5233
5475
  )
@@ -5262,7 +5504,7 @@ def validate_core_gates_scenario() -> int:
5262
5504
 
5263
5505
  write_text(
5264
5506
  tasks_path,
5265
- "# Tasks\n\n"
5507
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
5266
5508
  "- [ ] 1.1 Complete executable task\n"
5267
5509
  " - Owner: keel-agent\n"
5268
5510
  " - Mode: implementation\n"
@@ -5412,7 +5654,7 @@ def validate_core_gates_scenario() -> int:
5412
5654
  findings: str = "none",
5413
5655
  ) -> str:
5414
5656
  return (
5415
- "# Tasks\n\n"
5657
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
5416
5658
  "- [ ] 1.1 Complete behavior\n"
5417
5659
  " - Owner: keel-agent\n"
5418
5660
  " - Mode: implementation\n"
@@ -5531,7 +5773,7 @@ def validate_core_gates_scenario() -> int:
5531
5773
  )
5532
5774
  write_text(
5533
5775
  completion_repo / "openspec/changes/follow-up/tasks.md",
5534
- "# 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",
5535
5777
  )
5536
5778
  owned_finding = run_keel(
5537
5779
  completion_repo,
@@ -5849,7 +6091,7 @@ def validate_core_gates_scenario() -> int:
5849
6091
  if extra_touch:
5850
6092
  touch += " - src/extra.js\n"
5851
6093
  return (
5852
- "# Tasks\n\n"
6094
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
5853
6095
  "- [ ] 1.1 Record behavior\n"
5854
6096
  " - Owner: keel-agent\n"
5855
6097
  " - Mode: implementation\n"
@@ -6067,7 +6309,7 @@ def validate_core_gates_scenario() -> int:
6067
6309
  def close_task(checked: bool, review_status: str = "pass") -> str:
6068
6310
  mark = "x" if checked else " "
6069
6311
  return (
6070
- "# Tasks\n\n"
6312
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
6071
6313
  "## Expectation Coverage\n\n"
6072
6314
  "- E1:\n"
6073
6315
  " - Covered by: 1.1\n\n"
@@ -6226,7 +6468,7 @@ def validate_core_gates_scenario() -> int:
6226
6468
 
6227
6469
  def validate_scope_rename_attribution_scenario() -> int:
6228
6470
  rename_task = (
6229
- "# Tasks\n\n"
6471
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
6230
6472
  "- [ ] 1.1 Complete behavior\n"
6231
6473
  " - Owner: keel-agent\n"
6232
6474
  " - Mode: implementation\n"
@@ -6429,7 +6671,7 @@ def validate_target_capability_adapters_scenario() -> int:
6429
6671
 
6430
6672
  write_text(
6431
6673
  repo / "openspec/changes/demo/tasks.md",
6432
- "# 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",
6433
6675
  )
6434
6676
  gate = run_keel(
6435
6677
  repo,
@@ -6855,6 +7097,31 @@ def session_start_context(result: subprocess.CompletedProcess[str]) -> str | Non
6855
7097
  return output.get("additionalContext")
6856
7098
 
6857
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
+
6858
7125
  def validate_native_plugin_session_start_scenario() -> int:
6859
7126
  real_cli = f'node "{ROOT / "bin/keel.js"}"'
6860
7127
  codex_event = {"hook_event_name": "SessionStart", "source": "startup"}
@@ -6902,6 +7169,7 @@ def validate_native_plugin_session_start_scenario() -> int:
6902
7169
  or "demo#1.1" not in codex_context
6903
7170
  or "task-start" not in codex_context
6904
7171
  or "disposable" not in codex_context
7172
+ or SESSION_START_DISCLOSURE not in codex_context
6905
7173
  ):
6906
7174
  report(
6907
7175
  "native-plugin-session-start ready projection lacks concise "
@@ -6913,6 +7181,24 @@ def validate_native_plugin_session_start_scenario() -> int:
6913
7181
  report("native-plugin-session-start projection overstepped.")
6914
7182
  return 1
6915
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
+
6916
7202
  ambiguous_repo = tmp / "ambiguous"
6917
7203
  ambiguous_repo.mkdir()
6918
7204
  for change in ("alpha", "beta"):
@@ -6929,6 +7215,7 @@ def validate_native_plugin_session_start_scenario() -> int:
6929
7215
  or not ambiguous_context
6930
7216
  or "ambiguous" not in ambiguous_context
6931
7217
  or "keel context" not in ambiguous_context
7218
+ or SESSION_START_DISCLOSURE not in ambiguous_context
6932
7219
  or "alpha#1.1" in ambiguous_context
6933
7220
  ):
6934
7221
  report(
@@ -6958,6 +7245,7 @@ def validate_native_plugin_session_start_scenario() -> int:
6958
7245
  or not missing_context
6959
7246
  or "missing or incompatible" not in missing_context
6960
7247
  or "keel context" not in missing_context
7248
+ or SESSION_START_DISCLOSURE not in missing_context
6961
7249
  ):
6962
7250
  report("native-plugin-session-start missing-CLI fallback failed.")
6963
7251
  report(repr(missing_context))
@@ -6980,6 +7268,7 @@ def validate_native_plugin_session_start_scenario() -> int:
6980
7268
  malformed_result.returncode != 0
6981
7269
  or not malformed_context
6982
7270
  or "malformed" not in malformed_context
7271
+ or SESSION_START_DISCLOSURE not in malformed_context
6983
7272
  ):
6984
7273
  report("native-plugin-session-start malformed-output fallback failed.")
6985
7274
  report(repr(malformed_context))
@@ -7005,6 +7294,7 @@ def validate_native_plugin_session_start_scenario() -> int:
7005
7294
  hang_result.returncode != 0
7006
7295
  or not hang_context
7007
7296
  or "failed or timed out" not in hang_context
7297
+ or SESSION_START_DISCLOSURE not in hang_context
7008
7298
  ):
7009
7299
  report("native-plugin-session-start timeout fallback failed.")
7010
7300
  report(repr(hang_context))
@@ -7025,6 +7315,21 @@ def validate_native_plugin_session_start_scenario() -> int:
7025
7315
  )
7026
7316
  return 1
7027
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
+
7028
7333
  report("native-plugin-session-start scenario passed.")
7029
7334
  return 0
7030
7335
 
@@ -7713,7 +8018,7 @@ def validate_native_runtime_projection_scenario() -> int:
7713
8018
 
7714
8019
  def projection_task(acceptance: str = "observable result") -> str:
7715
8020
  return (
7716
- "# Tasks\n\n"
8021
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
7717
8022
  "- [ ] 1.1 Project selected behavior\n"
7718
8023
  " - Owner: keel-agent\n"
7719
8024
  " - Mode: implementation\n"
@@ -8307,7 +8612,7 @@ NATIVE_GOAL_VERSION = "keel-native-goal/v1"
8307
8612
 
8308
8613
 
8309
8614
  def _goal_tasks_file(blocks: list[str]) -> str:
8310
- 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"
8311
8616
 
8312
8617
 
8313
8618
  def _goal_task_block(
@@ -8926,7 +9231,7 @@ def validate_fast_pre_push_doctor_scenario() -> int:
8926
9231
 
8927
9232
  def validate_verify_layer_tag_scenario() -> int:
8928
9233
  fixture = (
8929
- "# Tasks\n\n"
9234
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
8930
9235
  "- [ ] 1.1 Exercise the verification-layer tag\n"
8931
9236
  " - Covers:\n"
8932
9237
  " - E1: Public behavior passes.\n"
@@ -10319,7 +10624,7 @@ def validate_native_plugin_install_matrix_scenario() -> int:
10319
10624
  def guard_task_fixture(checked: bool = False) -> str:
10320
10625
  box = "x" if checked else " "
10321
10626
  return (
10322
- "# Tasks\n\n"
10627
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
10323
10628
  f"- [{box}] 1.1 Exercise guarded feature\n"
10324
10629
  " - Covers:\n"
10325
10630
  " - E1: Guarded public behavior passes.\n"
@@ -10419,7 +10724,7 @@ RECORD_LAYER_SPEC = (
10419
10724
  def record_layer_tasks(checked: bool = False, touch: str = "src/feature.js") -> str:
10420
10725
  box = "x" if checked else " "
10421
10726
  return (
10422
- "# Tasks\n\n"
10727
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
10423
10728
  f"- [{box}] 1.1 Exercise guarded feature\n"
10424
10729
  " - Covers:\n"
10425
10730
  " - demo-cap / Guarded behavior holds / Guarded public behavior passes\n"
@@ -10435,7 +10740,7 @@ def record_layer_tasks(checked: bool = False, touch: str = "src/feature.js") ->
10435
10740
 
10436
10741
  def mode_fixture_tasks(mode: str, touch: str) -> str:
10437
10742
  return (
10438
- "# Tasks\n\n"
10743
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
10439
10744
  "- [ ] 1.1 Establish the version baseline\n"
10440
10745
  f" - Mode: {mode}\n"
10441
10746
  " - Covers:\n"
@@ -10577,7 +10882,7 @@ def sibling_scope_tasks(sibling_checked: bool, sibling_touch: str) -> str:
10577
10882
  )
10578
10883
 
10579
10884
  return (
10580
- "# Tasks\n\n"
10885
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
10581
10886
  "## 1. Work\n\n"
10582
10887
  + task("1.1", "Own the shared file", sibling_checked, sibling_touch)
10583
10888
  + "\n"
@@ -11258,7 +11563,7 @@ def validate_touch_write_guard_scenario() -> int:
11258
11563
 
11259
11564
  def compaction_task_fixture() -> str:
11260
11565
  return (
11261
- "# Tasks\n\n"
11566
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
11262
11567
  "- [ ] 1.1 Exercise compaction continuity\n"
11263
11568
  " - Covers:\n"
11264
11569
  " - E1: Continuity survives compaction.\n"
@@ -12146,6 +12451,11 @@ SCENARIOS: tuple = (
12146
12451
  ("repo-action-mode", validate_repo_action_mode_scenario),
12147
12452
  ("runner-skip-accounting", validate_runner_skip_accounting_scenario),
12148
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
+ ),
12149
12459
  (
12150
12460
  "completed-sibling-attribution",
12151
12461
  validate_completed_sibling_attribution_scenario,
package/src/core/gates.js CHANGED
@@ -134,7 +134,10 @@ function taskStart(repo, options) {
134
134
  const selection = loadSelection(repo, options);
135
135
  const task = selection.selected[0];
136
136
  const compiled = compileTaskContract(repo, selection.change, task);
137
- const problems = [...compiled.diagnostics];
137
+ const problems = [
138
+ ...compiled.diagnostics,
139
+ ...invalidationProblems(selection.content, selection.tasks),
140
+ ];
138
141
  // Recording the current fingerprint is idempotent: --record replaces the
139
142
  // selected task's Contract anchor whatever it holds, so reauthorizing a task
140
143
  // whose authority changed — the path the guard's own drift messages direct
@@ -543,6 +546,103 @@ function taskComplete(repo, options) {
543
546
  );
544
547
  }
545
548
 
549
+ // Follow-up Ownership governs work a change left undone. This is the opposite
550
+ // shape: statements left standing by work the change completed. It is asked at
551
+ // task-start rather than change-close because the whole value is that the
552
+ // affected paths enter Touch before implementation — asking at the close finds
553
+ // the same facts after the reauthorization it was meant to prevent.
554
+ //
555
+ // A location list is refused on purpose. The text that goes stale is the text
556
+ // the author was not already holding in mind, so a list of remembered files
557
+ // reproduces the failure; a searchable phrase is what turns the declaration
558
+ // into a grep. What the phrase says is the agent's judgment, not the gate's.
559
+ function invalidationProblems(content, tasks) {
560
+ const heading = content.search(/^## Invalidates\s*$/m);
561
+ if (heading < 0) {
562
+ return [
563
+ problem(
564
+ "invalidation-declaration",
565
+ "tasks.md requires a `## Invalidates` section before its tasks are "
566
+ + "executable: one `- I<n>: \"searchable phrase\" — where it lives. "
567
+ + "Updated by: <task ids>` line per statement this change makes "
568
+ + "stale, using `Durable owner:` or `Discard reason:` instead when "
569
+ + "no task of this change updates it, or `- None.`."
570
+ ),
571
+ ];
572
+ }
573
+ const bodyStart = content.indexOf("\n", heading);
574
+ const remainder = bodyStart < 0 ? "" : content.slice(bodyStart + 1);
575
+ const nextHeading = remainder.search(/^##\s+/m);
576
+ const section = nextHeading < 0 ? remainder : remainder.slice(0, nextHeading);
577
+ if (/^\s*-\s+None\.?\s*$/im.test(section)) return [];
578
+ const entries = [
579
+ ...section.matchAll(
580
+ /^\s*-\s+(I\d+)\s*:\s*([\s\S]*?)(?=^\s*-\s+I\d+\s*:|(?![\s\S]))/gm
581
+ ),
582
+ ];
583
+ if (entries.length === 0) {
584
+ return [
585
+ problem(
586
+ "invalidation-declaration",
587
+ "Invalidates must declare each `I<n>` entry — a quoted searchable "
588
+ + "phrase, where it lives, and a closure (`Updated by:`, "
589
+ + "`Durable owner:`, or `Discard reason:`) — or `- None.`."
590
+ ),
591
+ ];
592
+ }
593
+ const problems = [];
594
+ for (const entry of entries) {
595
+ const [, id, body] = entry;
596
+ if (!/"[^"\n]{3,}"/.test(body)) {
597
+ problems.push(
598
+ problem(
599
+ "invalidation-phrase",
600
+ `${id} names where to look but not what to look for. Quote the `
601
+ + "wording a reader would search for, so the entry is a search "
602
+ + "rather than a reminder."
603
+ )
604
+ );
605
+ continue;
606
+ }
607
+ const updated = body.match(/Updated by:\s*([0-9.,\s-]+)/i);
608
+ const declaredOwner = body.match(/Durable owner:\s*(\S[^\n]*)/i);
609
+ const hasDurableOwner = Boolean(
610
+ declaredOwner
611
+ && (/^openspec\/changes\//i.test(declaredOwner[1].trim())
612
+ || SHARED_DURABLE_OWNER_FORMS.test(declaredOwner[1]))
613
+ );
614
+ const discarded = /Discard(?:ed)? (?:reason|rationale):\s*\S/i.test(body);
615
+ if (!updated && !hasDurableOwner && !discarded) {
616
+ problems.push(
617
+ problem(
618
+ "invalidation-closure",
619
+ `${id} lacks an updating task, a durable owner, or a discard `
620
+ + "rationale."
621
+ )
622
+ );
623
+ continue;
624
+ }
625
+ // Deliberately weaker than Expectation Coverage, which requires a checked
626
+ // task: at authoring time the updater has not run yet, so existence is the
627
+ // only honest assertion. Completion is then structural — the named task
628
+ // carries the paths in its Touch and passes its own gate.
629
+ if (updated) {
630
+ const ids = updated[1].match(/\d+(?:\.\d+)+/g) || [];
631
+ for (const taskId of ids) {
632
+ if (!tasks.some((task) => task.id === taskId)) {
633
+ problems.push(
634
+ problem(
635
+ "invalidation-owner",
636
+ `${id} names task ${taskId}, which this change does not define.`
637
+ )
638
+ );
639
+ }
640
+ }
641
+ }
642
+ }
643
+ return problems;
644
+ }
645
+
546
646
  function expectationProblems(content, tasks) {
547
647
  const heading = content.search(/^## Expectation Coverage\s*$/m);
548
648
  if (heading < 0) {