@christang/keel 5.3.6 → 5.3.7

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.6 -->
1
+ <!-- keel:start version=5.3.7 -->
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.
@@ -108,8 +108,11 @@ artifacts:
108
108
  Accepted hidden-knowledge assumptions from risk-triggered deep
109
109
  alignment or a domain lens are critical expectations when they
110
110
  affect completion definition or execution boundaries.
111
- Covers references the relevant D/F/A/Q identifiers. An unresolved Q<n>
112
- without an authorized fallback blocks implementation.
111
+ Covers references the relevant D/F/A/Q identifiers. A Q<n> that opens a
112
+ Covers entry is an open question and blocks implementation without an
113
+ authorized fallback; the same identifier named inside an entry whose
114
+ subject is a fact is a citation and does not block, so a resolved question
115
+ can be cited beside the fact that closed it.
113
116
  A new or materially expanded dedicated skill remains non-executable until
114
117
  design or task evidence identifies authoritative sources, provenance and license
115
118
  implications, realistic positive and negative trigger cases, and
@@ -1,7 +1,9 @@
1
1
  ## ADDED Requirements
2
2
 
3
3
  ### Requirement: <!-- requirement name -->
4
- <!-- requirement text -->
4
+ <!-- The requirement text MUST contain SHALL or MUST; validation refuses a
5
+ requirement without one, so keep the modal verb when filling this in. -->
6
+ The system SHALL <!-- observable behavior -->
5
7
 
6
8
  #### Scenario: <!-- scenario name -->
7
9
  - **WHEN** <!-- condition -->
@@ -11,7 +11,7 @@
11
11
 
12
12
  - [ ] 1.1 <!-- Task description -->
13
13
  - Covers:
14
- - <source expectation: spec scenario as `capability / requirement or scenario heading`, hidden-knowledge assumption, domain lens requirement, or critical D<n>/F<n>/A<n>/Q<n>; an unresolved Q<n> requires an authorized fallback>
14
+ - <source expectation: spec scenario as `capability / requirement or scenario heading`, hidden-knowledge assumption, domain lens requirement, or critical D<n>/F<n>/A<n>/Q<n>; a Q<n> that OPENS an entry is an open question and requires an authorized fallback, while one named inside an entry about a fact is a citation and does not>
15
15
  - Touch:
16
16
  - <path>
17
17
  - Verify:
@@ -82,6 +82,36 @@
82
82
  - Findings: pending
83
83
  - Blocker: none
84
84
 
85
+ ## 3. <!-- Task Group Name -->
86
+
87
+ - [ ] 3.1 <!-- Red-green example: a vertical-tdd task and the Evidence it must record -->
88
+ - Covers:
89
+ - <source expectation whose observable behavior this slice proves>
90
+ - Touch:
91
+ - <path>
92
+ - Verify:
93
+ <!-- The untagged check below is load-bearing: a red-green strategy whose
94
+ every check is tagged (regression) is refused as regression-only,
95
+ because a regression check has no honest red to record. -->
96
+ - Strategy: vertical-tdd
97
+ - M1: <public behavior check for the new behavior>
98
+ - M2 (regression): <check asserting behavior that is already green stays green>
99
+ - Evidence:
100
+ <!-- M1 is red-green, so it records THREE entries: the bare M1 plus M1.red
101
+ and M1.green. M2 is tagged regression, so it records only its bare
102
+ entry — it is exempt from .red/.green, not from Evidence. -->
103
+ - Contract: pending
104
+ - M1: pending
105
+ - M1.red: pending
106
+ - M1.green: pending
107
+ - M2: pending
108
+ - Review:
109
+ - Status: pending
110
+ - Acceptance check: pending
111
+ - Scope check: pending
112
+ - Findings: pending
113
+ - Blocker: none
114
+
85
115
  ## Invalidates
86
116
 
87
117
  <!-- task-start requires this section before any task of this change runs, so
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.6",
5
+ "version": "5.3.7",
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.6",
3
+ "version": "5.3.7",
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.6",
3
+ "version": "5.3.7",
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.6"
41
- PROTOCOL_VERSION = "5.3.6"
40
+ PACKAGE_VERSION = "5.3.7"
41
+ PROTOCOL_VERSION = "5.3.7"
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
@@ -1307,7 +1307,11 @@ def validate_expectation_slice_gates_scenario() -> int:
1307
1307
  "Covers:",
1308
1308
  "source expectation",
1309
1309
  "D<n>/F<n>/A<n>/Q<n>",
1310
- "unresolved Q<n>",
1310
+ # The rule is no longer "an unresolved Q<n> requires a fallback"
1311
+ # unconditionally: the identifier blocks only where it opens the entry,
1312
+ # so a resolved question can be cited beside the fact that closed it.
1313
+ "OPENS an entry",
1314
+ "requires an authorized fallback",
1311
1315
  ]
1312
1316
  design_template_snippets = [
1313
1317
  "D<n>",
@@ -4729,8 +4733,10 @@ def validate_non_concrete_verify_diagnostic_scenario() -> int:
4729
4733
  )
4730
4734
  report(started.stdout.strip())
4731
4735
  return 1
4732
- # A task with no Verify at all is a genuine expanded v3 task and must
4733
- # keep its existing required-field diagnostics.
4736
+ # A task with no Verify at all must not be reported as carrying a token
4737
+ # it never wrote. What it is reported as instead — one missing
4738
+ # verification form rather than the expanded v3 set — belongs to the
4739
+ # absent-verification-form-is-one-problem scenario.
4734
4740
  bare = task_capsule_compact_fixture()
