@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.
- package/assets/bootstrap/AGENTS.md +1 -1
- package/assets/openspec/schemas/keel-spec-driven/schema.yaml +12 -2
- package/assets/openspec/schemas/keel-spec-driven/templates/tasks.md +16 -0
- package/package.json +1 -1
- package/plugins/keel/.claude-plugin/plugin.json +1 -1
- package/plugins/keel/.codex-plugin/plugin.json +1 -1
- package/plugins/keel/scripts/session-start.js +8 -1
- package/scripts/validate_plugin.py +342 -32
- package/src/core/gates.js +101 -1
|
@@ -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
|
|
143
|
-
|
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.3.
|
|
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.
|
|
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.
|
|
41
|
-
PROTOCOL_VERSION = "5.3.
|
|
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 = [
|
|
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) {
|