@christang/keel 5.3.7 → 5.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/bootstrap/AGENTS.md +2 -2
- package/assets/openspec/schemas/keel-spec-driven/schema.yaml +4 -1
- package/package.json +1 -1
- package/plugins/keel/.claude-plugin/plugin.json +1 -1
- package/plugins/keel/.codex-plugin/plugin.json +1 -1
- package/scripts/validate_plugin.py +381 -4
- package/src/core/gates.js +30 -7
- package/src/core/task-contract.js +15 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
<!-- keel:start version=5.3.
|
|
1
|
+
<!-- keel:start version=5.3.8 -->
|
|
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
|
|
5
|
+
- Obey the selected task capsule: `keel gate task-start` before implementing, `--record` its fingerprint to Evidence `Contract`, which `task-complete` requires, before checking complete. Touch bounds product writes; the change's own dir is exempt. On Claude a passing `task-start` guards it by default (`--no-guard` opts 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.
|
|
@@ -75,7 +75,10 @@ artifacts:
|
|
|
75
75
|
Every executable task MUST be a checkbox authored in the compact v4 form
|
|
76
76
|
from the template: Covers, Touch, Verify (Strategy plus ordered M<n>
|
|
77
77
|
public checks), and an Evidence anchor with its Contract line, per-check
|
|
78
|
-
results, semantic Review, and Blocker state.
|
|
78
|
+
results, semantic Review, and Blocker state. `task-start --record` writes
|
|
79
|
+
the compiled fingerprint into that Contract line, and task-complete
|
|
80
|
+
refuses a task that recorded none: without an anchor there is nothing for
|
|
81
|
+
completion to compare against, so the task has no drift detection. Omitted fields inherit
|
|
79
82
|
versioned keel-task-capsule/v1 defaults: Owner is the current Keel
|
|
80
83
|
agent, Mode is implementation, Read is the change artifacts plus
|
|
81
84
|
discovered repository context, Acceptance derives from the resolved
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.3.
|
|
3
|
+
"version": "5.3.8",
|
|
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.8",
|
|
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.
|
|
41
|
-
PROTOCOL_VERSION = "5.3.
|
|
40
|
+
PACKAGE_VERSION = "5.3.8"
|
|
41
|
+
PROTOCOL_VERSION = "5.3.8"
|
|
42
42
|
LEGACY_MANAGED_START = "<!-- keel:start version=2.1 -->"
|
|
43
43
|
OPENSPEC_SCHEMA_NAME = "keel-spec-driven"
|
|
44
44
|
# Mirrors KEEL_PACKAGE_NAME in scripts/install_to_repo.py, one of the two
|
|
@@ -3509,6 +3509,11 @@ def task_contract_fixture(
|
|
|
3509
3509
|
" - Stop Rules:\n"
|
|
3510
3510
|
" - Stop on failure.\n"
|
|
3511
3511
|
" - Evidence:\n"
|
|
3512
|
+
# Emitted unconditionally rather than through `evidence`, which callers
|
|
3513
|
+
# override to control the M-entries. Completion requires a recorded
|
|
3514
|
+
# fingerprint, and `--record` rewrites this line in place, so a caller
|
|
3515
|
+
# that customizes its evidence must not be able to drop the anchor.
|
|
3516
|
+
" - Contract: pending\n"
|
|
3512
3517
|
f"{evidence_lines}"
|
|
3513
3518
|
" - Review:\n"
|
|
3514
3519
|
" - Status: pending\n"
|
|
@@ -3545,7 +3550,7 @@ def invalidation_repo(root: Path, name: str, section: str | None) -> Path:
|
|
|
3545
3550
|
# A real Contract anchor, so the missing-section case can prove that a
|
|
3546
3551
|
# failing authoring gate leaves the anchor untouched rather than merely
|
|
3547
3552
|
# failing earlier for want of one.
|
|
3548
|
-
body = task_contract_fixture(
|
|
3553
|
+
body = task_contract_fixture()
|
|
3549
3554
|
body = body.replace(INVALIDATES_NONE, "" if section is None else section)
|
|
3550
3555
|
write_text(repo / "openspec/changes/demo/tasks.md", body)
|
|
3551
3556
|
return repo
|
|
@@ -3781,7 +3786,7 @@ def validate_anchor_reverification_bound_scenario() -> int:
|
|
|
3781
3786
|
repo = Path(raw_tmp) / "repo"
|
|
3782
3787
|
repo.mkdir()
|
|
3783
3788
|
live = repo / "openspec/changes/demo/tasks.md"
|
|
3784
|
-
write_text(live, task_contract_fixture(
|
|
3789
|
+
write_text(live, task_contract_fixture())
|
|
3785
3790
|
|
|
3786
3791
|
recorded = run_keel(
|
|
3787
3792
|
repo, "gate", "task-start", "--change", "demo", "--task", "1.1",
|
|
@@ -3993,6 +3998,7 @@ def validate_durable_owner_vocabulary_scenario() -> int:
|
|
|
3993
3998
|
)
|
|
3994
3999
|
)
|
|
3995
4000
|
write_text(tasks_path, fixture)
|
|
4001
|
+
record_contract_anchor(repo, "demo")
|
|
3996
4002
|
result = run_keel(
|
|
3997
4003
|
repo, "gate", "task-complete", "--change", "demo", "--task", "1.1", "--json"
|
|
3998
4004
|
)
|
|
@@ -4138,6 +4144,7 @@ def validate_regression_check_tag_scenario() -> int:
|
|
|
4138
4144
|
|
|
4139
4145
|
def complete(fixture: str) -> dict:
|
|
4140
4146
|
write_text(tasks_path, fixture)
|
|
4147
|
+
record_contract_anchor(repo, "demo")
|
|
4141
4148
|
result = run_keel(
|
|
4142
4149
|
repo, "gate", "task-complete", "--change", "demo", "--task", "1.1", "--json"
|
|
4143
4150
|
)
|
|
@@ -4654,12 +4661,39 @@ def task_capsule_compact_fixture() -> str:
|
|
|
4654
4661
|
" - Stop Rules:\n"
|
|
4655
4662
|
" - Stop on failure.\n"
|
|
4656
4663
|
" - Evidence:\n"
|
|
4664
|
+
# Completion requires a recorded fingerprint here, so the anchor line
|
|
4665
|
+
# must exist for `--record` to rewrite in place. See
|
|
4666
|
+
# record_contract_anchor.
|
|
4667
|
+
" - Contract: pending\n"
|
|
4657
4668
|
" - M1: pending\n"
|
|
4658
4669
|
" - Stop if:\n"
|
|
4659
4670
|
" - Requires files outside Touch.\n"
|
|
4660
4671
|
)
|
|
4661
4672
|
|
|
4662
4673
|
|
|
4674
|
+
def record_contract_anchor(repo: Path, change: str, task: str = "1.1") -> bool:
|
|
4675
|
+
"""Run `task-start --record` so a fixture can reach completion.
|
|
4676
|
+
|
|
4677
|
+
Completion refuses a task whose `Contract` anchor holds no compiled
|
|
4678
|
+
fingerprint (issue #30), so a scenario that only wanted to exercise
|
|
4679
|
+
task-complete still has to start the task first. That is the real loop, not
|
|
4680
|
+
a workaround: a task that was never started is not a task being completed.
|
|
4681
|
+
"""
|
|
4682
|
+
result = run_keel(
|
|
4683
|
+
repo,
|
|
4684
|
+
"gate",
|
|
4685
|
+
"task-start",
|
|
4686
|
+
"--change",
|
|
4687
|
+
change,
|
|
4688
|
+
"--task",
|
|
4689
|
+
task,
|
|
4690
|
+
"--record",
|
|
4691
|
+
"--no-guard",
|
|
4692
|
+
"--json",
|
|
4693
|
+
)
|
|
4694
|
+
return result.returncode == 0
|
|
4695
|
+
|
|
4696
|
+
|
|
4663
4697
|
def validate_non_concrete_verify_diagnostic_scenario() -> int:
|
|
4664
4698
|
"""A compact v4 task whose Verify carries an unfilled token must be told so.
|
|
4665
4699
|
|
|
@@ -5098,6 +5132,317 @@ def fill_template_slots(text: str, comments: str = "strip") -> str:
|
|
|
5098
5132
|
text = collapsed
|
|
5099
5133
|
|
|
5100
5134
|
|
|
5135
|
+
def validate_completion_requires_a_recorded_anchor_scenario() -> int:
|
|
5136
|
+
"""Issue #30: an unrecorded anchor made the drift guarantee conditional.
|
|
5137
|
+
|
|
5138
|
+
`anchoredFingerprint` returns null for a non-digest value and completion
|
|
5139
|
+
then skipped the comparison entirely, so a task with `Contract: pending`
|
|
5140
|
+
passed with zero problems. It could be implemented against one contract,
|
|
5141
|
+
have its Touch or Verify rewritten mid-flight, and complete clean — purely
|
|
5142
|
+
by never running `task-start --record`. 5.3.7 closed the inference path;
|
|
5143
|
+
this closes the explicitly named one.
|
|
5144
|
+
"""
|
|
5145
|
+
header = "# Tasks\n\n## Invalidates\n\n- None.\n\n"
|
|
5146
|
+
|
|
5147
|
+
def task(contract: str) -> str:
|
|
5148
|
+
return (
|
|
5149
|
+
"- [ ] 1.1 Exercise task contract\n"
|
|
5150
|
+
" - Covers:\n"
|
|
5151
|
+
" - E1: Public behavior passes.\n"
|
|
5152
|
+
" - Touch:\n"
|
|
5153
|
+
" - src/feature.js\n"
|
|
5154
|
+
" - Verify:\n"
|
|
5155
|
+
" - Strategy: evidence-first\n"
|
|
5156
|
+
" - M1: node test.js asserts the recorded feed status\n"
|
|
5157
|
+
" - Evidence:\n"
|
|
5158
|
+
f" - Contract: {contract}\n"
|
|
5159
|
+
" - M1: the suite passed\n"
|
|
5160
|
+
" - Review:\n"
|
|
5161
|
+
" - Status: pass\n"
|
|
5162
|
+
" - Acceptance check: behavior asserted at the interface\n"
|
|
5163
|
+
" - Scope check: only Touch files changed\n"
|
|
5164
|
+
" - Findings: none\n"
|
|
5165
|
+
" - Blocker: none\n"
|
|
5166
|
+
)
|
|
5167
|
+
|
|
5168
|
+
with tempfile.TemporaryDirectory(prefix="keel-anchor-required-") as raw:
|
|
5169
|
+
repo = Path(raw)
|
|
5170
|
+
write_text(repo / "openspec/changes/unrecorded/tasks.md", header + task("pending"))
|
|
5171
|
+
|
|
5172
|
+
def gate(change: str, stage: str) -> dict:
|
|
5173
|
+
return json.loads(
|
|
5174
|
+
run_keel(
|
|
5175
|
+
repo,
|
|
5176
|
+
"gate",
|
|
5177
|
+
stage,
|
|
5178
|
+
"--change",
|
|
5179
|
+
change,
|
|
5180
|
+
"--task",
|
|
5181
|
+
"1.1",
|
|
5182
|
+
"--json",
|
|
5183
|
+
).stdout
|
|
5184
|
+
)
|
|
5185
|
+
|
|
5186
|
+
payload = gate("unrecorded", "task-complete")
|
|
5187
|
+
if payload.get("status") == "pass":
|
|
5188
|
+
report(
|
|
5189
|
+
"completion-requires-a-recorded-anchor: an explicitly named task "
|
|
5190
|
+
"with `Contract: pending` passed task-complete, so the "
|
|
5191
|
+
"fingerprint comparison still compares nothing."
|
|
5192
|
+
)
|
|
5193
|
+
return 1
|
|
5194
|
+
named = [
|
|
5195
|
+
problem.get("message", "")
|
|
5196
|
+
for problem in payload.get("problems", [])
|
|
5197
|
+
if problem.get("code") == "missing-contract-anchor"
|
|
5198
|
+
]
|
|
5199
|
+
if not named:
|
|
5200
|
+
report(
|
|
5201
|
+
"completion-requires-a-recorded-anchor: the task did not pass, "
|
|
5202
|
+
"but no missing-contract-anchor diagnostic explained why."
|
|
5203
|
+
)
|
|
5204
|
+
for problem in payload.get("problems", []):
|
|
5205
|
+
report(f" {problem.get('code')}: {problem.get('message')}")
|
|
5206
|
+
return 1
|
|
5207
|
+
for needle in ("Contract", "--record"):
|
|
5208
|
+
if needle not in named[0]:
|
|
5209
|
+
report(
|
|
5210
|
+
"completion-requires-a-recorded-anchor: the diagnostic did "
|
|
5211
|
+
f"not name {needle}."
|
|
5212
|
+
)
|
|
5213
|
+
report(named[0])
|
|
5214
|
+
return 1
|
|
5215
|
+
# Doing what the diagnostic asks must clear it. The fingerprint is the
|
|
5216
|
+
# one this task actually compiles to, so the anchor comparison that
|
|
5217
|
+
# follows is a real comparison rather than a shape check.
|
|
5218
|
+
started = gate("unrecorded", "task-start")
|
|
5219
|
+
fingerprint = started.get("contract", {}).get("fingerprint", {}).get("value")
|
|
5220
|
+
if not fingerprint:
|
|
5221
|
+
report(
|
|
5222
|
+
"completion-requires-a-recorded-anchor: task-start returned no "
|
|
5223
|
+
"fingerprint to record."
|
|
5224
|
+
)
|
|
5225
|
+
return 1
|
|
5226
|
+
if any(
|
|
5227
|
+
problem.get("code") == "missing-contract-anchor"
|
|
5228
|
+
for problem in started.get("problems", [])
|
|
5229
|
+
):
|
|
5230
|
+
report(
|
|
5231
|
+
"completion-requires-a-recorded-anchor: task-start reported the "
|
|
5232
|
+
"missing anchor, but it runs before one can exist."
|
|
5233
|
+
)
|
|
5234
|
+
return 1
|
|
5235
|
+
write_text(
|
|
5236
|
+
repo / "openspec/changes/recorded/tasks.md",
|
|
5237
|
+
header + task(f"keel-task-capsule/v1 sha256:{fingerprint}"),
|
|
5238
|
+
)
|
|
5239
|
+
recorded = gate("recorded", "task-complete")
|
|
5240
|
+
if recorded.get("status") != "pass":
|
|
5241
|
+
report(
|
|
5242
|
+
"completion-requires-a-recorded-anchor: recording the anchor did "
|
|
5243
|
+
"not clear the refusal."
|
|
5244
|
+
)
|
|
5245
|
+
for problem in recorded.get("problems", []):
|
|
5246
|
+
report(f" {problem.get('code')}: {problem.get('message')}")
|
|
5247
|
+
return 1
|
|
5248
|
+
if "completion-requires-a-recorded-anchor" not in {name for name, _ in SCENARIOS}:
|
|
5249
|
+
report(
|
|
5250
|
+
"completion-requires-a-recorded-anchor: the scenario registry does "
|
|
5251
|
+
"not include it."
|
|
5252
|
+
)
|
|
5253
|
+
return 1
|
|
5254
|
+
report("completion-requires-a-recorded-anchor scenario passed.")
|
|
5255
|
+
return 0
|
|
5256
|
+
|
|
5257
|
+
|
|
5258
|
+
def validate_task_body_ends_at_heading_scenario() -> int:
|
|
5259
|
+
"""Issue #29: a change-level section was read as the last task's Evidence.
|
|
5260
|
+
|
|
5261
|
+
parseTasks gave a task every line up to the next task or EOF, and a `##`
|
|
5262
|
+
heading did not stop it, so `## Invalidates` and `## Expectation Coverage`
|
|
5263
|
+
landed in whichever field was open last. That made the two checks look
|
|
5264
|
+
contradictory: `invalidation-phrase` requires the searchable wording in
|
|
5265
|
+
double quotes, while the concreteness test rejects an angle-bracket slot
|
|
5266
|
+
outside inline code, so an entry quoting wording that carries one could
|
|
5267
|
+
satisfy neither. They were never in conflict — the parser only made them
|
|
5268
|
+
appear so.
|
|
5269
|
+
"""
|
|
5270
|
+
slot = "<" + "n" + ">"
|
|
5271
|
+
header = "# Tasks\n\n"
|
|
5272
|
+
|
|
5273
|
+
def task(number: str, contract: str) -> str:
|
|
5274
|
+
return (
|
|
5275
|
+
f"- [ ] {number} Exercise task contract\n"
|
|
5276
|
+
" - Covers:\n"
|
|
5277
|
+
" - E1: Public behavior passes.\n"
|
|
5278
|
+
" - Touch:\n"
|
|
5279
|
+
" - src/feature.js\n"
|
|
5280
|
+
" - Verify:\n"
|
|
5281
|
+
" - Strategy: evidence-first\n"
|
|
5282
|
+
" - M1: node test.js asserts the recorded feed status\n"
|
|
5283
|
+
" - Evidence:\n"
|
|
5284
|
+
f" - Contract: {contract}\n"
|
|
5285
|
+
" - M1: the suite passed\n"
|
|
5286
|
+
" - Review:\n"
|
|
5287
|
+
" - Status: pass\n"
|
|
5288
|
+
" - Acceptance check: behavior asserted at the interface\n"
|
|
5289
|
+
" - Scope check: only Touch files changed\n"
|
|
5290
|
+
" - Findings: none\n"
|
|
5291
|
+
" - Blocker: none\n"
|
|
5292
|
+
)
|
|
5293
|
+
|
|
5294
|
+
# The entry quotes stale wording carrying an unfilled slot, which is exactly
|
|
5295
|
+
# what issue #16 asks an invalidation to quote. A stray Contract line sits
|
|
5296
|
+
# in the trailing section to prove the anchor search stops at the heading.
|
|
5297
|
+
sections = (
|
|
5298
|
+
"\n## Invalidates\n\n"
|
|
5299
|
+
f'- I1: "an unresolved Q{slot} without an authorized fallback blocks '
|
|
5300
|
+
'implementation" — the schema prose. Updated by: 1.1\n'
|
|
5301
|
+
" - Contract: keel-task-capsule/v1 sha256:" + "b" * 64 + "\n"
|
|
5302
|
+
"\n## Expectation Coverage\n\n"
|
|
5303
|
+
f"- E1: Every Q{slot} reference resolves Covered by: 1.1\n"
|
|
5304
|
+
)
|
|
5305
|
+
body = header + task("1.1", "pending") + "\n## 2. Second group\n\n" + task(
|
|
5306
|
+
"2.1", "pending"
|
|
5307
|
+
)
|
|
5308
|
+
|
|
5309
|
+
with tempfile.TemporaryDirectory(prefix="keel-task-extent-") as raw:
|
|
5310
|
+
repo = Path(raw)
|
|
5311
|
+
write_text(repo / "openspec/changes/demo/tasks.md", body + sections)
|
|
5312
|
+
started = run_keel(
|
|
5313
|
+
repo, "gate", "task-start", "--change", "demo", "--task", "2.1", "--json"
|
|
5314
|
+
)
|
|
5315
|
+
payload = json.loads(started.stdout)
|
|
5316
|
+
problems = payload.get("problems", [])
|
|
5317
|
+
if payload.get("status") != "pass":
|
|
5318
|
+
report(
|
|
5319
|
+
"task-body-ends-at-heading: the last task did not pass task-start "
|
|
5320
|
+
"with a trailing section quoting an unfilled slot."
|
|
5321
|
+
)
|
|
5322
|
+
for problem in problems:
|
|
5323
|
+
report(f" {problem.get('code')}: {problem.get('message')}")
|
|
5324
|
+
return 1
|
|
5325
|
+
# The same run proves the phrase check is satisfied: an entry the phrase
|
|
5326
|
+
# check rejected would have produced invalidation-phrase above.
|
|
5327
|
+
malformed = body + (
|
|
5328
|
+
"\n## Invalidates\n\n"
|
|
5329
|
+
"- I1: the schema prose is stale. Updated by: 1.1\n"
|
|
5330
|
+
)
|
|
5331
|
+
write_text(repo / "openspec/changes/unquoted/tasks.md", malformed)
|
|
5332
|
+
unquoted = json.loads(
|
|
5333
|
+
run_keel(
|
|
5334
|
+
repo,
|
|
5335
|
+
"gate",
|
|
5336
|
+
"task-start",
|
|
5337
|
+
"--change",
|
|
5338
|
+
"unquoted",
|
|
5339
|
+
"--task",
|
|
5340
|
+
"2.1",
|
|
5341
|
+
"--json",
|
|
5342
|
+
).stdout
|
|
5343
|
+
)
|
|
5344
|
+
if not any(
|
|
5345
|
+
problem.get("code") == "invalidation-phrase"
|
|
5346
|
+
for problem in unquoted.get("problems", [])
|
|
5347
|
+
):
|
|
5348
|
+
report(
|
|
5349
|
+
"task-body-ends-at-heading: an unquoted invalidation entry was "
|
|
5350
|
+
"accepted, so the phrase check is no longer being satisfied by "
|
|
5351
|
+
"the quoted one."
|
|
5352
|
+
)
|
|
5353
|
+
return 1
|
|
5354
|
+
# A group heading must not be appended to the preceding task's field.
|
|
5355
|
+
first = json.loads(
|
|
5356
|
+
run_keel(
|
|
5357
|
+
repo, "gate", "task-start", "--change", "demo", "--task", "1.1", "--json"
|
|
5358
|
+
).stdout
|
|
5359
|
+
)
|
|
5360
|
+
evidence = json.dumps(first)
|
|
5361
|
+
if "Second group" in evidence:
|
|
5362
|
+
report(
|
|
5363
|
+
"task-body-ends-at-heading: the group heading leaked into the "
|
|
5364
|
+
"preceding task's fields."
|
|
5365
|
+
)
|
|
5366
|
+
return 1
|
|
5367
|
+
# --record must anchor the last task's own Contract line, not the stray
|
|
5368
|
+
# one planted in the trailing section.
|
|
5369
|
+
recorded = run_keel(
|
|
5370
|
+
repo,
|
|
5371
|
+
"gate",
|
|
5372
|
+
"task-start",
|
|
5373
|
+
"--change",
|
|
5374
|
+
"demo",
|
|
5375
|
+
"--task",
|
|
5376
|
+
"2.1",
|
|
5377
|
+
"--record",
|
|
5378
|
+
"--json",
|
|
5379
|
+
)
|
|
5380
|
+
if recorded.returncode != 0:
|
|
5381
|
+
report("task-body-ends-at-heading: --record failed on the last task.")
|
|
5382
|
+
report((recorded.stdout or recorded.stderr).strip())
|
|
5383
|
+
return 1
|
|
5384
|
+
written = (repo / "openspec/changes/demo/tasks.md").read_text(
|
|
5385
|
+
encoding="utf-8"
|
|
5386
|
+
)
|
|
5387
|
+
after_heading = written.split("## Invalidates", 1)[1]
|
|
5388
|
+
if "b" * 64 not in after_heading:
|
|
5389
|
+
report(
|
|
5390
|
+
"task-body-ends-at-heading: --record overwrote the Contract line "
|
|
5391
|
+
"planted inside the trailing section."
|
|
5392
|
+
)
|
|
5393
|
+
return 1
|
|
5394
|
+
# The extent change must move no fingerprint: an anchor that shifted
|
|
5395
|
+
# would drift every live change in every consumer repo at once. Pinned
|
|
5396
|
+
# rather than merely measured, so a future extent change cannot move one
|
|
5397
|
+
# silently. A deliberate capsule-shape change will fail here too — that
|
|
5398
|
+
# is the point; it should be looked at, not absorbed.
|
|
5399
|
+
pinned = {
|
|
5400
|
+
"1.1": "2f723a8778160a2d51cd91e34255bf19f2c654fa23cdcd7b013915727a541d17",
|
|
5401
|
+
"2.1": "5e0481362b06992d0317c91d34bbd5d6746fb9c1fee47566060f61fbba7cbf05",
|
|
5402
|
+
}
|
|
5403
|
+
plain = (
|
|
5404
|
+
"# Tasks\n\n"
|
|
5405
|
+
+ task("1.1", "pending")
|
|
5406
|
+
+ "\n## 2. Second group\n\n"
|
|
5407
|
+
+ task("2.1", "pending")
|
|
5408
|
+
+ '\n## Invalidates\n\n- I1: "the schema prose is stale here" — the '
|
|
5409
|
+
"schema. Updated by: 1.1\n\n## Expectation Coverage\n\n"
|
|
5410
|
+
"- E1: Covered by: 1.1\n"
|
|
5411
|
+
)
|
|
5412
|
+
write_text(repo / "openspec/changes/pinned/tasks.md", plain)
|
|
5413
|
+
for task_id, expected in pinned.items():
|
|
5414
|
+
payload = json.loads(
|
|
5415
|
+
run_keel(
|
|
5416
|
+
repo,
|
|
5417
|
+
"gate",
|
|
5418
|
+
"task-start",
|
|
5419
|
+
"--change",
|
|
5420
|
+
"pinned",
|
|
5421
|
+
"--task",
|
|
5422
|
+
task_id,
|
|
5423
|
+
"--json",
|
|
5424
|
+
).stdout
|
|
5425
|
+
)
|
|
5426
|
+
actual = (
|
|
5427
|
+
payload.get("contract", {}).get("fingerprint", {}).get("value")
|
|
5428
|
+
)
|
|
5429
|
+
if actual != expected:
|
|
5430
|
+
report(
|
|
5431
|
+
"task-body-ends-at-heading: the compiled fingerprint for "
|
|
5432
|
+
f"task {task_id} moved. Expected {expected}, got {actual}. "
|
|
5433
|
+
"An extent or capsule-shape change that moves an anchor "
|
|
5434
|
+
"drifts every live change in every consumer repo."
|
|
5435
|
+
)
|
|
5436
|
+
return 1
|
|
5437
|
+
if "task-body-ends-at-heading" not in {name for name, _ in SCENARIOS}:
|
|
5438
|
+
report(
|
|
5439
|
+
"task-body-ends-at-heading: the scenario registry does not include it."
|
|
5440
|
+
)
|
|
5441
|
+
return 1
|
|
5442
|
+
report("task-body-ends-at-heading scenario passed.")
|
|
5443
|
+
return 0
|
|
5444
|
+
|
|
5445
|
+
|
|
5101
5446
|
TASKS_TEMPLATE_RELATIVE = "schemas/keel-spec-driven/templates/tasks.md"
|
|
5102
5447
|
|
|
5103
5448
|
|
|
@@ -5876,6 +6221,7 @@ def tracker_owner_tasks(findings: str, closure: str) -> str:
|
|
|
5876
6221
|
" - Stop Rules:\n"
|
|
5877
6222
|
" - Stop on failure.\n"
|
|
5878
6223
|
" - Evidence:\n"
|
|
6224
|
+
" - Contract: pending\n"
|
|
5879
6225
|
" - M1: passed\n"
|
|
5880
6226
|
" - Review:\n"
|
|
5881
6227
|
" - Status: pass\n"
|
|
@@ -5910,6 +6256,7 @@ def validate_tracker_durable_owner_scenario() -> int:
|
|
|
5910
6256
|
|
|
5911
6257
|
def complete(findings: str, closure: str = "Covered by: 1.1"):
|
|
5912
6258
|
write_text(tasks, tracker_owner_tasks(findings, closure))
|
|
6259
|
+
record_contract_anchor(repo, "demo")
|
|
5913
6260
|
return run_keel(
|
|
5914
6261
|
repo, "gate", "task-complete",
|
|
5915
6262
|
"--change", "demo", "--task", "1.1", "--json",
|
|
@@ -6331,6 +6678,7 @@ def validate_task_capsule_scenario() -> int:
|
|
|
6331
6678
|
repo / "openspec/changes/demo/tasks.md",
|
|
6332
6679
|
completion_task,
|
|
6333
6680
|
)
|
|
6681
|
+
record_contract_anchor(repo, "demo")
|
|
6334
6682
|
completed = run_keel(
|
|
6335
6683
|
repo,
|
|
6336
6684
|
"gate",
|
|
@@ -7025,6 +7373,10 @@ def validate_core_gates_scenario() -> int:
|
|
|
7025
7373
|
" - Stop Rules:\n"
|
|
7026
7374
|
" - Stop on final assertion failure.\n"
|
|
7027
7375
|
" - Evidence:\n"
|
|
7376
|
+
# Read at call time, so every variant below carries the anchor
|
|
7377
|
+
# recorded once for this fixture. Evidence is not in the capsule,
|
|
7378
|
+
# so one fingerprint is correct for all of them.
|
|
7379
|
+
f" - Contract: {completion_anchor}\n"
|
|
7028
7380
|
f" - M1: {evidence}\n"
|
|
7029
7381
|
" - Review:\n"
|
|
7030
7382
|
f" - Status: {review_status}\n"
|
|
@@ -7038,6 +7390,19 @@ def validate_core_gates_scenario() -> int:
|
|
|
7038
7390
|
" - Summary\n"
|
|
7039
7391
|
)
|
|
7040
7392
|
|
|
7393
|
+
completion_anchor = "pending"
|
|
7394
|
+
write_text(completion_tasks, completion_task("pending", "pass"))
|
|
7395
|
+
if not record_contract_anchor(completion_repo, "demo"):
|
|
7396
|
+
report("core-gates scenario could not record the completion anchor.")
|
|
7397
|
+
return 1
|
|
7398
|
+
recorded_line = re.search(
|
|
7399
|
+
r"-\s*Contract:\s*(.+)", completion_tasks.read_text(encoding="utf-8")
|
|
7400
|
+
)
|
|
7401
|
+
if not recorded_line:
|
|
7402
|
+
report("core-gates scenario found no recorded Contract anchor.")
|
|
7403
|
+
return 1
|
|
7404
|
+
completion_anchor = recorded_line.group(1).strip()
|
|
7405
|
+
|
|
7041
7406
|
write_text(completion_tasks, completion_task("pending", "pass"))
|
|
7042
7407
|
missing_evidence = run_keel(
|
|
7043
7408
|
completion_repo,
|
|
@@ -7840,6 +8205,7 @@ def validate_scope_rename_attribution_scenario() -> int:
|
|
|
7840
8205
|
" - Stop Rules:\n"
|
|
7841
8206
|
" - Stop on final assertion failure.\n"
|
|
7842
8207
|
" - Evidence:\n"
|
|
8208
|
+
" - Contract: pending\n"
|
|
7843
8209
|
" - M1: passed\n"
|
|
7844
8210
|
" - Review:\n"
|
|
7845
8211
|
" - Status: pass\n"
|
|
@@ -7883,6 +8249,7 @@ def validate_scope_rename_attribution_scenario() -> int:
|
|
|
7883
8249
|
report("scope-rename scenario git mv failed:")
|
|
7884
8250
|
report((moved.stderr or moved.stdout).strip())
|
|
7885
8251
|
return 1
|
|
8252
|
+
record_contract_anchor(repo, "demo")
|
|
7886
8253
|
completed = run_keel(
|
|
7887
8254
|
repo,
|
|
7888
8255
|
"gate",
|
|
@@ -9244,6 +9611,7 @@ def validate_task_verification_strategies_scenario() -> int:
|
|
|
9244
9611
|
|
|
9245
9612
|
def run_completion(fixture: str) -> subprocess.CompletedProcess[str]:
|
|
9246
9613
|
write_text(tasks_path, fixture)
|
|
9614
|
+
record_contract_anchor(repo, "demo")
|
|
9247
9615
|
return run_keel(
|
|
9248
9616
|
repo,
|
|
9249
9617
|
"gate",
|
|
@@ -9983,6 +10351,7 @@ def _goal_task_block(
|
|
|
9983
10351
|
" - Stop Rules:",
|
|
9984
10352
|
" - stop on failure",
|
|
9985
10353
|
" - Evidence:",
|
|
10354
|
+
" - Contract: pending",
|
|
9986
10355
|
]
|
|
9987
10356
|
)
|
|
9988
10357
|
if filled:
|
|
@@ -10686,6 +11055,7 @@ def validate_native_goal_gate_order_scenario() -> int:
|
|
|
10686
11055
|
|
|
10687
11056
|
# 4. With evidence and a passing Review recorded, task-complete passes.
|
|
10688
11057
|
write_text(tasks_path, _goal_tasks_file([_goal_task_block(filled=True)]))
|
|
11058
|
+
record_contract_anchor(repo, "sample-change")
|
|
10689
11059
|
completed = run_keel(
|
|
10690
11060
|
repo, "gate", "task-complete",
|
|
10691
11061
|
"--change", "sample-change", "--task", "1.1", "--json",
|
|
@@ -11249,6 +11619,7 @@ def validate_single_task_goal_real_tasks_scenario() -> int:
|
|
|
11249
11619
|
[_goal_task_block(strategy=strategy, filled=True, redgreen=redgreen)]
|
|
11250
11620
|
),
|
|
11251
11621
|
)
|
|
11622
|
+
record_contract_anchor(repo, "sample-change")
|
|
11252
11623
|
done = run_keel(
|
|
11253
11624
|
repo, "gate", "task-complete",
|
|
11254
11625
|
"--change", "sample-change", "--task", "1.1", "--json", env=env,
|
|
@@ -11544,6 +11915,7 @@ def _single_task_matrix_target(target: str, root: Path) -> int:
|
|
|
11544
11915
|
]
|
|
11545
11916
|
),
|
|
11546
11917
|
)
|
|
11918
|
+
record_contract_anchor(repo, "sample-change")
|
|
11547
11919
|
done = run_keel(repo, "gate", "task-complete", "--change", "sample-change", "--task", "1.1", "--json", env=env)
|
|
11548
11920
|
if done.returncode != 0:
|
|
11549
11921
|
report("native-single-task-matrix %s could not complete with evidence." % target)
|
|
@@ -13768,6 +14140,11 @@ SCENARIOS: tuple = (
|
|
|
13768
14140
|
"task-complete-selection-requires-a-started-task",
|
|
13769
14141
|
validate_task_complete_selection_requires_a_started_task_scenario,
|
|
13770
14142
|
),
|
|
14143
|
+
("task-body-ends-at-heading", validate_task_body_ends_at_heading_scenario),
|
|
14144
|
+
(
|
|
14145
|
+
"completion-requires-a-recorded-anchor",
|
|
14146
|
+
validate_completion_requires_a_recorded_anchor_scenario,
|
|
14147
|
+
),
|
|
13771
14148
|
("spec-template-validates", validate_spec_template_validates_scenario),
|
|
13772
14149
|
(
|
|
13773
14150
|
"tasks-template-red-green-example",
|
package/src/core/gates.js
CHANGED
|
@@ -110,10 +110,29 @@ function loadSelection(repo, options, requireTask = true) {
|
|
|
110
110
|
// of the task they just finished. A task that has started records a fingerprint
|
|
111
111
|
// in its Evidence `Contract` anchor, so that anchor is what makes the inference
|
|
112
112
|
// safe — and without one there is nothing for completion to compare against.
|
|
113
|
+
function hasRecordedAnchor(selection, task) {
|
|
114
|
+
const plan = contractAnchorPlan(selection, task);
|
|
115
|
+
return Boolean(plan && anchoredFingerprint(plan.previous));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// A task that recorded no anchor has no drift detection at all while presenting
|
|
119
|
+
// as fully gated: completion skipped the comparison rather than reporting that
|
|
120
|
+
// it had nothing to compare. Recording is already the documented step; this is
|
|
121
|
+
// what makes the guarantee unconditional instead of aspirational.
|
|
122
|
+
function missingAnchorProblem(selection, task) {
|
|
123
|
+
if (hasRecordedAnchor(selection, task)) return null;
|
|
124
|
+
return problem(
|
|
125
|
+
"missing-contract-anchor",
|
|
126
|
+
`${selection.change}#${task.id} records no compiled fingerprint in its `
|
|
127
|
+
+ "Evidence `Contract` anchor, so completion has nothing to compare and "
|
|
128
|
+
+ "the task has no drift detection. Run `keel gate task-start --record` "
|
|
129
|
+
+ "for this task, which rewrites the anchor in place, then complete it."
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
113
133
|
function unstartedInferenceProblem(selection) {
|
|
114
134
|
const task = selection.selected[0];
|
|
115
|
-
|
|
116
|
-
if (plan && anchoredFingerprint(plan.previous)) return null;
|
|
135
|
+
if (hasRecordedAnchor(selection, task)) return null;
|
|
117
136
|
const checked = [...selection.tasks].filter((item) => item.checked).pop();
|
|
118
137
|
return problem(
|
|
119
138
|
"ambiguous-completion-selection",
|
|
@@ -131,11 +150,9 @@ function unstartedInferenceProblem(selection) {
|
|
|
131
150
|
|
|
132
151
|
function contractAnchorPlan(selection, task) {
|
|
133
152
|
const lines = selection.content.split("\n");
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
? selection.tasks[index + 1].line
|
|
138
|
-
: lines.length;
|
|
153
|
+
// The task carries its own extent, so the anchor search cannot reach a
|
|
154
|
+
// `- Contract:` line sitting in a trailing change-level section.
|
|
155
|
+
const end = task.endLine !== undefined ? task.endLine : lines.length;
|
|
139
156
|
for (let cursor = task.line; cursor < end; cursor += 1) {
|
|
140
157
|
const match = lines[cursor].match(/^(\s*)-\s*Contract:\s*(.*?)(\r?)$/);
|
|
141
158
|
if (match) {
|
|
@@ -585,6 +602,10 @@ function completionChecks(repo, task, contract = null) {
|
|
|
585
602
|
function taskComplete(repo, options) {
|
|
586
603
|
const selection = loadSelection(repo, options);
|
|
587
604
|
const task = selection.selected[0];
|
|
605
|
+
// Selection ambiguity short-circuits, because the gate does not know which
|
|
606
|
+
// task the caller meant and evaluating the wrong one is the defect. A named
|
|
607
|
+
// task is not ambiguous: its missing anchor is one problem among however many
|
|
608
|
+
// else it has, so it joins the list rather than hiding the rest.
|
|
588
609
|
if (!options.task) {
|
|
589
610
|
const ambiguous = unstartedInferenceProblem(selection);
|
|
590
611
|
if (ambiguous) {
|
|
@@ -603,6 +624,8 @@ function taskComplete(repo, options) {
|
|
|
603
624
|
const usableContract = contract.diagnostics.length === 0 ? contract : null;
|
|
604
625
|
const checks = completionChecks(repo, task, usableContract);
|
|
605
626
|
checks.problems.push(...contract.diagnostics);
|
|
627
|
+
const missingAnchor = missingAnchorProblem(selection, task);
|
|
628
|
+
if (missingAnchor) checks.problems.push(missingAnchor);
|
|
606
629
|
const scope = scopeEvidence(
|
|
607
630
|
repo,
|
|
608
631
|
task,
|
|
@@ -68,7 +68,21 @@ function parseTasks(content) {
|
|
|
68
68
|
});
|
|
69
69
|
}
|
|
70
70
|
for (let index = 0; index < tasks.length; index += 1) {
|
|
71
|
-
|
|
71
|
+
// A task body ends at the next task or the next `##` heading, whichever
|
|
72
|
+
// comes first. Without the heading bound a change-level section such as
|
|
73
|
+
// `## Invalidates` was appended to whichever field was open last — the
|
|
74
|
+
// Evidence, in every shipped template — so a token quoted there made the
|
|
75
|
+
// Evidence non-concrete and the gate blamed a task that was fine.
|
|
76
|
+
const nextTask =
|
|
77
|
+
index + 1 < tasks.length ? tasks[index + 1].line : lines.length;
|
|
78
|
+
let end = nextTask;
|
|
79
|
+
for (let cursor = tasks[index].line + 1; cursor < nextTask; cursor += 1) {
|
|
80
|
+
if (/^\s*##\s/.test(lines[cursor])) {
|
|
81
|
+
end = cursor;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
tasks[index].endLine = end;
|
|
72
86
|
const bodyLines = lines.slice(tasks[index].line, end);
|
|
73
87
|
tasks[index].body = bodyLines.join("\n");
|
|
74
88
|
tasks[index].fields = new Map();
|