@christang/keel 5.2.4 → 5.3.1
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/templates/tasks.md +5 -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/plugins/keel/scripts/pretooluse-guard.js +54 -5
- package/scripts/validate_plugin.py +775 -39
- package/src/core/gates.js +46 -4
- package/src/core/guard.js +6 -0
- package/src/core/task-contract.js +19 -5
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
<!-- keel:start version=5.
|
|
1
|
+
<!-- keel:start version=5.3.1 -->
|
|
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 in
|
|
5
|
+
- Obey the selected task capsule: `keel gate task-start` before implementing, record its fingerprint in Evidence `Contract`, and pass `keel gate task-complete` before checking complete. Touch is the write boundary for product files; on Claude a passing `task-start` guards it by default (`--no-guard`/`keel guard clear` opt 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.
|
|
@@ -40,7 +40,11 @@
|
|
|
40
40
|
- Blocker: none
|
|
41
41
|
|
|
42
42
|
<!-- Exceptional boundaries are declared only when they differ from defaults:
|
|
43
|
-
- Mode: diagnose-only (with `Touch: none`) or plan-first
|
|
43
|
+
- Mode: diagnose-only or repo-action (both with `Touch: none`), or plan-first.
|
|
44
|
+
repo-action is for a task whose whole effect is an authorized
|
|
45
|
+
repository-level action — a commit, a tag — and which writes no worktree
|
|
46
|
+
file; it is the one mode that may commit, and it still may not push,
|
|
47
|
+
sync, archive, or mark tasks complete
|
|
44
48
|
- Read: additional required starting context beyond the base set
|
|
45
49
|
- Acceptance: a task-specific observable delta the Covers authority does not express
|
|
46
50
|
- Execution recommendation / Rationale: advisory notes for the current agent
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.3.1",
|
|
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
|
+
"version": "5.3.1",
|
|
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",
|
|
@@ -84,6 +84,29 @@ function pathAllowed(candidate, touch) {
|
|
|
84
84
|
});
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
// A checked task keeps its records writable so it can finish the Evidence its
|
|
88
|
+
// completion gate requires, but earns no further product authorization. Byte
|
|
89
|
+
// hashing used to enforce this by accident, since ticking the box changed
|
|
90
|
+
// tasks.md; now it is stated. An unreadable or unmatched tasks.md is not read as
|
|
91
|
+
// checked — every gate that compiles the capsule catches that, and denying
|
|
92
|
+
// product writes on a parse miss would trade a real capability for a guess.
|
|
93
|
+
function taskIsChecked(repo, manifest) {
|
|
94
|
+
const file = path.join(
|
|
95
|
+
repo, "openspec", "changes", manifest.change, "tasks.md"
|
|
96
|
+
);
|
|
97
|
+
let content = "";
|
|
98
|
+
try {
|
|
99
|
+
content = fs.readFileSync(file, "utf8");
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
const id = manifest.task.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
104
|
+
const match = content.match(
|
|
105
|
+
new RegExp(`^\\s*-\\s*\\[([ xX])\\]\\s*${id}(?![0-9.])`, "m")
|
|
106
|
+
);
|
|
107
|
+
return Boolean(match && match[1].toLowerCase() === "x");
|
|
108
|
+
}
|
|
109
|
+
|
|
87
110
|
function main() {
|
|
88
111
|
let event = {};
|
|
89
112
|
try {
|
|
@@ -114,8 +137,30 @@ function main() {
|
|
|
114
137
|
return 0;
|
|
115
138
|
}
|
|
116
139
|
const pointer = `${manifest.change}#${manifest.task}`;
|
|
140
|
+
// The record layer: the guarded change's own directory holds the records the
|
|
141
|
+
// task produces — its checkbox, Evidence, and Review — not the product it
|
|
142
|
+
// changes. `keel gate task-complete` already refuses to attribute this
|
|
143
|
+
// directory as an outside-Touch failure, so denying it here made the guard
|
|
144
|
+
// stop the one thing the completion gate is waiting for. Derived from the
|
|
145
|
+
// manifest's existing `change` field, so no manifest shape changes.
|
|
146
|
+
const recordPrefix = `openspec/changes/${manifest.change}/`;
|
|
147
|
+
|
|
148
|
+
const target = event.tool_input ? event.tool_input[pathField] : null;
|
|
149
|
+
if (typeof target !== "string" || !target) return 0;
|
|
150
|
+
const relative = path.relative(repo, path.resolve(repo, target));
|
|
151
|
+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
const candidate = relative.replace(/\\/g, "/");
|
|
155
|
+
if (candidate.startsWith(recordPrefix)) return 0;
|
|
117
156
|
|
|
118
157
|
for (const entry of manifest.authority) {
|
|
158
|
+
// Same boundary: bytes under the guarded change's own directory are
|
|
159
|
+
// records, and the capsule fingerprint — which carries no checkbox state
|
|
160
|
+
// and no Evidence values — is what separates a record write from a
|
|
161
|
+
// contract change. `keel guard status` and `keel gate task-complete`
|
|
162
|
+
// compile the capsule and compare it; this hook cannot, and must not guess.
|
|
163
|
+
if (entry.path.replace(/\\/g, "/").startsWith(recordPrefix)) continue;
|
|
119
164
|
const file = path.join(repo, entry.path);
|
|
120
165
|
let fresh = null;
|
|
121
166
|
try {
|
|
@@ -134,13 +179,17 @@ function main() {
|
|
|
134
179
|
}
|
|
135
180
|
}
|
|
136
181
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
182
|
+
if (taskIsChecked(repo, manifest)) {
|
|
183
|
+
deny(
|
|
184
|
+
`Keel write guard: ${pointer} is checked complete, so it authorizes no `
|
|
185
|
+
+ `further product writes — ${candidate} is denied. Its own `
|
|
186
|
+
+ `${recordPrefix} records stay writable so the task can finish its `
|
|
187
|
+
+ "Evidence. Run `keel guard clear`, then authorize the next task "
|
|
188
|
+
+ "explicitly with `keel gate task-start`."
|
|
189
|
+
);
|
|
141
190
|
return 0;
|
|
142
191
|
}
|
|
143
|
-
|
|
192
|
+
|
|
144
193
|
if (pathAllowed(candidate, manifest.touch)) return 0;
|
|
145
194
|
|
|
146
195
|
deny(
|
|
@@ -37,8 +37,8 @@ REQUIRED_SCRIPTS = [
|
|
|
37
37
|
"scripts/validate_plugin.py",
|
|
38
38
|
]
|
|
39
39
|
|
|
40
|
-
PACKAGE_VERSION = "5.
|
|
41
|
-
PROTOCOL_VERSION = "5.
|
|
40
|
+
PACKAGE_VERSION = "5.3.1"
|
|
41
|
+
PROTOCOL_VERSION = "5.3.1"
|
|
42
42
|
LEGACY_MANAGED_START = "<!-- keel:start version=2.1 -->"
|
|
43
43
|
OPENSPEC_SCHEMA_NAME = "keel-spec-driven"
|
|
44
44
|
OPENSPEC_CONFIG_PATH = Path("openspec/config.yaml")
|
|
@@ -99,7 +99,11 @@ RESIDENT_BLOCKS = [
|
|
|
99
99
|
"keel gate task-start",
|
|
100
100
|
"task capsule",
|
|
101
101
|
"fingerprint",
|
|
102
|
-
|
|
102
|
+
# Prose, not a command: this sentence is the one that has actually
|
|
103
|
+
# been reworded under byte pressure, so it is matched as a topic —
|
|
104
|
+
# Touch and a `bound…` word in one statement. The other prose
|
|
105
|
+
# entries stay literal until one of them needs the same freedom.
|
|
106
|
+
re.compile(r"Touch\b[^\n]*\bbound", re.IGNORECASE),
|
|
103
107
|
"read-only report/evidence",
|
|
104
108
|
"native plugin",
|
|
105
109
|
"keel --init",
|
|
@@ -491,9 +495,9 @@ def strip_template_checksum(content: str) -> tuple[str, str | None]:
|
|
|
491
495
|
return "".join(kept), checksum
|
|
492
496
|
|
|
493
497
|
|
|
494
|
-
def validate_resident_blocks(errors: list[str]) -> None:
|
|
498
|
+
def validate_resident_blocks(errors: list[str], root: Path = ROOT) -> None:
|
|
495
499
|
for block in RESIDENT_BLOCKS:
|
|
496
|
-
path =
|
|
500
|
+
path = root / block["path"]
|
|
497
501
|
if not path.is_file():
|
|
498
502
|
errors.append(f"{block['name']} is missing: {block['path']}")
|
|
499
503
|
continue
|
|
@@ -519,9 +523,24 @@ def validate_resident_blocks(errors: list[str]) -> None:
|
|
|
519
523
|
f"budget is {max_lines}"
|
|
520
524
|
)
|
|
521
525
|
|
|
526
|
+
# A required entry is one of two kinds, and they mean different things.
|
|
527
|
+
# A literal names a command, marker, or identifier: if the block no
|
|
528
|
+
# longer contains it exactly, it is telling a reader to run something
|
|
529
|
+
# that does not exist, so the check must fail. A pattern states a topic
|
|
530
|
+
# in prose: the concepts must remain, the wording may move — which it
|
|
531
|
+
# must be free to, because this block is under a line and byte budget
|
|
532
|
+
# and gets rewritten to fit.
|
|
522
533
|
for required in block["required"]:
|
|
523
|
-
if required
|
|
524
|
-
|
|
534
|
+
if isinstance(required, str):
|
|
535
|
+
if required not in managed_block:
|
|
536
|
+
errors.append(
|
|
537
|
+
f"{block['name']} missing required literal: {required}"
|
|
538
|
+
)
|
|
539
|
+
elif not required.search(managed_block):
|
|
540
|
+
errors.append(
|
|
541
|
+
f"{block['name']} missing required topic: "
|
|
542
|
+
f"{required.pattern}"
|
|
543
|
+
)
|
|
525
544
|
|
|
526
545
|
lowered = managed_block.lower()
|
|
527
546
|
for forbidden in RESIDENT_FORBIDDEN_SNIPPETS:
|
|
@@ -1082,6 +1101,17 @@ def validate_skill_portability_policy_scenario() -> int:
|
|
|
1082
1101
|
return 0
|
|
1083
1102
|
|
|
1084
1103
|
|
|
1104
|
+
def posix_paths(text: str) -> str:
|
|
1105
|
+
"""Fold path separators so an assertion does not encode the host's spelling.
|
|
1106
|
+
|
|
1107
|
+
Keel prints these paths through the host's path joiner, so the same doctor
|
|
1108
|
+
line reads `.claude\\commands\\opsx` on Windows and `.claude/commands/opsx`
|
|
1109
|
+
on a POSIX runner. Assertions state the forward-slash form and normalize the
|
|
1110
|
+
captured output, rather than branching on the platform or accepting both.
|
|
1111
|
+
"""
|
|
1112
|
+
return (text or "").replace("\\", "/")
|
|
1113
|
+
|
|
1114
|
+
|
|
1085
1115
|
def validate_target_surface_scenario() -> int:
|
|
1086
1116
|
with tempfile.TemporaryDirectory(prefix="keel-surface-") as raw_tmp:
|
|
1087
1117
|
tmp = Path(raw_tmp)
|
|
@@ -1098,9 +1128,9 @@ def validate_target_surface_scenario() -> int:
|
|
|
1098
1128
|
claude_doctor.returncode != 0
|
|
1099
1129
|
or "Target surface:" not in claude_doctor.stdout
|
|
1100
1130
|
or "OpenSpec commands: ok" not in claude_doctor.stdout
|
|
1101
|
-
or ".claude
|
|
1131
|
+
or ".claude/commands/opsx" not in posix_paths(claude_doctor.stdout)
|
|
1102
1132
|
or "OpenSpec action skills: ok" not in claude_doctor.stdout
|
|
1103
|
-
or ".claude
|
|
1133
|
+
or ".claude/skills" not in posix_paths(claude_doctor.stdout)
|
|
1104
1134
|
or "bootstrap: ok" not in claude_doctor.stdout
|
|
1105
1135
|
or "CLAUDE import: ok" not in claude_doctor.stdout
|
|
1106
1136
|
or "native plugin runtime: manual" not in claude_doctor.stdout
|
|
@@ -1130,9 +1160,9 @@ def validate_target_surface_scenario() -> int:
|
|
|
1130
1160
|
if (
|
|
1131
1161
|
codex_doctor.returncode != 0
|
|
1132
1162
|
or "OpenSpec commands: ok" not in codex_doctor.stdout
|
|
1133
|
-
or codex_prompt_dir not in codex_doctor.stdout
|
|
1163
|
+
or posix_paths(codex_prompt_dir) not in posix_paths(codex_doctor.stdout)
|
|
1134
1164
|
or "OpenSpec action skills: ok" not in codex_doctor.stdout
|
|
1135
|
-
or ".codex
|
|
1165
|
+
or ".codex/skills" not in posix_paths(codex_doctor.stdout)
|
|
1136
1166
|
or "bootstrap: ok" not in codex_doctor.stdout
|
|
1137
1167
|
or "native plugin runtime: manual" not in codex_doctor.stdout
|
|
1138
1168
|
or "Target capabilities (codex):" not in codex_doctor.stdout
|
|
@@ -1173,9 +1203,9 @@ def validate_target_surface_scenario() -> int:
|
|
|
1173
1203
|
if (
|
|
1174
1204
|
opencode_doctor.returncode != 0
|
|
1175
1205
|
or "OpenSpec commands: ok" not in opencode_doctor.stdout
|
|
1176
|
-
or ".opencode
|
|
1206
|
+
or ".opencode/commands" not in posix_paths(opencode_doctor.stdout)
|
|
1177
1207
|
or "OpenSpec action skills: ok" not in opencode_doctor.stdout
|
|
1178
|
-
or ".opencode
|
|
1208
|
+
or ".opencode/skills" not in posix_paths(opencode_doctor.stdout)
|
|
1179
1209
|
or "bootstrap: ok" not in opencode_doctor.stdout
|
|
1180
1210
|
or "native plugin: manual" not in opencode_doctor.stdout
|
|
1181
1211
|
or "Target capabilities (opencode):" not in opencode_doctor.stdout
|
|
@@ -7024,8 +7054,11 @@ def validate_native_plugin_marketplaces_scenario() -> int:
|
|
|
7024
7054
|
codex = shutil.which("codex")
|
|
7025
7055
|
claude = claude_cli()
|
|
7026
7056
|
if codex is None or claude is None:
|
|
7027
|
-
|
|
7028
|
-
|
|
7057
|
+
return skip_scenario(
|
|
7058
|
+
"native-plugin-marketplaces",
|
|
7059
|
+
"requires the codex and claude CLIs, which are not installed; it "
|
|
7060
|
+
"probes native marketplace behavior no CI runner provides",
|
|
7061
|
+
)
|
|
7029
7062
|
|
|
7030
7063
|
with tempfile.TemporaryDirectory(prefix="keel-native-market-") as raw_tmp:
|
|
7031
7064
|
tmp = Path(raw_tmp)
|
|
@@ -10129,8 +10162,11 @@ def validate_native_plugin_install_matrix_scenario() -> int:
|
|
|
10129
10162
|
codex = shutil.which("codex")
|
|
10130
10163
|
claude = claude_cli()
|
|
10131
10164
|
if codex is None or claude is None:
|
|
10132
|
-
|
|
10133
|
-
|
|
10165
|
+
return skip_scenario(
|
|
10166
|
+
"native-plugin-install-matrix",
|
|
10167
|
+
"requires the codex and claude CLIs, which are not installed; it "
|
|
10168
|
+
"probes native install behavior no CI runner provides",
|
|
10169
|
+
)
|
|
10134
10170
|
|
|
10135
10171
|
expected_version = json.loads(
|
|
10136
10172
|
(ROOT / "package.json").read_text(encoding="utf-8")
|
|
@@ -10367,6 +10403,623 @@ def expect_guard_allow(
|
|
|
10367
10403
|
return 0
|
|
10368
10404
|
|
|
10369
10405
|
|
|
10406
|
+
RECORD_LAYER_SPEC = (
|
|
10407
|
+
"# demo-cap Specification\n\n"
|
|
10408
|
+
"## Purpose\n"
|
|
10409
|
+
"Fixture capability for the record-layer scenario.\n\n"
|
|
10410
|
+
"## Requirements\n"
|
|
10411
|
+
"### Requirement: Guarded behavior holds\n"
|
|
10412
|
+
"The guarded feature MUST keep its public behavior.\n\n"
|
|
10413
|
+
"#### Scenario: Guarded public behavior passes\n"
|
|
10414
|
+
"- **WHEN** the guarded feature runs\n"
|
|
10415
|
+
"- **THEN** it passes\n"
|
|
10416
|
+
)
|
|
10417
|
+
|
|
10418
|
+
|
|
10419
|
+
def record_layer_tasks(checked: bool = False, touch: str = "src/feature.js") -> str:
|
|
10420
|
+
box = "x" if checked else " "
|
|
10421
|
+
return (
|
|
10422
|
+
"# Tasks\n\n"
|
|
10423
|
+
f"- [{box}] 1.1 Exercise guarded feature\n"
|
|
10424
|
+
" - Covers:\n"
|
|
10425
|
+
" - demo-cap / Guarded behavior holds / Guarded public behavior passes\n"
|
|
10426
|
+
" - Touch:\n"
|
|
10427
|
+
f" - {touch}\n"
|
|
10428
|
+
" - Verify:\n"
|
|
10429
|
+
" - Strategy: evidence-first\n"
|
|
10430
|
+
" - M1: node test.js\n"
|
|
10431
|
+
" - Evidence:\n"
|
|
10432
|
+
" - M1: pending\n"
|
|
10433
|
+
)
|
|
10434
|
+
|
|
10435
|
+
|
|
10436
|
+
def mode_fixture_tasks(mode: str, touch: str) -> str:
|
|
10437
|
+
return (
|
|
10438
|
+
"# Tasks\n\n"
|
|
10439
|
+
"- [ ] 1.1 Establish the version baseline\n"
|
|
10440
|
+
f" - Mode: {mode}\n"
|
|
10441
|
+
" - Covers:\n"
|
|
10442
|
+
" - E1: the repository carries its first commit\n"
|
|
10443
|
+
" - Touch:\n"
|
|
10444
|
+
f" - {touch}\n"
|
|
10445
|
+
" - Verify:\n"
|
|
10446
|
+
" - Strategy: evidence-first\n"
|
|
10447
|
+
" - M1: git rev-parse HEAD resolves and git log reports one commit\n"
|
|
10448
|
+
" - Evidence:\n"
|
|
10449
|
+
" - Contract: pending\n"
|
|
10450
|
+
" - M1: pending\n"
|
|
10451
|
+
)
|
|
10452
|
+
|
|
10453
|
+
|
|
10454
|
+
def validate_runner_skip_accounting_scenario() -> int:
|
|
10455
|
+
"""Issue #10: the suite could not pass anywhere the native CLIs are absent.
|
|
10456
|
+
|
|
10457
|
+
Two of seventy scenarios probe native runtimes and used to `return 1` when
|
|
10458
|
+
the CLI was missing, so no CI runner could ever go green. A skip must be
|
|
10459
|
+
reported and counted, never conflated with a pass or a failure.
|
|
10460
|
+
"""
|
|
10461
|
+
label = "runner-skip-accounting"
|
|
10462
|
+
runner = str(ROOT / "scripts/validate_plugin.py")
|
|
10463
|
+
|
|
10464
|
+
def run_registry(results: str) -> subprocess.CompletedProcess[str]:
|
|
10465
|
+
"""Drive run_all over synthetic scenario results in a child process.
|
|
10466
|
+
|
|
10467
|
+
run_all dispatches each scenario as its own subprocess, which reads the
|
|
10468
|
+
real registry from disk, so a substituted registry would be ignored.
|
|
10469
|
+
The accounting is the behavior under test, so the process fan-out is
|
|
10470
|
+
replaced with fixed (name, code, output) triples instead.
|
|
10471
|
+
"""
|
|
10472
|
+
program = (
|
|
10473
|
+
"import sys\n"
|
|
10474
|
+
f"src = open({runner!r}, encoding='utf-8').read()\n"
|
|
10475
|
+
"ns = {'__name__': 'v', '__file__': %r}\n" % runner
|
|
10476
|
+
+ "exec(compile(src, %r, 'exec'), ns)\n" % runner
|
|
10477
|
+
+ f"results = {results}\n"
|
|
10478
|
+
"ns['SCENARIOS'] = tuple((n, None) for n, _, _ in results)\n"
|
|
10479
|
+
"ns['run_baseline'] = lambda: 0\n"
|
|
10480
|
+
"ns['run_scenario_processes'] = lambda names, jobs: results\n"
|
|
10481
|
+
"sys.exit(ns['run_all'](2))\n"
|
|
10482
|
+
)
|
|
10483
|
+
return subprocess.run(
|
|
10484
|
+
[sys.executable, "-c", program],
|
|
10485
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
10486
|
+
cwd=str(ROOT),
|
|
10487
|
+
)
|
|
10488
|
+
|
|
10489
|
+
# A skipping scenario must not fail the run, must be named, and must be
|
|
10490
|
+
# excluded from the verified count; a failing one must still fail it.
|
|
10491
|
+
both = run_registry(
|
|
10492
|
+
"[('fake-skip', 3, 'fake-skip scenario skipped: the frob CLI\\n'),"
|
|
10493
|
+
" ('fake-pass', 0, 'fake-pass scenario passed.\\n')]"
|
|
10494
|
+
)
|
|
10495
|
+
out = (both.stdout or "") + (both.stderr or "")
|
|
10496
|
+
if both.returncode != 0:
|
|
10497
|
+
report(f"{label}: a skipping scenario must not fail the run.")
|
|
10498
|
+
report(out.strip())
|
|
10499
|
+
return 1
|
|
10500
|
+
for needle in ("fake-skip", "skipped", "the frob CLI", "plus 1 scenario"):
|
|
10501
|
+
if needle not in out:
|
|
10502
|
+
report(f"{label}: the summary must report {needle!r}; got:\n{out.strip()}")
|
|
10503
|
+
return 1
|
|
10504
|
+
if "fake-pass" in out.split("passed:")[-1]:
|
|
10505
|
+
report(f"{label}: a passing scenario must not be listed as skipped.")
|
|
10506
|
+
report(out.strip())
|
|
10507
|
+
return 1
|
|
10508
|
+
|
|
10509
|
+
mixed = run_registry(
|
|
10510
|
+
"[('fake-skip', 3, 'fake-skip scenario skipped: the frob CLI\\n'),"
|
|
10511
|
+
" ('fake-fail', 1, 'fake-fail scenario failed.\\n')]"
|
|
10512
|
+
)
|
|
10513
|
+
mixed_out = (mixed.stdout or "") + (mixed.stderr or "")
|
|
10514
|
+
if mixed.returncode == 0 or "failed for: fake-fail" not in mixed_out:
|
|
10515
|
+
report(
|
|
10516
|
+
f"{label}: a skip beside a failure must still fail the run and name "
|
|
10517
|
+
"the failure."
|
|
10518
|
+
)
|
|
10519
|
+
report(mixed_out.strip())
|
|
10520
|
+
return 1
|
|
10521
|
+
if "fake-skip" in mixed_out.split("failed for:")[-1]:
|
|
10522
|
+
report(f"{label}: a skipped scenario must not be named as a failure.")
|
|
10523
|
+
report(mixed_out.strip())
|
|
10524
|
+
return 1
|
|
10525
|
+
|
|
10526
|
+
# The two real native-runtime scenarios must take the skip path, not fail,
|
|
10527
|
+
# when their CLI cannot be resolved.
|
|
10528
|
+
for name in ("native-plugin-marketplaces", "native-plugin-install-matrix"):
|
|
10529
|
+
blinded = subprocess.run(
|
|
10530
|
+
[sys.executable, runner, "--scenario", name],
|
|
10531
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
10532
|
+
cwd=str(ROOT),
|
|
10533
|
+
env={**os.environ, "PATH": str(ROOT), "PATHEXT": ""},
|
|
10534
|
+
)
|
|
10535
|
+
blinded_out = (blinded.stdout or "") + (blinded.stderr or "")
|
|
10536
|
+
if blinded.returncode != 3 or "skipped" not in blinded_out:
|
|
10537
|
+
report(
|
|
10538
|
+
f"{label}: {name} must exit 3 with a reported skip when its CLI "
|
|
10539
|
+
f"cannot be resolved; got {blinded.returncode}."
|
|
10540
|
+
)
|
|
10541
|
+
report(blinded_out.strip())
|
|
10542
|
+
return 1
|
|
10543
|
+
if "codex" not in blinded_out:
|
|
10544
|
+
report(f"{label}: {name}'s skip does not name the runtime it needed.")
|
|
10545
|
+
report(blinded_out.strip())
|
|
10546
|
+
return 1
|
|
10547
|
+
|
|
10548
|
+
if label not in {name for name, _ in SCENARIOS}:
|
|
10549
|
+
report(f"{label}: the scenario registry does not include it.")
|
|
10550
|
+
return 1
|
|
10551
|
+
report(f"{label} scenario passed.")
|
|
10552
|
+
return 0
|
|
10553
|
+
|
|
10554
|
+
|
|
10555
|
+
def sibling_scope_tasks(sibling_checked: bool, sibling_touch: str) -> str:
|
|
10556
|
+
"""Two tasks: 1.1 owns `shared.js`, 1.2 is the one being completed."""
|
|
10557
|
+
|
|
10558
|
+
def task(task_id: str, title: str, checked: bool, touch: str) -> str:
|
|
10559
|
+
mark = "x" if checked else " "
|
|
10560
|
+
return (
|
|
10561
|
+
f"- [{mark}] {task_id} {title}\n"
|
|
10562
|
+
" - Covers:\n"
|
|
10563
|
+
" - E1: public behavior\n"
|
|
10564
|
+
" - Touch:\n"
|
|
10565
|
+
+ "".join(f" - {entry}\n" for entry in touch.split(","))
|
|
10566
|
+
+ " - Verify:\n"
|
|
10567
|
+
" - Strategy: evidence-first\n"
|
|
10568
|
+
" - M1: node test.js\n"
|
|
10569
|
+
" - Evidence:\n"
|
|
10570
|
+
" - M1: verified\n"
|
|
10571
|
+
" - Review:\n"
|
|
10572
|
+
" - Status: pass\n"
|
|
10573
|
+
" - Acceptance check: reviewed\n"
|
|
10574
|
+
" - Scope check: reviewed\n"
|
|
10575
|
+
" - Findings: none\n"
|
|
10576
|
+
" - Blocker: none\n"
|
|
10577
|
+
)
|
|
10578
|
+
|
|
10579
|
+
return (
|
|
10580
|
+
"# Tasks\n\n"
|
|
10581
|
+
"## 1. Work\n\n"
|
|
10582
|
+
+ task("1.1", "Own the shared file", sibling_checked, sibling_touch)
|
|
10583
|
+
+ "\n"
|
|
10584
|
+
+ task("1.2", "Own its own file", False, "src/mine.js")
|
|
10585
|
+
+ "\n## Expectation Coverage\n\n- None.\n"
|
|
10586
|
+
)
|
|
10587
|
+
|
|
10588
|
+
|
|
10589
|
+
def validate_completed_sibling_attribution_scenario() -> int:
|
|
10590
|
+
"""Issue #13 item 2: a finished task's uncommitted work blamed the next one.
|
|
10591
|
+
|
|
10592
|
+
`--base HEAD` cannot tell who wrote a path, so a sibling that already passed
|
|
10593
|
+
its own completion gate had its files attributed to whoever ran next. The
|
|
10594
|
+
workaround — commit per task — was correct but implicit, and the diagnostic
|
|
10595
|
+
named a file the author never touched.
|
|
10596
|
+
"""
|
|
10597
|
+
label = "completed-sibling-attribution"
|
|
10598
|
+
|
|
10599
|
+
def complete(sibling_checked: bool, sibling_touch: str = "src/shared.js"):
|
|
10600
|
+
with tempfile.TemporaryDirectory(prefix="keel-sibling-scope-") as raw:
|
|
10601
|
+
repo = Path(raw)
|
|
10602
|
+
for name in ("src/shared.js", "src/mine.js", "src/stray.js"):
|
|
10603
|
+
write_text(repo / name, "// base\n")
|
|
10604
|
+
write_text(
|
|
10605
|
+
repo / "openspec/changes/demo/tasks.md",
|
|
10606
|
+
sibling_scope_tasks(sibling_checked, sibling_touch),
|
|
10607
|
+
)
|
|
10608
|
+
for args in (
|
|
10609
|
+
["init", "--quiet"],
|
|
10610
|
+
["-c", "user.email=t@e", "-c", "user.name=t", "add", "-A"],
|
|
10611
|
+
[
|
|
10612
|
+
"-c", "user.email=t@e", "-c", "user.name=t",
|
|
10613
|
+
"commit", "--quiet", "-m", "base",
|
|
10614
|
+
],
|
|
10615
|
+
):
|
|
10616
|
+
done = subprocess.run(
|
|
10617
|
+
["git", *args], cwd=repo, capture_output=True, text=True
|
|
10618
|
+
)
|
|
10619
|
+
if done.returncode != 0:
|
|
10620
|
+
report(f"{label}: git {args[0]} failed: {done.stderr}")
|
|
10621
|
+
return None
|
|
10622
|
+
# The sibling's work and an undeclared stray, both uncommitted.
|
|
10623
|
+
write_text(repo / "src/shared.js", "// sibling's uncommitted work\n")
|
|
10624
|
+
write_text(repo / "src/stray.js", "// nobody declared this\n")
|
|
10625
|
+
result = run_keel(
|
|
10626
|
+
repo, "gate", "task-complete",
|
|
10627
|
+
"--change", "demo", "--task", "1.2", "--base", "HEAD", "--json",
|
|
10628
|
+
)
|
|
10629
|
+
return json.loads(result.stdout) if result.stdout else {}
|
|
10630
|
+
|
|
10631
|
+
owned = complete(sibling_checked=True)
|
|
10632
|
+
if owned is None:
|
|
10633
|
+
return 1
|
|
10634
|
+
outside = [
|
|
10635
|
+
item.get("message", "")
|
|
10636
|
+
for item in owned.get("problems", [])
|
|
10637
|
+
if item.get("code") == "outside-touch"
|
|
10638
|
+
]
|
|
10639
|
+
if any("src/shared.js" in message for message in outside):
|
|
10640
|
+
report(
|
|
10641
|
+
f"{label}: a completed sibling's declared file was still attributed "
|
|
10642
|
+
f"to the selected task: {outside}"
|
|
10643
|
+
)
|
|
10644
|
+
return 1
|
|
10645
|
+
if not any("src/stray.js" in message for message in outside):
|
|
10646
|
+
report(
|
|
10647
|
+
f"{label}: a path no task declares must still fail: {outside}"
|
|
10648
|
+
)
|
|
10649
|
+
return 1
|
|
10650
|
+
warnings = " ".join(owned.get("warnings", []))
|
|
10651
|
+
if "src/shared.js" not in warnings or "1.1" not in warnings:
|
|
10652
|
+
report(
|
|
10653
|
+
f"{label}: the exclusion must be reported, naming the path and the "
|
|
10654
|
+
f"completed task that declares it; got {owned.get('warnings')}"
|
|
10655
|
+
)
|
|
10656
|
+
return 1
|
|
10657
|
+
|
|
10658
|
+
unchecked = complete(sibling_checked=False)
|
|
10659
|
+
if unchecked is None:
|
|
10660
|
+
return 1
|
|
10661
|
+
unchecked_outside = [
|
|
10662
|
+
item.get("message", "")
|
|
10663
|
+
for item in unchecked.get("problems", [])
|
|
10664
|
+
if item.get("code") == "outside-touch"
|
|
10665
|
+
]
|
|
10666
|
+
if not any("src/shared.js" in message for message in unchecked_outside):
|
|
10667
|
+
report(
|
|
10668
|
+
f"{label}: an unchecked sibling's Touch must grant nothing: "
|
|
10669
|
+
f"{unchecked_outside}"
|
|
10670
|
+
)
|
|
10671
|
+
return 1
|
|
10672
|
+
|
|
10673
|
+
no_touch = complete(sibling_checked=True, sibling_touch="none")
|
|
10674
|
+
if no_touch is None:
|
|
10675
|
+
return 1
|
|
10676
|
+
none_outside = [
|
|
10677
|
+
item.get("message", "")
|
|
10678
|
+
for item in no_touch.get("problems", [])
|
|
10679
|
+
if item.get("code") == "outside-touch"
|
|
10680
|
+
]
|
|
10681
|
+
if not any("src/shared.js" in message for message in none_outside):
|
|
10682
|
+
report(
|
|
10683
|
+
f"{label}: a sibling whose Touch is none must contribute no claim: "
|
|
10684
|
+
f"{none_outside}"
|
|
10685
|
+
)
|
|
10686
|
+
return 1
|
|
10687
|
+
|
|
10688
|
+
if label not in {name for name, _ in SCENARIOS}:
|
|
10689
|
+
report(f"{label}: the scenario registry does not include it.")
|
|
10690
|
+
return 1
|
|
10691
|
+
report(f"{label} scenario passed.")
|
|
10692
|
+
return 0
|
|
10693
|
+
|
|
10694
|
+
|
|
10695
|
+
def validate_resident_topic_matching_scenario() -> int:
|
|
10696
|
+
"""Issue #15 item 1: required entries were named topics, matched as prose.
|
|
10697
|
+
|
|
10698
|
+
The bootstrap is under a line and byte budget, so its wording gets rewritten
|
|
10699
|
+
to fit — and every rewrite of a pinned sentence failed the check that was
|
|
10700
|
+
supposed to prove only that the topic was still covered.
|
|
10701
|
+
"""
|
|
10702
|
+
label = "resident-topic-matching"
|
|
10703
|
+
source = (ROOT / "assets/bootstrap/AGENTS.md").read_text(encoding="utf-8")
|
|
10704
|
+
original = "Touch is the write boundary for product files;"
|
|
10705
|
+
if original not in source:
|
|
10706
|
+
report(
|
|
10707
|
+
f"{label}: the fixture's anchor sentence is not in the bootstrap; "
|
|
10708
|
+
"update this scenario alongside the wording."
|
|
10709
|
+
)
|
|
10710
|
+
return 1
|
|
10711
|
+
|
|
10712
|
+
def errors_for(text: str) -> list[str]:
|
|
10713
|
+
with tempfile.TemporaryDirectory(prefix="keel-resident-topic-") as raw:
|
|
10714
|
+
root = Path(raw)
|
|
10715
|
+
write_text(root / "assets/bootstrap/AGENTS.md", text)
|
|
10716
|
+
found: list[str] = []
|
|
10717
|
+
validate_resident_blocks(found, root)
|
|
10718
|
+
return found
|
|
10719
|
+
|
|
10720
|
+
def touch_errors(text: str) -> list[str]:
|
|
10721
|
+
return [item for item in errors_for(text) if "Touch" in item or "bound" in item]
|
|
10722
|
+
|
|
10723
|
+
baseline = errors_for(source)
|
|
10724
|
+
if baseline:
|
|
10725
|
+
report(f"{label}: the unmodified bootstrap must pass: {baseline}")
|
|
10726
|
+
return 1
|
|
10727
|
+
|
|
10728
|
+
# A rewording that keeps both concepts in one statement must pass.
|
|
10729
|
+
reworded = source.replace(
|
|
10730
|
+
original, "Touch bounds product writes, not the task's own records;"
|
|
10731
|
+
)
|
|
10732
|
+
if touch_errors(reworded):
|
|
10733
|
+
report(
|
|
10734
|
+
f"{label}: a rewording that keeps the topic was rejected: "
|
|
10735
|
+
f"{touch_errors(reworded)}"
|
|
10736
|
+
)
|
|
10737
|
+
return 1
|
|
10738
|
+
|
|
10739
|
+
# Deleting the statement must still fail.
|
|
10740
|
+
deleted = source.replace(original, "")
|
|
10741
|
+
if not touch_errors(deleted):
|
|
10742
|
+
report(f"{label}: deleting the boundary statement did not fail the check.")
|
|
10743
|
+
return 1
|
|
10744
|
+
|
|
10745
|
+
# Mentioning only one of the topic's words must not satisfy it.
|
|
10746
|
+
partial = source.replace(original, "Touch the files you declared;")
|
|
10747
|
+
if not touch_errors(partial):
|
|
10748
|
+
report(
|
|
10749
|
+
f"{label}: a statement mentioning only Touch, with no boundary "
|
|
10750
|
+
"concept, satisfied the topic."
|
|
10751
|
+
)
|
|
10752
|
+
return 1
|
|
10753
|
+
|
|
10754
|
+
# A renamed command must still fail, and be reported as a literal.
|
|
10755
|
+
renamed = source.replace("keel context", "keel status")
|
|
10756
|
+
literal_errors = [item for item in errors_for(renamed) if "keel context" in item]
|
|
10757
|
+
if not literal_errors:
|
|
10758
|
+
report(f"{label}: renaming a required command did not fail the check.")
|
|
10759
|
+
return 1
|
|
10760
|
+
if not any("literal" in item for item in literal_errors):
|
|
10761
|
+
report(
|
|
10762
|
+
f"{label}: a missing command must be reported as a missing literal, "
|
|
10763
|
+
f"distinguishably from a missing topic: {literal_errors}"
|
|
10764
|
+
)
|
|
10765
|
+
return 1
|
|
10766
|
+
if any("literal" in item for item in touch_errors(deleted)):
|
|
10767
|
+
report(
|
|
10768
|
+
f"{label}: a missing topic must not be reported as a missing "
|
|
10769
|
+
f"literal: {touch_errors(deleted)}"
|
|
10770
|
+
)
|
|
10771
|
+
return 1
|
|
10772
|
+
|
|
10773
|
+
if (ROOT / "assets/bootstrap/AGENTS.md").read_text(encoding="utf-8") != source:
|
|
10774
|
+
report(f"{label}: the shipped bootstrap was left modified.")
|
|
10775
|
+
return 1
|
|
10776
|
+
if label not in {name for name, _ in SCENARIOS}:
|
|
10777
|
+
report(f"{label}: the scenario registry does not include it.")
|
|
10778
|
+
return 1
|
|
10779
|
+
report(f"{label} scenario passed.")
|
|
10780
|
+
return 0
|
|
10781
|
+
|
|
10782
|
+
|
|
10783
|
+
def validate_repo_action_mode_scenario() -> int:
|
|
10784
|
+
"""Issue #8 example 2: a repository action had no legal contract.
|
|
10785
|
+
|
|
10786
|
+
A task whose whole effect is the repository's first commit writes no
|
|
10787
|
+
worktree file, so it has no concrete Touch; it is not diagnose-only,
|
|
10788
|
+
because it has real side effects needing evidence; and `Touch: none` was
|
|
10789
|
+
accepted for no other mode. The author was forced to name a path the task
|
|
10790
|
+
did not write, which then tripped the drift defect in example 1.
|
|
10791
|
+
"""
|
|
10792
|
+
label = "repo-action-mode"
|
|
10793
|
+
|
|
10794
|
+
def compile_task(repo: Path, mode: str, touch: str):
|
|
10795
|
+
write_text(repo / "openspec/changes/demo/tasks.md", mode_fixture_tasks(mode, touch))
|
|
10796
|
+
return run_keel(
|
|
10797
|
+
repo, "gate", "task-start",
|
|
10798
|
+
"--change", "demo", "--task", "1.1", "--no-guard", "--json",
|
|
10799
|
+
)
|
|
10800
|
+
|
|
10801
|
+
with tempfile.TemporaryDirectory(prefix="keel-repo-action-") as raw:
|
|
10802
|
+
repo = Path(raw)
|
|
10803
|
+
|
|
10804
|
+
started = compile_task(repo, "repo-action", "none")
|
|
10805
|
+
if started.returncode != 0:
|
|
10806
|
+
report(f"{label}: `Mode: repo-action` with `Touch: none` was rejected.")
|
|
10807
|
+
report((started.stderr or started.stdout).strip())
|
|
10808
|
+
return 1
|
|
10809
|
+
capsule = json.loads(started.stdout).get("contract", {}).get("capsule", {})
|
|
10810
|
+
prohibitions = capsule.get("prohibitions", [])
|
|
10811
|
+
if capsule.get("mode") != "repo-action":
|
|
10812
|
+
report(f"{label}: the capsule did not record the repo-action mode.")
|
|
10813
|
+
return 1
|
|
10814
|
+
if "must not write product files" not in prohibitions:
|
|
10815
|
+
report(
|
|
10816
|
+
f"{label}: repo-action must prohibit product writes; got "
|
|
10817
|
+
f"{prohibitions}."
|
|
10818
|
+
)
|
|
10819
|
+
return 1
|
|
10820
|
+
if "must not commit" in prohibitions:
|
|
10821
|
+
report(
|
|
10822
|
+
f"{label}: repo-action must be the mode that may commit; got "
|
|
10823
|
+
f"{prohibitions}."
|
|
10824
|
+
)
|
|
10825
|
+
return 1
|
|
10826
|
+
|
|
10827
|
+
# Every other mode keeps the commit prohibition.
|
|
10828
|
+
for mode, touch in (
|
|
10829
|
+
("implementation", "src/feature.js"),
|
|
10830
|
+
("plan-first", "src/feature.js"),
|
|
10831
|
+
("diagnose-only", "none"),
|
|
10832
|
+
):
|
|
10833
|
+
other = compile_task(repo, mode, touch)
|
|
10834
|
+
if other.returncode != 0:
|
|
10835
|
+
report(f"{label}: `Mode: {mode}` regressed and no longer compiles.")
|
|
10836
|
+
report((other.stderr or other.stdout).strip())
|
|
10837
|
+
return 1
|
|
10838
|
+
other_capsule = (
|
|
10839
|
+
json.loads(other.stdout).get("contract", {}).get("capsule", {})
|
|
10840
|
+
)
|
|
10841
|
+
if "must not commit" not in other_capsule.get("prohibitions", []):
|
|
10842
|
+
report(f"{label}: `Mode: {mode}` lost the commit prohibition.")
|
|
10843
|
+
return 1
|
|
10844
|
+
product_write_prohibited = (
|
|
10845
|
+
"must not write product files"
|
|
10846
|
+
in other_capsule.get("prohibitions", [])
|
|
10847
|
+
)
|
|
10848
|
+
if product_write_prohibited != (mode == "diagnose-only"):
|
|
10849
|
+
report(
|
|
10850
|
+
f"{label}: `Mode: {mode}` changed its product-write "
|
|
10851
|
+
"prohibition."
|
|
10852
|
+
)
|
|
10853
|
+
return 1
|
|
10854
|
+
|
|
10855
|
+
# repo-action means no worktree writes, so a concrete Touch contradicts it.
|
|
10856
|
+
with_touch = compile_task(repo, "repo-action", "src/feature.js")
|
|
10857
|
+
with_touch_problems = (
|
|
10858
|
+
json.loads(with_touch.stdout).get("problems", [])
|
|
10859
|
+
if with_touch.stdout
|
|
10860
|
+
else []
|
|
10861
|
+
)
|
|
10862
|
+
touch_message = " ".join(
|
|
10863
|
+
item.get("message", "")
|
|
10864
|
+
for item in with_touch_problems
|
|
10865
|
+
if item.get("code") == "invalid-touch"
|
|
10866
|
+
)
|
|
10867
|
+
if with_touch.returncode == 0 or "Touch: none" not in touch_message:
|
|
10868
|
+
report(
|
|
10869
|
+
f"{label}: repo-action with a concrete Touch must fail with a "
|
|
10870
|
+
"diagnostic naming the `Touch: none` it requires."
|
|
10871
|
+
)
|
|
10872
|
+
report((with_touch.stderr or with_touch.stdout).strip())
|
|
10873
|
+
return 1
|
|
10874
|
+
|
|
10875
|
+
unsupported = compile_task(repo, "repo-actions", "none")
|
|
10876
|
+
unsupported_message = " ".join(
|
|
10877
|
+
item.get("message", "")
|
|
10878
|
+
for item in (
|
|
10879
|
+
json.loads(unsupported.stdout).get("problems", [])
|
|
10880
|
+
if unsupported.stdout
|
|
10881
|
+
else []
|
|
10882
|
+
)
|
|
10883
|
+
if item.get("code") == "unsupported-mode"
|
|
10884
|
+
)
|
|
10885
|
+
missing = [
|
|
10886
|
+
mode
|
|
10887
|
+
for mode in ("implementation", "diagnose-only", "plan-first", "repo-action")
|
|
10888
|
+
if mode not in unsupported_message
|
|
10889
|
+
]
|
|
10890
|
+
if unsupported.returncode == 0 or missing:
|
|
10891
|
+
report(
|
|
10892
|
+
f"{label}: the unsupported-mode diagnostic must list every "
|
|
10893
|
+
f"supported mode; missing {missing}."
|
|
10894
|
+
)
|
|
10895
|
+
report((unsupported.stderr or unsupported.stdout).strip())
|
|
10896
|
+
return 1
|
|
10897
|
+
|
|
10898
|
+
if label not in {name for name, _ in SCENARIOS}:
|
|
10899
|
+
report(f"{label}: the scenario registry does not include it.")
|
|
10900
|
+
return 1
|
|
10901
|
+
report(f"{label} scenario passed.")
|
|
10902
|
+
return 0
|
|
10903
|
+
|
|
10904
|
+
|
|
10905
|
+
def validate_touch_guard_record_layer_scenario() -> int:
|
|
10906
|
+
"""Issue #8: the guard denied what the completion gate already forgives.
|
|
10907
|
+
|
|
10908
|
+
`scopeProblems` exempts the selected change's own `openspec/changes/<change>/`
|
|
10909
|
+
directory from outside-Touch attribution, but the guard denied writes there
|
|
10910
|
+
and treated the byte hash of that change's tasks.md as authority, so ticking
|
|
10911
|
+
a checkbox or appending Evidence locked the task out of its own bookkeeping.
|
|
10912
|
+
"""
|
|
10913
|
+
label = "touch-guard-record-layer"
|
|
10914
|
+
with tempfile.TemporaryDirectory(prefix="keel-record-layer-") as raw:
|
|
10915
|
+
repo = Path(raw)
|
|
10916
|
+
tasks = repo / "openspec/changes/demo/tasks.md"
|
|
10917
|
+
spec = repo / "openspec/specs/demo-cap/spec.md"
|
|
10918
|
+
write_text(tasks, record_layer_tasks())
|
|
10919
|
+
write_text(spec, RECORD_LAYER_SPEC)
|
|
10920
|
+
write_text(repo / "openspec/changes/other/tasks.md", "# Tasks\n")
|
|
10921
|
+
write_text(repo / "keel/archive/follow-ups/note.md", "# Note\n")
|
|
10922
|
+
|
|
10923
|
+
started = run_keel(
|
|
10924
|
+
repo, "guard", "start", "--change", "demo", "--task", "1.1", "--json"
|
|
10925
|
+
)
|
|
10926
|
+
if started.returncode != 0:
|
|
10927
|
+
report(f"{label}: guard start failed on the fixture.")
|
|
10928
|
+
report((started.stderr or started.stdout).strip())
|
|
10929
|
+
return 1
|
|
10930
|
+
manifest = json.loads(started.stdout).get("manifest", {})
|
|
10931
|
+
authority = [entry.get("path") for entry in manifest.get("authority", [])]
|
|
10932
|
+
if not any(item == "openspec/specs/demo-cap/spec.md" for item in authority):
|
|
10933
|
+
report(
|
|
10934
|
+
f"{label}: the fixture does not record an authority file outside "
|
|
10935
|
+
f"the change directory, so the negative case is untested: {authority}"
|
|
10936
|
+
)
|
|
10937
|
+
return 1
|
|
10938
|
+
|
|
10939
|
+
# The record layer: writable although Touch never named it.
|
|
10940
|
+
if expect_guard_allow(repo, tasks, f"{label} record write"):
|
|
10941
|
+
return 1
|
|
10942
|
+
# A record write already made must not lock the task out of its product
|
|
10943
|
+
# writes — this is the drift half of the reported defect.
|
|
10944
|
+
write_text(tasks, record_layer_tasks().replace("M1: pending", "M1: done"))
|
|
10945
|
+
if expect_guard_allow(repo, repo / "src/feature.js", f"{label} after record write"):
|
|
10946
|
+
return 1
|
|
10947
|
+
|
|
10948
|
+
# The layer is exactly this change's directory, nothing wider.
|
|
10949
|
+
if expect_guard_deny(
|
|
10950
|
+
repo,
|
|
10951
|
+
repo / "openspec/changes/other/tasks.md",
|
|
10952
|
+
["outside Touch"],
|
|
10953
|
+
f"{label} other change",
|
|
10954
|
+
):
|
|
10955
|
+
return 1
|
|
10956
|
+
if expect_guard_deny(
|
|
10957
|
+
repo,
|
|
10958
|
+
repo / "keel/archive/follow-ups/note.md",
|
|
10959
|
+
["outside Touch"],
|
|
10960
|
+
f"{label} archive tree",
|
|
10961
|
+
):
|
|
10962
|
+
return 1
|
|
10963
|
+
|
|
10964
|
+
# Authority outside the change directory still hashes and still denies.
|
|
10965
|
+
write_text(spec, RECORD_LAYER_SPEC.replace("it passes", "it passes twice"))
|
|
10966
|
+
if expect_guard_deny(
|
|
10967
|
+
repo,
|
|
10968
|
+
repo / "src/feature.js",
|
|
10969
|
+
["authority drift"],
|
|
10970
|
+
f"{label} real authority drift",
|
|
10971
|
+
):
|
|
10972
|
+
return 1
|
|
10973
|
+
write_text(spec, RECORD_LAYER_SPEC)
|
|
10974
|
+
|
|
10975
|
+
status = run_keel(repo, "guard", "status", "--json")
|
|
10976
|
+
status_payload = json.loads(status.stdout) if status.stdout else {}
|
|
10977
|
+
codes = [item.get("code") for item in status_payload.get("problems", [])]
|
|
10978
|
+
if status_payload.get("status") != "active" or codes:
|
|
10979
|
+
report(
|
|
10980
|
+
f"{label}: guard status reported {status_payload.get('status')!r} "
|
|
10981
|
+
f"with problems {codes} after a record write; a checkbox or "
|
|
10982
|
+
"Evidence write is not authority drift."
|
|
10983
|
+
)
|
|
10984
|
+
return 1
|
|
10985
|
+
|
|
10986
|
+
# A real contract edit in the same file must still hard-stop, through the
|
|
10987
|
+
# fingerprint rather than through byte hashing.
|
|
10988
|
+
write_text(tasks, record_layer_tasks(touch="src/other.js"))
|
|
10989
|
+
drifted = run_keel(repo, "guard", "status", "--json")
|
|
10990
|
+
drifted_codes = [
|
|
10991
|
+
item.get("code")
|
|
10992
|
+
for item in (json.loads(drifted.stdout) if drifted.stdout else {}).get(
|
|
10993
|
+
"problems", []
|
|
10994
|
+
)
|
|
10995
|
+
]
|
|
10996
|
+
if "fingerprint-drift" not in drifted_codes:
|
|
10997
|
+
report(
|
|
10998
|
+
f"{label}: editing the task's Touch line did not report "
|
|
10999
|
+
f"fingerprint drift; got {drifted_codes}."
|
|
11000
|
+
)
|
|
11001
|
+
return 1
|
|
11002
|
+
|
|
11003
|
+
# Once checked, product writes stop but the task can still finish its
|
|
11004
|
+
# own records — the completion gate requires that Evidence.
|
|
11005
|
+
write_text(tasks, record_layer_tasks(checked=True))
|
|
11006
|
+
if expect_guard_deny(
|
|
11007
|
+
repo,
|
|
11008
|
+
repo / "src/feature.js",
|
|
11009
|
+
["checked complete"],
|
|
11010
|
+
f"{label} completed product write",
|
|
11011
|
+
):
|
|
11012
|
+
return 1
|
|
11013
|
+
if expect_guard_allow(repo, tasks, f"{label} completed record write"):
|
|
11014
|
+
return 1
|
|
11015
|
+
|
|
11016
|
+
if label not in {name for name, _ in SCENARIOS}:
|
|
11017
|
+
report(f"{label}: the scenario registry does not include it.")
|
|
11018
|
+
return 1
|
|
11019
|
+
report(f"{label} scenario passed.")
|
|
11020
|
+
return 0
|
|
11021
|
+
|
|
11022
|
+
|
|
10370
11023
|
def validate_touch_write_guard_scenario() -> int:
|
|
10371
11024
|
with tempfile.TemporaryDirectory(
|
|
10372
11025
|
prefix="keel-touch-guard-", ignore_cleanup_errors=True
|
|
@@ -11156,18 +11809,32 @@ def validate_touch_guard_drift_scenario() -> int:
|
|
|
11156
11809
|
"Exercise guarded feature", "Exercise guarded feature differently"
|
|
11157
11810
|
),
|
|
11158
11811
|
)
|
|
11159
|
-
|
|
11160
|
-
|
|
11161
|
-
|
|
11162
|
-
|
|
11163
|
-
|
|
11812
|
+
# A contract edit inside the guarded change's own directory is caught
|
|
11813
|
+
# where the capsule is compiled, not at the next write: the hook cannot
|
|
11814
|
+
# compile, so it cannot separate this from a checkbox or Evidence write
|
|
11815
|
+
# in the same file. It therefore allows the write and `guard status`
|
|
11816
|
+
# reports the drift. Authority *outside* that directory still denies at
|
|
11817
|
+
# write time — see the touch-guard-record-layer scenario.
|
|
11818
|
+
if expect_guard_allow(
|
|
11819
|
+
repo, repo / "src/feature.js", "touch-guard-drift contract edit"
|
|
11164
11820
|
):
|
|
11165
11821
|
return 1
|
|
11166
11822
|
status = run_keel(repo, "guard", "status", "--json")
|
|
11167
|
-
|
|
11823
|
+
status_payload = json.loads(status.stdout) if status.stdout else {}
|
|
11824
|
+
if status.returncode != 3 or status_payload.get("status") != "drifted":
|
|
11168
11825
|
report("touch-guard-drift: status did not report drifted.")
|
|
11169
11826
|
report((status.stderr or status.stdout).strip())
|
|
11170
11827
|
return 1
|
|
11828
|
+
if not any(
|
|
11829
|
+
item.get("code") == "fingerprint-drift"
|
|
11830
|
+
for item in status_payload.get("problems", [])
|
|
11831
|
+
):
|
|
11832
|
+
report(
|
|
11833
|
+
"touch-guard-drift: the contract edit was not reported as "
|
|
11834
|
+
"fingerprint drift by the check that compiles the capsule."
|
|
11835
|
+
)
|
|
11836
|
+
report(status.stdout or "")
|
|
11837
|
+
return 1
|
|
11171
11838
|
|
|
11172
11839
|
restarted = run_keel(
|
|
11173
11840
|
repo, "guard", "start", "--change", "demo", "--task", "1.1", "--json"
|
|
@@ -11184,30 +11851,41 @@ def validate_touch_guard_drift_scenario() -> int:
|
|
|
11184
11851
|
):
|
|
11185
11852
|
return 1
|
|
11186
11853
|
|
|
11854
|
+
# A fingerprint-neutral edit to the task's own file is a record write,
|
|
11855
|
+
# not drift. This used to fail closed, which is the defect issue #8
|
|
11856
|
+
# reports: appending Evidence blocked every following write.
|
|
11187
11857
|
tasks_path.write_text(
|
|
11188
11858
|
tasks_path.read_text(encoding="utf-8") + "\n", encoding="utf-8"
|
|
11189
11859
|
)
|
|
11190
|
-
if
|
|
11191
|
-
repo,
|
|
11192
|
-
repo / "src/feature.js",
|
|
11193
|
-
["drift", "keel guard start"],
|
|
11194
|
-
"touch-guard-drift cosmetic edit fails closed",
|
|
11860
|
+
if expect_guard_allow(
|
|
11861
|
+
repo, repo / "src/feature.js", "touch-guard-drift cosmetic edit"
|
|
11195
11862
|
):
|
|
11196
11863
|
return 1
|
|
11197
|
-
cosmetic = run_keel(
|
|
11198
|
-
|
|
11864
|
+
cosmetic = run_keel(repo, "guard", "status", "--json")
|
|
11865
|
+
cosmetic_payload = json.loads(cosmetic.stdout) if cosmetic.stdout else {}
|
|
11866
|
+
if cosmetic_payload.get("status") != "active" or cosmetic_payload.get(
|
|
11867
|
+
"problems"
|
|
11868
|
+
):
|
|
11869
|
+
report(
|
|
11870
|
+
"touch-guard-drift: a fingerprint-neutral edit to the task's "
|
|
11871
|
+
"own file was reported as a guard problem."
|
|
11872
|
+
)
|
|
11873
|
+
report(cosmetic.stdout or "")
|
|
11874
|
+
return 1
|
|
11875
|
+
restarted_cosmetic = run_keel(
|
|
11876
|
+
repo, "guard", "start", "--change", "demo", "--task", "1.1",
|
|
11877
|
+
"--force", "--json",
|
|
11199
11878
|
)
|
|
11200
|
-
if
|
|
11879
|
+
if restarted_cosmetic.returncode != 0:
|
|
11201
11880
|
report("touch-guard-drift: cosmetic restart failed.")
|
|
11881
|
+
report((restarted_cosmetic.stderr or restarted_cosmetic.stdout).strip())
|
|
11202
11882
|
return 1
|
|
11203
|
-
third = json.loads(
|
|
11883
|
+
third = json.loads(restarted_cosmetic.stdout)["manifest"]["fingerprint"][
|
|
11884
|
+
"value"
|
|
11885
|
+
]
|
|
11204
11886
|
if third != second:
|
|
11205
11887
|
report("touch-guard-drift: cosmetic edit drifted the capsule fingerprint.")
|
|
11206
11888
|
return 1
|
|
11207
|
-
if expect_guard_allow(
|
|
11208
|
-
repo, repo / "src/feature.js", "touch-guard-drift cosmetic restart"
|
|
11209
|
-
):
|
|
11210
|
-
return 1
|
|
11211
11889
|
|
|
11212
11890
|
write_text(
|
|
11213
11891
|
tasks_path,
|
|
@@ -11290,6 +11968,33 @@ def validate_validation_runner_scenario() -> int:
|
|
|
11290
11968
|
report("validation-runner: README does not document the parallel runner.")
|
|
11291
11969
|
return 1
|
|
11292
11970
|
|
|
11971
|
+
# The full gate must actually run somewhere other than one author's machine:
|
|
11972
|
+
# a workflow drives the same single entry point on push and pull request,
|
|
11973
|
+
# and the release workflow keeps its own tag guard rather than becoming the
|
|
11974
|
+
# suite's only runner.
|
|
11975
|
+
workflow_path = ROOT / ".github/workflows/test.yml"
|
|
11976
|
+
if not workflow_path.is_file():
|
|
11977
|
+
report(
|
|
11978
|
+
"validation-runner: no .github/workflows/test.yml, so the full gate "
|
|
11979
|
+
"runs only in the local pre-push hook."
|
|
11980
|
+
)
|
|
11981
|
+
return 1
|
|
11982
|
+
workflow = workflow_path.read_text(encoding="utf-8")
|
|
11983
|
+
for needle in ("npm test", "npm ci", "pull_request", "push:", "ubuntu-latest"):
|
|
11984
|
+
if needle not in workflow:
|
|
11985
|
+
report(
|
|
11986
|
+
"validation-runner: the full-gate workflow does not declare "
|
|
11987
|
+
f"{needle!r}."
|
|
11988
|
+
)
|
|
11989
|
+
report(workflow)
|
|
11990
|
+
return 1
|
|
11991
|
+
publish = (ROOT / ".github/workflows/publish.yml").read_text(encoding="utf-8")
|
|
11992
|
+
if "does not match package.json version" not in publish:
|
|
11993
|
+
report(
|
|
11994
|
+
"validation-runner: the release workflow lost its tag/version guard."
|
|
11995
|
+
)
|
|
11996
|
+
return 1
|
|
11997
|
+
|
|
11293
11998
|
# Behavioral: the parallel machinery preserves registry order, keeps a
|
|
11294
11999
|
# passing scenario's buffered output, and fails loudly on a bad entry.
|
|
11295
12000
|
ordered = run_scenario_processes(
|
|
@@ -11437,6 +12142,14 @@ SCENARIOS: tuple = (
|
|
|
11437
12142
|
("native-helper-targets", validate_native_helper_targets_scenario),
|
|
11438
12143
|
("native-single-task-matrix", validate_native_single_task_matrix_scenario),
|
|
11439
12144
|
("touch-write-guard", validate_touch_write_guard_scenario),
|
|
12145
|
+
("touch-guard-record-layer", validate_touch_guard_record_layer_scenario),
|
|
12146
|
+
("repo-action-mode", validate_repo_action_mode_scenario),
|
|
12147
|
+
("runner-skip-accounting", validate_runner_skip_accounting_scenario),
|
|
12148
|
+
("resident-topic-matching", validate_resident_topic_matching_scenario),
|
|
12149
|
+
(
|
|
12150
|
+
"completed-sibling-attribution",
|
|
12151
|
+
validate_completed_sibling_attribution_scenario,
|
|
12152
|
+
),
|
|
11440
12153
|
("touch-guard-drift", validate_touch_guard_drift_scenario),
|
|
11441
12154
|
("touch-guard-surface", validate_touch_guard_surface_scenario),
|
|
11442
12155
|
("plugin-compaction-continuity", validate_plugin_compaction_continuity_scenario),
|
|
@@ -11553,6 +12266,19 @@ def validate_archive_overlay_hygiene(errors: list[str]) -> None:
|
|
|
11553
12266
|
)
|
|
11554
12267
|
|
|
11555
12268
|
|
|
12269
|
+
# Exit code 3 means "this scenario did not run because an external runtime it
|
|
12270
|
+
# probes is absent". 0 is pass, 1 is fail, 2 is an unknown scenario or a usage
|
|
12271
|
+
# error, so conflating an unavailable runtime with either would hide both. The
|
|
12272
|
+
# reason is narrow on purpose: an inconvenient assertion, a hard fixture, or a
|
|
12273
|
+
# platform difference is a failure, never a skip.
|
|
12274
|
+
SKIPPED = 3
|
|
12275
|
+
|
|
12276
|
+
|
|
12277
|
+
def skip_scenario(label: str, reason: str) -> int:
|
|
12278
|
+
report(f"{label} scenario skipped: {reason}")
|
|
12279
|
+
return SKIPPED
|
|
12280
|
+
|
|
12281
|
+
|
|
11556
12282
|
def run_baseline() -> int:
|
|
11557
12283
|
errors: list[str] = []
|
|
11558
12284
|
validate_manifest(errors)
|
|
@@ -11584,19 +12310,29 @@ def run_all(jobs: int) -> int:
|
|
|
11584
12310
|
# completion, buffered output is replayed in registry order, and every
|
|
11585
12311
|
# failure is named in one summary.
|
|
11586
12312
|
failures = []
|
|
12313
|
+
skipped = []
|
|
11587
12314
|
if run_baseline() != 0:
|
|
11588
12315
|
failures.append("baseline")
|
|
11589
12316
|
ordered = run_scenario_processes([name for name, _ in SCENARIOS], jobs)
|
|
11590
12317
|
for name, code, output in ordered:
|
|
11591
12318
|
sys.stdout.write(output)
|
|
11592
|
-
if code
|
|
12319
|
+
if code == SKIPPED:
|
|
12320
|
+
skipped.append(name)
|
|
12321
|
+
elif code != 0:
|
|
11593
12322
|
failures.append(name)
|
|
11594
12323
|
if failures:
|
|
11595
12324
|
report(f"validation --all failed for: {', '.join(failures)}")
|
|
11596
12325
|
return 1
|
|
11597
|
-
|
|
11598
|
-
|
|
12326
|
+
# The verified count excludes skips, so the number that lands in evidence is
|
|
12327
|
+
# the number actually run, and every skip is named with the run.
|
|
12328
|
+
verified = len(SCENARIOS) - len(skipped)
|
|
12329
|
+
summary = (
|
|
12330
|
+
f"validation --all passed: baseline plus {verified} "
|
|
12331
|
+
f"scenario{'' if verified == 1 else 's'}"
|
|
11599
12332
|
)
|
|
12333
|
+
if skipped:
|
|
12334
|
+
summary += f", {len(skipped)} skipped: {', '.join(skipped)}"
|
|
12335
|
+
report(f"{summary}.")
|
|
11600
12336
|
return 0
|
|
11601
12337
|
|
|
11602
12338
|
|
package/src/core/gates.js
CHANGED
|
@@ -316,7 +316,30 @@ function pathAllowed(candidate, touch) {
|
|
|
316
316
|
});
|
|
317
317
|
}
|
|
318
318
|
|
|
319
|
-
|
|
319
|
+
// A base comparison shows that a path changed, never who changed it. When a
|
|
320
|
+
// task of the same change is already checked complete and declares the path in
|
|
321
|
+
// its own Touch, blaming the selected task is a guess — and the wrong one often
|
|
322
|
+
// enough that the per-task commit habit became an implicit requirement whose
|
|
323
|
+
// diagnostic named a file the author never touched. Only a checked sibling
|
|
324
|
+
// counts: an unchecked task's Touch is a plan, not a record.
|
|
325
|
+
function completedSiblingOwners(tasks, selected) {
|
|
326
|
+
const owners = [];
|
|
327
|
+
for (const item of tasks || []) {
|
|
328
|
+
if (item.id === selected.id || !item.checked) continue;
|
|
329
|
+
const declared = touchEntries(item);
|
|
330
|
+
if (declared.length > 0) owners.push({ id: item.id, touch: declared });
|
|
331
|
+
}
|
|
332
|
+
return owners;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function scopeEvidence(
|
|
336
|
+
repo,
|
|
337
|
+
task,
|
|
338
|
+
base,
|
|
339
|
+
contract = null,
|
|
340
|
+
change = null,
|
|
341
|
+
tasks = null
|
|
342
|
+
) {
|
|
320
343
|
const dirtyPaths = gitPaths(repo);
|
|
321
344
|
if (!base) {
|
|
322
345
|
return {
|
|
@@ -358,17 +381,35 @@ function scopeEvidence(repo, task, base, contract = null, change = null) {
|
|
|
358
381
|
// attributed as outside Touch. Other changes' directories, the archive tree,
|
|
359
382
|
// and the specs/schemas trees stay attributable.
|
|
360
383
|
const authoringPrefix = change ? `openspec/changes/${change}/` : null;
|
|
361
|
-
const
|
|
384
|
+
const owners = completedSiblingOwners(tasks, task);
|
|
385
|
+
const warnings = [];
|
|
386
|
+
const outside = [];
|
|
387
|
+
const candidates = [...changed]
|
|
362
388
|
.map((item) => item.replace(/\\/g, "/"))
|
|
363
389
|
.filter((item) => item !== "keel/guard.json")
|
|
364
390
|
.filter((item) => !(authoringPrefix && item.startsWith(authoringPrefix)))
|
|
365
391
|
.filter((item) => !pathAllowed(item, touch))
|
|
366
392
|
.sort();
|
|
393
|
+
for (const item of candidates) {
|
|
394
|
+
const owner = owners.find((entry) => pathAllowed(item, entry.touch));
|
|
395
|
+
if (owner) {
|
|
396
|
+
// Reported, not silent: the comparison could not establish authorship,
|
|
397
|
+
// and resolving that in the selected task's favour is a judgment the
|
|
398
|
+
// author should see rather than a fact the gate discovered.
|
|
399
|
+
warnings.push(
|
|
400
|
+
`${item} is not attributed to this task: task ${owner.id} of the same `
|
|
401
|
+
+ "change is checked complete and declares it in Touch. A base "
|
|
402
|
+
+ "comparison cannot establish which task wrote it."
|
|
403
|
+
);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
outside.push(item);
|
|
407
|
+
}
|
|
367
408
|
return {
|
|
368
409
|
problems: outside.map((item) =>
|
|
369
410
|
problem("outside-touch", `Changed path is outside Touch: ${item}`)
|
|
370
411
|
),
|
|
371
|
-
warnings
|
|
412
|
+
warnings,
|
|
372
413
|
};
|
|
373
414
|
}
|
|
374
415
|
|
|
@@ -481,7 +522,8 @@ function taskComplete(repo, options) {
|
|
|
481
522
|
task,
|
|
482
523
|
options.base,
|
|
483
524
|
usableContract,
|
|
484
|
-
selection.change
|
|
525
|
+
selection.change,
|
|
526
|
+
selection.tasks
|
|
485
527
|
);
|
|
486
528
|
checks.problems.push(...scope.problems);
|
|
487
529
|
const status =
|
package/src/core/guard.js
CHANGED
|
@@ -247,7 +247,13 @@ function guardStatus(repo) {
|
|
|
247
247
|
+ "reauthorize through `keel gate task-start` and `keel guard start`.",
|
|
248
248
|
});
|
|
249
249
|
}
|
|
250
|
+
// Bytes under the guarded change's own directory are records the task
|
|
251
|
+
// produces — checkbox, Evidence, Review — not authority it must not touch.
|
|
252
|
+
// The fingerprint comparison above already covers every part of that file
|
|
253
|
+
// the capsule reads, so hashing it here only reported progress as drift.
|
|
254
|
+
const recordPrefix = `openspec/changes/${manifest.change}/`;
|
|
250
255
|
for (const entry of manifest.authority) {
|
|
256
|
+
if (entry.path.replace(/\\/g, "/").startsWith(recordPrefix)) continue;
|
|
251
257
|
const file = path.join(repo, entry.path);
|
|
252
258
|
if (!fs.existsSync(file) || sha256(fs.readFileSync(file)) !== entry.sha256) {
|
|
253
259
|
problems.push({
|
|
@@ -8,8 +8,17 @@ const SUPPORTED_MODES = new Set([
|
|
|
8
8
|
"implementation",
|
|
9
9
|
"diagnose-only",
|
|
10
10
|
"plan-first",
|
|
11
|
+
// A task whose whole effect is an authorized repository-level action — the
|
|
12
|
+
// repository's first commit, a tag — writes no worktree file, so it has no
|
|
13
|
+
// concrete Touch to declare. It is not diagnose-only either: it has real side
|
|
14
|
+
// effects that need evidence. It is the one mode that may commit.
|
|
15
|
+
"repo-action",
|
|
11
16
|
]);
|
|
12
17
|
|
|
18
|
+
// Modes whose contract is "no worktree writes", so `Touch: none` is required
|
|
19
|
+
// rather than merely tolerated.
|
|
20
|
+
const NO_WRITE_MODES = new Set(["diagnose-only", "repo-action"]);
|
|
21
|
+
|
|
13
22
|
const UNFILLED_TOKEN = /(<[^>]+>|\bTODO\b|\bTBD\b|\bplaceholder\b)/i;
|
|
14
23
|
|
|
15
24
|
function normalizeFieldText(value) {
|
|
@@ -241,16 +250,17 @@ function taskStartContractProblems(task) {
|
|
|
241
250
|
code: "unsupported-mode",
|
|
242
251
|
message:
|
|
243
252
|
`Unsupported Mode \`${mode}\`; expected implementation, `
|
|
244
|
-
+ "diagnose-only, or
|
|
253
|
+
+ "diagnose-only, plan-first, or repo-action.",
|
|
245
254
|
},
|
|
246
255
|
];
|
|
247
256
|
}
|
|
248
|
-
if (mode
|
|
257
|
+
if (NO_WRITE_MODES.has(mode)) {
|
|
249
258
|
if (touch.length !== 1 || touch[0].toLowerCase() !== "none") {
|
|
250
259
|
return [
|
|
251
260
|
{
|
|
252
261
|
code: "invalid-touch",
|
|
253
|
-
message:
|
|
262
|
+
message: `${mode} writes no worktree file and requires `
|
|
263
|
+
+ "`Touch: none`.",
|
|
254
264
|
},
|
|
255
265
|
];
|
|
256
266
|
}
|
|
@@ -819,13 +829,17 @@ function compileTaskContract(repo, change, task) {
|
|
|
819
829
|
helperAuthority: "read-only-evidence-only",
|
|
820
830
|
prohibitions: [
|
|
821
831
|
"must not change Acceptance",
|
|
822
|
-
|
|
832
|
+
// repo-action is the one mode whose authorized effect is the repository
|
|
833
|
+
// action itself, so it alone does not carry the commit prohibition.
|
|
834
|
+
// Whether the action it performed was the authorized one is a Review
|
|
835
|
+
// judgment; what the capsule fixes is the write posture.
|
|
836
|
+
...(mode === "repo-action" ? [] : ["must not commit"]),
|
|
823
837
|
"must not continue to another task",
|
|
824
838
|
"must not mark tasks complete",
|
|
825
839
|
"must not push",
|
|
826
840
|
"must not sync or archive",
|
|
827
841
|
"must not transfer Keel ownership",
|
|
828
|
-
...(mode
|
|
842
|
+
...(NO_WRITE_MODES.has(mode) ? ["must not write product files"] : []),
|
|
829
843
|
],
|
|
830
844
|
};
|
|
831
845
|
if (resolved.diagnostics.length > 0) {
|