@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.
- package/assets/bootstrap/AGENTS.md +1 -1
- package/assets/openspec/schemas/keel-spec-driven/schema.yaml +5 -2
- package/assets/openspec/schemas/keel-spec-driven/templates/spec.md +3 -1
- package/assets/openspec/schemas/keel-spec-driven/templates/tasks.md +31 -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 +672 -5
- package/src/core/gates.js +40 -0
- package/src/core/task-contract.js +63 -28
|
@@ -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.
|
|
112
|
-
|
|
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>;
|
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.3.
|
|
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.
|
|
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.
|
|
41
|
-
PROTOCOL_VERSION = "5.3.
|
|
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
|
|
4733
|
-
#
|
|
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:
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
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(
|
|
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)
|