4735
4741
  for block in (
4736
4742
  " - Verify:\n - Strategy: evidence-first\n - M1: node test.js\n",
@@ -5049,6 +5055,646 @@ def validate_unresolved_authority_names_field_scenario() -> int:
5049
5055
  return 0
5050
5056
 
5051
5057
 
5058
+ SPEC_TEMPLATE_RELATIVE = "schemas/keel-spec-driven/templates/spec.md"
5059
+
5060
+
5061
+ SLOT_FILLER = "the recorded feed status"
5062
+ SLOT_VOCABULARY = {"<strategy>": "evidence-first"}
5063
+
5064
+
5065
+ def fill_template_slots(text: str, comments: str = "strip") -> str:
5066
+ """Fill a shipped template's author-facing slots the way an author would.
5067
+
5068
+ Deliberately mechanical, so a slot added to the template later is handled
5069
+ without touching the scenario: what is asserted is the template's structure
5070
+ rather than a hand-maintained copy of it.
5071
+
5072
+ The two templates use HTML comments for different jobs, so the caller says
5073
+ which. In the spec template a comment *is* the slot — the requirement name
5074
+ and body are both comments — so it is replaced. In the tasks template
5075
+ comments are instructions to the author sitting on their own lines, and the
5076
+ slots are angle-bracket runs, so the comments are stripped.
5077
+ """
5078
+ if comments == "replace":
5079
+ text = re.sub(r"<!--[\s\S]*?-->", SLOT_FILLER, text)
5080
+ else:
5081
+ # An own-line comment is an instruction to the author, so it goes. A
5082
+ # comment with text before it on the line is that line's slot — a task
5083
+ # title, a group name — and stripping it leaves a task line the parser
5084
+ # cannot read, so it is filled like any other slot.
5085
+ text = re.sub(r"^[ \t]*<!--[\s\S]*?-->[ \t]*\r?\n", "", text, flags=re.M)
5086
+ text = re.sub(r"<!--[\s\S]*?-->", SLOT_FILLER, text)
5087
+ # A slot whose value comes from a fixed vocabulary needs a member of it, so
5088
+ # those are named. Everything else takes the generic filler.
5089
+ for slot, value in SLOT_VOCABULARY.items():
5090
+ text = text.replace(slot, value)
5091
+ # Innermost-first to a fixed point: a slot may quote an identifier shape such
5092
+ # as `Q<n>`, and the inner brackets would otherwise block the outer match and
5093
+ # leave the whole slot unfilled.
5094
+ while True:
5095
+ collapsed = re.sub(r"<[^<>\n]*>", SLOT_FILLER, text)
5096
+ if collapsed == text:
5097
+ return text
5098
+ text = collapsed
5099
+
5100
+
5101
+ TASKS_TEMPLATE_RELATIVE = "schemas/keel-spec-driven/templates/tasks.md"
5102
+
5103
+
5104
+ def validate_tasks_template_red_green_example_scenario() -> int:
5105
+ """Issue #28 items 2 and 3: the red-green shape was described, never shown.
5106
+
5107
+ The template's prose has said since 5.3.4 that the `.red`/`.green` entries
5108
+ come in addition to the bare `M<n>` entry, but it showed only the flat form.
5109
+ The reporter tried annotated labels, was refused, landed on the flat form,
5110
+ and was refused again for the missing bare entry. One worked example closes
5111
+ both attempts — and it is asserted by gating it, so it cannot drift from the
5112
+ rule it illustrates.
5113
+ """
5114
+ shipped = ROOT / "openspec" / TASKS_TEMPLATE_RELATIVE
5115
+ packaged = ROOT / "assets" / "openspec" / TASKS_TEMPLATE_RELATIVE
5116
+ for path in (shipped, packaged):
5117
+ if not path.is_file():
5118
+ report(f"tasks-template-red-green-example: missing template {path}.")
5119
+ return 1
5120
+ if shipped.read_bytes() != packaged.read_bytes():
5121
+ report(
5122
+ "tasks-template-red-green-example: the two shipped copies of the "
5123
+ "tasks template have diverged."
5124
+ )
5125
+ return 1
5126
+
5127
+ source = shipped.read_text(encoding="utf-8")
5128
+ red_green = [
5129
+ block
5130
+ for block in re.split(r"^## ", source, flags=re.MULTILINE)
5131
+ if re.search(r"^\s*-\s*Strategy:\s*(vertical-tdd|regression-first)", block, re.M)
5132
+ ]
5133
+ if not red_green:
5134
+ report(
5135
+ "tasks-template-red-green-example: the template defines no task "
5136
+ "group with a red-green strategy, so it still only describes one."
5137
+ )
5138
+ return 1
5139
+ group = red_green[0]
5140
+ untagged = re.findall(r"^\s*-\s*(M[1-9]\d*):\s*(?!pending)", group, re.M)
5141
+ tagged = re.findall(r"^\s*-\s*(M[1-9]\d*)\s*\([^)]*regression[^)]*\):", group, re.M)
5142
+ if not untagged:
5143
+ report(
5144
+ "tasks-template-red-green-example: the red-green group has no "
5145
+ "untagged check, which task-start refuses as regression-only."
5146
+ )
5147
+ return 1
5148
+ if not tagged:
5149
+ report(
5150
+ "tasks-template-red-green-example: the red-green group shows no "
5151
+ "(regression)-tagged check, so the exemption is still unillustrated."
5152
+ )
5153
+ return 1
5154
+ label = untagged[0]
5155
+ for suffix in ("", ".red", ".green"):
5156
+ if not re.search(rf"^\s*-\s*{label}{re.escape(suffix)}:", group, re.M):
5157
+ report(
5158
+ "tasks-template-red-green-example: the untagged check is missing "
5159
+ f"its `{label}{suffix}` Evidence entry."
5160
+ )
5161
+ return 1
5162
+ for suffix in (".red", ".green"):
5163
+ if re.search(rf"^\s*-\s*{tagged[0]}{re.escape(suffix)}:", group, re.M):
5164
+ report(
5165
+ "tasks-template-red-green-example: the (regression)-tagged check "
5166
+ f"carries a `{suffix}` entry it is exempt from."
5167
+ )
5168
+ return 1
5169
+
5170
+ # Gating the filled template is what keeps the example from drifting from
5171
+ # the rule it illustrates.
5172
+ filled = fill_template_slots(source)
5173
+ with tempfile.TemporaryDirectory(prefix="keel-tasks-template-") as raw:
5174
+ repo = Path(raw)
5175
+ write_text(repo / "openspec/changes/from-template/tasks.md", filled)
5176
+ ids = re.findall(r"^\s*-\s*\[[ xX]\]\s+(\d+(?:\.\d+)+)\s", filled, re.M)
5177
+ if not ids:
5178
+ report(
5179
+ "tasks-template-red-green-example: the filled template defines "
5180
+ "no task the gate can read."
5181
+ )
5182
+ return 1
5183
+ for task_id in ids:
5184
+ result = run_keel(
5185
+ repo,
5186
+ "gate",
5187
+ "task-start",
5188
+ "--change",
5189
+ "from-template",
5190
+ "--task",
5191
+ task_id,
5192
+ "--json",
5193
+ )
5194
+ payload = json.loads(result.stdout)
5195
+ if payload.get("status") != "pass":
5196
+ report(
5197
+ "tasks-template-red-green-example: task "
5198
+ f"{task_id} written from the shipped template did not pass "
5199
+ "task-start."
5200
+ )
5201
+ for problem in payload.get("problems", []):
5202
+ report(f" {problem.get('code')}: {problem.get('message')}")
5203
+ return 1
5204
+ if "tasks-template-red-green-example" not in {name for name, _ in SCENARIOS}:
5205
+ report(
5206
+ "tasks-template-red-green-example: the scenario registry does not "
5207
+ "include it."
5208
+ )
5209
+ return 1
5210
+ report("tasks-template-red-green-example scenario passed.")
5211
+ return 0
5212
+
5213
+
5214
+ def validate_spec_template_validates_scenario() -> int:
5215
+ """Issue #28 item 7: following the spec template guaranteed a first failure.
5216
+
5217
+ The template's requirement body is a comment with no modal verb, and
5218
+ `openspec validate` requires SHALL or MUST, so the reporter's 16 requirements
5219
+ produced 16 errors. Asserted by running the filled template through the
5220
+ validator rather than by matching the template's prose, because a template
5221
+ that only mentions the requirement in a comment would satisfy the latter.
5222
+ """
5223
+ shipped = ROOT / "openspec" / SPEC_TEMPLATE_RELATIVE
5224
+ packaged = ROOT / "assets" / "openspec" / SPEC_TEMPLATE_RELATIVE
5225
+ for path in (shipped, packaged):
5226
+ if not path.is_file():
5227
+ report(f"spec-template-validates: missing shipped template {path}.")
5228
+ return 1
5229
+ if shipped.read_bytes() != packaged.read_bytes():
5230
+ report(
5231
+ "spec-template-validates: the two shipped copies of the spec "
5232
+ "template have diverged."
5233
+ )
5234
+ return 1
5235
+ if run_openspec(ROOT, "--version") is None:
5236
+ report("spec-template-validates skipped: the openspec CLI is not on PATH.")
5237
+ return 0
5238
+
5239
+ filled = fill_template_slots(
5240
+ shipped.read_text(encoding="utf-8"), comments="replace"
5241
+ )
5242
+ with tempfile.TemporaryDirectory(prefix="keel-spec-template-") as raw:
5243
+ repo = Path(raw)
5244
+ write_text(repo / "openspec/project.md", "# Project\n\nA fixture.\n")
5245
+ change = repo / "openspec/changes/from-template"
5246
+ write_text(
5247
+ change / "proposal.md",
5248
+ "# from-template\n\n## Why\n\nExercise the shipped spec template.\n"
5249
+ "\n## What Changes\n\n- One requirement written from the template.\n",
5250
+ )
5251
+ write_text(change / "specs/demo-capability/spec.md", filled)
5252
+ result = run_openspec(repo, "validate", "from-template")
5253
+ if result is None:
5254
+ report(
5255
+ "spec-template-validates skipped: the openspec CLI vanished "
5256
+ "mid-scenario."
5257
+ )
5258
+ return 0
5259
+ if result.returncode != 0:
5260
+ report(
5261
+ "spec-template-validates: a requirement written from the "
5262
+ "shipped template did not validate."
5263
+ )
5264
+ report((result.stdout or result.stderr).strip())
5265
+ return 1
5266
+ if "spec-template-validates" not in {name for name, _ in SCENARIOS}:
5267
+ report("spec-template-validates: the scenario registry does not include it.")
5268
+ return 1
5269
+ report("spec-template-validates scenario passed.")
5270
+ return 0
5271
+
5272
+
5273
+ def validate_task_complete_selection_requires_a_started_task_scenario() -> int:
5274
+ """Issue #28 item 6: the no-arg default reported another task's problems.
5275
+
5276
+ The documented order is gate-then-checkbox, so first-unchecked is the right
5277
+ inference and stays. The hazard is narrower: inferring a task that never
5278
+ started, then printing its readiness problems under a selection heading the
5279
+ author reads as their own task's failure. A task that has started records a
5280
+ fingerprint in its Evidence `Contract` anchor, so that anchor is what makes
5281
+ the inference safe.
5282
+ """
5283
+ header = "# Tasks\n\n## Invalidates\n\n- None.\n\n"
5284
+
5285
+ def task(number: str, checked: bool, contract: str) -> str:
5286
+ box = "x" if checked else " "
5287
+ return (
5288
+ f"- [{box}] {number} Exercise task contract\n"
5289
+ " - Covers:\n"
5290
+ " - E1: Public behavior passes.\n"
5291
+ " - Touch:\n"
5292
+ " - src/feature.js\n"
5293
+ " - Verify:\n"
5294
+ " - Strategy: evidence-first\n"
5295
+ " - M1: node test.js\n"
5296
+ " - Evidence:\n"
5297
+ f" - Contract: {contract}\n"
5298
+ " - M1: the suite passed\n"
5299
+ " - Review:\n"
5300
+ " - Status: pass\n"
5301
+ " - Acceptance check: behavior asserted at the interface\n"
5302
+ " - Scope check: only Touch files changed\n"
5303
+ " - Findings: none\n"
5304
+ " - Blocker: none\n"
5305
+ )
5306
+
5307
+ digest = "a" * 64
5308
+ anchored = f"keel-task-capsule/v1 sha256:{digest}"
5309
+ with tempfile.TemporaryDirectory(prefix="keel-complete-selection-") as raw:
5310
+ repo = Path(raw)
5311
+
5312
+ def gate(change: str, stage: str) -> dict:
5313
+ result = run_keel(
5314
+ repo, "gate", stage, "--change", change, "--json"
5315
+ )
5316
+ return json.loads(result.stdout)
5317
+
5318
+ # 1.1 is finished; 1.2 has never started, so its anchor is still pending.
5319
+ write_text(
5320
+ repo / "openspec/changes/unstarted/tasks.md",
5321
+ header
5322
+ + task("1.1", True, anchored)
5323
+ + "\n"
5324
+ + task("1.2", False, "pending"),
5325
+ )
5326
+ payload = gate("unstarted", "task-complete")
5327
+ codes = {problem.get("code") for problem in payload.get("problems", [])}
5328
+ if "ambiguous-completion-selection" not in codes:
5329
+ report(
5330
+ "task-complete-selection-requires-a-started-task: no-arg "
5331
+ "task-complete did not refuse on selection for a task that "
5332
+ "records no start fingerprint."
5333
+ )
5334
+ report(json.dumps(payload.get("problems", []), indent=2))
5335
+ return 1
5336
+ message = next(
5337
+ problem.get("message", "")
5338
+ for problem in payload.get("problems", [])
5339
+ if problem.get("code") == "ambiguous-completion-selection"
5340
+ )
5341
+ for needle in ("1.2", "1.1", "--task"):
5342
+ if needle not in message:
5343
+ report(
5344
+ "task-complete-selection-requires-a-started-task: the "
5345
+ f"refusal did not name {needle}."
5346
+ )
5347
+ report(message)
5348
+ return 1
5349
+ # The same shape, once 1.2 has recorded its start fingerprint.
5350
+ write_text(
5351
+ repo / "openspec/changes/started/tasks.md",
5352
+ header
5353
+ + task("1.1", True, anchored)
5354
+ + "\n"
5355
+ + task("1.2", False, anchored),
5356
+ )
5357
+ started = gate("started", "task-complete")
5358
+ started_codes = {
5359
+ problem.get("code") for problem in started.get("problems", [])
5360
+ }
5361
+ if "ambiguous-completion-selection" in started_codes:
5362
+ report(
5363
+ "task-complete-selection-requires-a-started-task: a task that "
5364
+ "recorded its start fingerprint was still refused on selection."
5365
+ )
5366
+ return 1
5367
+ if started.get("selection", {}).get("tasks") != ["1.2"]:
5368
+ report(
5369
+ "task-complete-selection-requires-a-started-task: the started "
5370
+ "task was not the inferred selection."
5371
+ )
5372
+ report(json.dumps(started.get("selection", {}), indent=2))
5373
+ return 1
5374
+ # task-start keeps the plain first-unchecked default: selecting a task
5375
+ # that has not started is exactly its job.
5376
+ start = gate("unstarted", "task-start")
5377
+ if start.get("selection", {}).get("tasks") != ["1.2"]:
5378
+ report(
5379
+ "task-complete-selection-requires-a-started-task: no-arg "
5380
+ "task-start no longer selects the first unchecked task."
5381
+ )
5382
+ report(json.dumps(start.get("selection", {}), indent=2))
5383
+ return 1
5384
+ start_codes = {problem.get("code") for problem in start.get("problems", [])}
5385
+ if "ambiguous-completion-selection" in start_codes:
5386
+ report(
5387
+ "task-complete-selection-requires-a-started-task: the selection "
5388
+ "refusal leaked into task-start."
5389
+ )
5390
+ return 1
5391
+ if "task-complete-selection-requires-a-started-task" not in {
5392
+ name for name, _ in SCENARIOS
5393
+ }:
5394
+ report(
5395
+ "task-complete-selection-requires-a-started-task: the scenario "
5396
+ "registry does not include it."
5397
+ )
5398
+ return 1
5399
+ report("task-complete-selection-requires-a-started-task scenario passed.")
5400
+ return 0
5401
+
5402
+
5403
+ def validate_absent_verification_form_is_one_problem_scenario() -> int:
5404
+ """Issue #28 item 4: the cascade reported a schema the author never chose.
5405
+
5406
+ Compact detection read only `isConcrete(Verify)`, so a task that simply had
5407
+ no verification field was reported as an expanded v3 task missing nine
5408
+ fields. Seven of them either resolve to a documented default, derive from
5409
+ other authority, are consumed nowhere, or belong to the coupling contract —
5410
+ leaving the one actionable line last.
5411
+ """
5412
+ defaulted = (
5413
+ "Owner",
5414
+ "Mode",
5415
+ "Read",
5416
+ "Acceptance",
5417
+ "Report",
5418
+ "Candidate Boundary",
5419
+ "Stop Rules",
5420
+ )
5421
+ header = "# Tasks\n\n## Invalidates\n\n- None.\n\n"
5422
+ body = (
5423
+ "- [ ] 1.1 Exercise task contract\n"
5424
+ " - Covers:\n"
5425
+ " - E1: Public behavior passes.\n"
5426
+ " - Touch:\n"
5427
+ " - src/feature.js\n"
5428
+ )
5429
+ evidence = " - Evidence:\n - M1: pending\n"
5430
+ commands = " - Commands:\n - M1: node test.js\n"
5431
+
5432
+ with tempfile.TemporaryDirectory(prefix="keel-absent-verification-") as raw:
5433
+ repo = Path(raw)
5434
+
5435
+ def problems_for(change: str, content: str) -> list:
5436
+ write_text(repo / f"openspec/changes/{change}/tasks.md", content)
5437
+ result = run_keel(
5438
+ repo,
5439
+ "gate",
5440
+ "task-start",
5441
+ "--change",
5442
+ change,
5443
+ "--task",
5444
+ "1.1",
5445
+ "--json",
5446
+ )
5447
+ return json.loads(result.stdout).get("problems", [])
5448
+
5449
+ # M1 — neither verification form declared.
5450
+ none_declared = problems_for("noform", header + body + evidence)
5451
+ naming = [
5452
+ problem
5453
+ for problem in none_declared
5454
+ if "Verify" in problem.get("message", "")
5455
+ ]
5456
+ if len(naming) != 1:
5457
+ report(
5458
+ "absent-verification-form-is-one-problem: expected exactly one "
5459
+ f"diagnostic naming Verify, found {len(naming)}."
5460
+ )
5461
+ for problem in none_declared:
5462
+ report(f" {problem.get('code')}: {problem.get('message')}")
5463
+ return 1
5464
+ leaked = sorted(
5465
+ name
5466
+ for problem in none_declared
5467
+ if problem.get("code") == "missing-field"
5468
+ for name in defaulted
5469
+ if problem.get("message", "").startswith(f"{name} must be concrete")
5470
+ )
5471
+ if leaked:
5472
+ report(
5473
+ "absent-verification-form-is-one-problem: fields with documented "
5474
+ f"defaults were still required: {', '.join(leaked)}."
5475
+ )
5476
+ return 1
5477
+
5478
+ # M2 — a genuine expanded v3 task that omits every defaulted field.
5479
+ expanded = problems_for("expanded", header + body + commands + evidence)
5480
+ if expanded:
5481
+ report(
5482
+ "absent-verification-form-is-one-problem: an expanded task "
5483
+ "declaring Commands, Covers, Touch and Evidence did not pass."
5484
+ )
5485
+ for problem in expanded:
5486
+ report(f" {problem.get('code')}: {problem.get('message')}")
5487
+ return 1
5488
+ # Removing Commands from that same task must still fail.
5489
+ if not problems_for("expanded-no-commands", header + body + evidence):
5490
+ report(
5491
+ "absent-verification-form-is-one-problem: removing Commands "
5492
+ "from the expanded task left it passing."
5493
+ )
5494
+ return 1
5495
+ # Candidate Boundary is the coupling contract's to require.
5496
+ coupled = problems_for(
5497
+ "coupled",
5498
+ header
5499
+ + body
5500
+ + " - Coupling: required\n"
5501
+ + commands
5502
+ + evidence,
5503
+ )
5504
+ if not any(
5505
+ "Candidate Boundary" in problem.get("message", "") for problem in coupled
5506
+ ):
5507
+ report(
5508
+ "absent-verification-form-is-one-problem: Coupling required did "
5509
+ "not require a Candidate Boundary."
5510
+ )
5511
+ for problem in coupled:
5512
+ report(f" {problem.get('code')}: {problem.get('message')}")
5513
+ return 1
5514
+ if "absent-verification-form-is-one-problem" not in {
5515
+ name for name, _ in SCENARIOS
5516
+ }:
5517
+ report(
5518
+ "absent-verification-form-is-one-problem: the scenario registry does "
5519
+ "not include it."
5520
+ )
5521
+ return 1
5522
+ report("absent-verification-form-is-one-problem scenario passed.")
5523
+ return 0
5524
+
5525
+
5526
+ def validate_non_concrete_check_names_token_scenario() -> int:
5527
+ """Issue #28 item 5: the check diagnostic must name the slot it matched.
5528
+
5529
+ `unfilledToken` already identifies the matched token and `Verify` already
5530
+ reports it. The per-check message said only that the check "must define a
5531
+ concrete public check", which describes the consequence rather than the
5532
+ cause, so the reporter had to guess which of several inline slots was the
5533
+ problem.
5534
+ """
5535
+ slot = "<" + "url" + ">"
5536
+ with tempfile.TemporaryDirectory(prefix="keel-check-token-") as raw:
5537
+ repo = Path(raw)
5538
+ base = task_capsule_compact_fixture().replace(
5539
+ " - M1: node test.js\n",
5540
+ " - M1: node test.js\n"
5541
+ f" - M2: run the fetch script against {slot} and assert the "
5542
+ "recorded status\n",
5543
+ ).replace(" - M1: pending\n", " - M1: pending\n - M2: pending\n")
5544
+ write_text(repo / "openspec/changes/tokened/tasks.md", base)
5545
+
5546
+ def check_messages(change: str) -> list:
5547
+ result = run_keel(
5548
+ repo,
5549
+ "gate",
5550
+ "task-start",
5551
+ "--change",
5552
+ change,
5553
+ "--task",
5554
+ "1.1",
5555
+ "--json",
5556
+ )
5557
+ payload = json.loads(result.stdout)
5558
+ return [
5559
+ problem.get("message", "")
5560
+ for problem in payload.get("problems", [])
5561
+ if problem.get("code") == "missing-command-check"
5562
+ ]
5563
+
5564
+ tokened = check_messages("tokened")
5565
+ if not tokened:
5566
+ report(
5567
+ "non-concrete-check-names-token: a check carrying a bare "
5568
+ "unfilled slot produced no missing-command-check diagnostic."
5569
+ )
5570
+ return 1
5571
+ if slot not in tokened[0]:
5572
+ report(
5573
+ "non-concrete-check-names-token: the diagnostic did not name "
5574
+ f"the {slot} slot it matched."
5575
+ )
5576
+ report(tokened[0])
5577
+ return 1
5578
+ # Replacing exactly what the diagnostic names must clear it.
5579
+ fixed = base.replace(slot, "`https://example.test/feed`")
5580
+ write_text(repo / "openspec/changes/fixed/tasks.md", fixed)
5581
+ if check_messages("fixed"):
5582
+ report(
5583
+ "non-concrete-check-names-token: replacing the named slot did "
5584
+ "not clear the diagnostic."
5585
+ )
5586
+ return 1
5587
+ # An empty check has no token to name, so the unqualified wording is
5588
+ # still the honest one there.
5589
+ empty = base.replace(
5590
+ f" - M2: run the fetch script against {slot} and assert the "
5591
+ "recorded status\n",
5592
+ " - M2: pending\n",
5593
+ )
5594
+ write_text(repo / "openspec/changes/empty/tasks.md", empty)
5595
+ bare = check_messages("empty")
5596
+ if not bare:
5597
+ report(
5598
+ "non-concrete-check-names-token: a pending check produced no "
5599
+ "missing-command-check diagnostic."
5600
+ )
5601
+ return 1
5602
+ if "must define a concrete public check" not in bare[0]:
5603
+ report(
5604
+ "non-concrete-check-names-token: a pending check lost the "
5605
+ "unqualified wording."
5606
+ )
5607
+ report(bare[0])
5608
+ return 1
5609
+ if "non-concrete-check-names-token" not in {name for name, _ in SCENARIOS}:
5610
+ report(
5611
+ "non-concrete-check-names-token: the scenario registry does not "
5612
+ "include it."
5613
+ )
5614
+ return 1
5615
+ report("non-concrete-check-names-token scenario passed.")
5616
+ return 0
5617
+
5618
+
5619
+ def validate_covers_question_reference_scope_scenario() -> int:
5620
+ """Issue #28 item 9: citing a resolved question must not re-open it.
5621
+
5622
+ The question scan used to run over the whole Covers field, so a task that
5623
+ named `Q1` beside the fact that closed it was told to declare a fallback for
5624
+ a question it does not carry. The reporter's only available fix was to
5625
+ delete the reference, which makes traceability worse.
5626
+
5627
+ Both sides are asserted. A scenario that only checked the newly passing
5628
+ shape would also be satisfied by deleting the check outright.
5629
+ """
5630
+ with tempfile.TemporaryDirectory(prefix="keel-covers-question-") as raw:
5631
+ repo = Path(raw)
5632
+ base = task_capsule_compact_fixture()
5633
+ # Still in scope: the question is the subject of its entry.
5634
+ subject = base.replace(
5635
+ " - E1: Public behavior passes.\n",
5636
+ " - Q1: Should the widget retry on timeout?\n",
5637
+ )
5638
+ write_text(repo / "openspec/changes/subject/tasks.md", subject)
5639
+ # Out of scope: the entry's subject is the fact, and the resolved
5640
+ # question is named as the supporting detail that points at it.
5641
+ detail = base.replace(
5642
+ " - E1: Public behavior passes.\n",
5643
+ " - F13 (Q1 resolved: the widget retries twice, then stops)\n",
5644
+ )
5645
+ write_text(repo / "openspec/changes/detail/tasks.md", detail)
5646
+
5647
+ def authority_messages(change: str) -> list:
5648
+ result = run_keel(
5649
+ repo,
5650
+ "gate",
5651
+ "task-start",
5652
+ "--change",
5653
+ change,
5654
+ "--task",
5655
+ "1.1",
5656
+ "--json",
5657
+ )
5658
+ payload = json.loads(result.stdout)
5659
+ return [
5660
+ problem.get("message", "")
5661
+ for problem in payload.get("problems", [])
5662
+ if problem.get("code") == "unresolved-authority"
5663
+ ]
5664
+
5665
+ blocking = authority_messages("subject")
5666
+ if not blocking:
5667
+ report(
5668
+ "covers-question-reference-scope: a question that opens its "
5669
+ "Covers entry produced no unresolved-authority diagnostic, so "
5670
+ "the check no longer refuses anything."
5671
+ )
5672
+ return 1
5673
+ if "Q1" not in blocking[0]:
5674
+ report(
5675
+ "covers-question-reference-scope: the diagnostic did not name "
5676
+ "the question it read."
5677
+ )
5678
+ report(blocking[0])
5679
+ return 1
5680
+ citing = authority_messages("detail")
5681
+ if citing:
5682
+ report(
5683
+ "covers-question-reference-scope: naming a resolved question as "
5684
+ "supporting detail still demands a fallback for it."
5685
+ )
5686
+ report(citing[0])
5687
+ return 1
5688
+ if "covers-question-reference-scope" not in {name for name, _ in SCENARIOS}:
5689
+ report(
5690
+ "covers-question-reference-scope: the scenario registry does not "
5691
+ "include it."
5692
+ )
5693
+ return 1
5694
+ report("covers-question-reference-scope scenario passed.")
5695
+ return 0
5696
+
5697
+
5052
5698
  def validate_dev_only_plugin_source_scoping_scenario() -> int:
5053
5699
  """Issue #6: plugins/keel/ exists only in Keel's own repository.
5054
5700
 
@@ -13106,6 +13752,27 @@ SCENARIOS: tuple = (
13106
13752
  "unresolved-authority-names-field",
13107
13753
  validate_unresolved_authority_names_field_scenario,
13108
13754
  ),
13755
+ (
13756
+ "covers-question-reference-scope",
13757
+ validate_covers_question_reference_scope_scenario,
13758
+ ),
13759
+ (
13760
+ "non-concrete-check-names-token",
13761
+ validate_non_concrete_check_names_token_scenario,
13762
+ ),
13763
+ (
13764
+ "absent-verification-form-is-one-problem",
13765
+ validate_absent_verification_form_is_one_problem_scenario,
13766
+ ),
13767
+ (
13768
+ "task-complete-selection-requires-a-started-task",
13769
+ validate_task_complete_selection_requires_a_started_task_scenario,
13770
+ ),
13771
+ ("spec-template-validates", validate_spec_template_validates_scenario),
13772
+ (
13773
+ "tasks-template-red-green-example",
13774
+ validate_tasks_template_red_green_example_scenario,
13775
+ ),
13109
13776
  (
13110
13777
  "dev-only-plugin-source-scoping",
13111
13778
  validate_dev_only_plugin_source_scoping_scenario,
package/src/core/gates.js CHANGED
@@ -103,6 +103,32 @@ function loadSelection(repo, options, requireTask = true) {
103
103
  return { change, tasksPath, content, tasks, selected: [selected] };
104
104
  }
105
105
 
106
+ // The documented order is gate-then-checkbox, so first-unchecked is the right
107
+ // inference for task-start and stays. The hazard is narrower: completing without
108
+ // an explicit task infers a task that has not started, then reports its
109
+ // readiness problems under a selection heading the author reads as the failure
110
+ // of the task they just finished. A task that has started records a fingerprint
111
+ // in its Evidence `Contract` anchor, so that anchor is what makes the inference
112
+ // safe — and without one there is nothing for completion to compare against.
113
+ function unstartedInferenceProblem(selection) {
114
+ const task = selection.selected[0];
115
+ const plan = contractAnchorPlan(selection, task);
116
+ if (plan && anchoredFingerprint(plan.previous)) return null;
117
+ const checked = [...selection.tasks].filter((item) => item.checked).pop();
118
+ return problem(
119
+ "ambiguous-completion-selection",
120
+ `task-complete inferred ${selection.change}#${task.id}, the first unchecked `
121
+ + "task, but that task records no start fingerprint in its Evidence "
122
+ + "`Contract` anchor, so it has not started and there is nothing to "
123
+ + `compare. ${
124
+ checked
125
+ ? `The most recently checked task is ${checked.id}. `
126
+ : ""
127
+ }Name the task you mean with \`--task\`, or run \`task-start --record\` `
128
+ + "first."
129
+ );
130
+ }
131
+
106
132
  function contractAnchorPlan(selection, task) {
107
133
  const lines = selection.content.split("\n");
108
134
  const index = selection.tasks.findIndex((item) => item.id === task.id);
@@ -559,6 +585,20 @@ function completionChecks(repo, task, contract = null) {
559
585
  function taskComplete(repo, options) {
560
586
  const selection = loadSelection(repo, options);
561
587
  const task = selection.selected[0];
588
+ if (!options.task) {
589
+ const ambiguous = unstartedInferenceProblem(selection);
590
+ if (ambiguous) {
591
+ return gateResult(
592
+ "task-complete",
593
+ "fail",
594
+ selection.change,
595
+ [task.id],
596
+ [ambiguous],
597
+ [],
598
+ null
599
+ );
600
+ }
601
+ }
562
602
  const contract = compileTaskContract(repo, selection.change, task);
563
603
  const usableContract = contract.diagnostics.length === 0 ? contract : null;
564
604
  const checks = completionChecks(repo, task, usableContract);
@@ -192,6 +192,16 @@ function verification(task) {
192
192
  }
193
193
 
194
194
  function commandLabelProblems(task) {
195
+ // A task that declared no verification form at all is reported once, by
196
+ // requiredFieldProblems, as the one field it is missing. Its orphan Evidence
197
+ // labels are a consequence of that same absence, and restating them here is
198
+ // the cascade that buries the actionable line.
199
+ if (
200
+ fieldValues(task, "Verify").length === 0
201
+ && fieldValues(task, "Commands").length === 0
202
+ ) {
203
+ return [];
204
+ }
195
205
  const problems = [];
196
206
  const seen = new Set();
197
207
  const labels = [];
@@ -222,9 +232,18 @@ function commandLabelProblems(task) {
222
232
  seen.add(command[1]);
223
233
  labels.push(command[1]);
224
234
  if (!isConcrete(command[2])) {
235
+ // Name the matched slot, the way the Verify diagnostic already does. The
236
+ // unqualified wording described the consequence, so an author with
237
+ // several slots in one check had to guess which one was read.
238
+ const token = unfilledToken(command[2]);
225
239
  problems.push({
226
240
  code: "missing-command-check",
227
- message: `${command[1]} must define a concrete public check.`,
241
+ message: token
242
+ ? `${command[1]} carries the unfilled slot \`${token}\`, so it does `
243
+ + "not define a concrete public check. Replace that slot with the "
244
+ + "value the check actually runs against, or fence it in inline "
245
+ + "code when it is a documented pattern rather than a slot."
246
+ : `${command[1]} must define a concrete public check.`,
228
247
  });
229
248
  }
230
249
  }
@@ -343,38 +362,44 @@ function requiredFieldProblems(task) {
343
362
  },
344
363
  ];
345
364
  }
346
- }
347
- const required = compact
348
- ? ["Covers", "Verify", "Evidence"]
349
- : [
350
- "Owner",
351
- "Mode",
352
- "Covers",
353
- "Read",
354
- "Commands",
355
- "Acceptance",
356
- "Candidate Boundary",
357
- "Stop Rules",
358
- "Evidence",
359
- "Report",
365
+ // Neither verification form declared. That is a compact v4 task missing one
366
+ // field, not an expanded v3 task missing nine — and listing the v3 set here
367
+ // reported a schema this author never chose.
368
+ if (!isConcrete(field(task, "Commands"))) {
369
+ return [
370
+ {
371
+ code: "missing-verification-form",
372
+ message:
373
+ "The task declares no verification form. Add a `Verify` field with "
374
+ + "a `Strategy:` entry and one `M<n>:` check per behavior the task "
375
+ + "proves. The expanded v3 `Commands` field is the other accepted "
376
+ + "form; the remaining v3 fields are not required.",
377
+ },
378
+ // The rest of the compact set is still reported, so a near-empty task
379
+ // learns everything it is missing. Only the v3 cascade is replaced.
380
+ ...missingFieldProblems(task, ["Covers", "Evidence"]),
360
381
  ];
361
- const problems = required
382
+ }
383
+ }
384
+ // The expanded set is the compact set with `Commands` in place of `Verify`.
385
+ // Owner, Mode, Read, and Acceptance resolve to documented defaults or derive
386
+ // from Covers, Report is consumed nowhere, and Candidate Boundary and Stop
387
+ // Rules belong to couplingProblems, which requires them when the coupling
388
+ // contract does. Requiring them here reported fields that were already in
389
+ // effect.
390
+ return missingFieldProblems(
391
+ task,
392
+ compact ? ["Covers", "Verify", "Evidence"] : ["Covers", "Commands", "Evidence"]
393
+ );
394
+ }
395
+
396
+ function missingFieldProblems(task, names) {
397
+ return names
362
398
  .filter((name) => !isConcrete(field(task, name)))
363
399
  .map((name) => ({
364
400
  code: "missing-field",
365
401
  message: `${name} must be concrete.`,
366
402
  }));
367
- if (
368
- !compact
369
- && !isConcrete(field(task, "Autonomy boundary"))
370
- && !isConcrete(field(task, "Stop if"))
371
- ) {
372
- problems.push({
373
- code: "missing-boundary",
374
- message: "Stop if or Autonomy boundary must be concrete.",
375
- });
376
- }
377
- return problems;
378
403
  }
379
404
 
380
405
  function canonical(value) {
@@ -808,8 +833,18 @@ function compileTaskContract(repo, change, task) {
808
833
  if (!autonomy.some((item) => /^Pre-authorized fallback:/i.test(item))) {
809
834
  autonomy.push("Pre-authorized fallback: none");
810
835
  }
836
+ // A question is unresolved authority when it is the subject of its Covers
837
+ // entry. Scanning the whole field also matched a resolved question named as
838
+ // supporting detail beside the fact that closed it, and the only fix
839
+ // available to the author was deleting the reference — so the check punished
840
+ // the traceability it exists to protect.
811
841
  const questionIds = [
812
- ...new Set(field(task, "Covers").match(/\bQ\d+\b/g) || []),
842
+ ...new Set(
843
+ normalizedValues(task, "Covers", { ordered: true })
844
+ .map((entry) => entry.match(/^(Q\d+)\b/))
845
+ .filter(Boolean)
846
+ .map((match) => match[1])
847
+ ),
813
848
  ];
814
849
  const fallback = autonomy.find((item) =>
815
850
  /^Pre-authorized fallback:/i.test(item)