@christang/keel 5.2.1 → 5.2.2
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/README.md +27 -0
- package/README.zh-CN.md +25 -0
- package/assets/bootstrap/AGENTS.md +1 -1
- package/assets/openspec/schemas/keel-spec-driven/templates/tasks.md +5 -1
- package/bin/keel.js +63 -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/install_to_repo.py +149 -0
- package/scripts/validate_plugin.py +298 -2
- package/src/core/task-contract.js +14 -4
package/README.md
CHANGED
|
@@ -139,6 +139,33 @@ command at the right moment. Three things make that happen.
|
|
|
139
139
|
So in day-to-day use you run two commands: `keel --init` once, and `keel --doctor` when you
|
|
140
140
|
want to check the wiring. Everything below is the vocabulary the agent uses on your behalf.
|
|
141
141
|
|
|
142
|
+
## Verification layering
|
|
143
|
+
|
|
144
|
+
Keel splits verification into two layers so a slow suite never blocks your push:
|
|
145
|
+
|
|
146
|
+
- **Fast inner-loop check** — seconds, run at a local pre-push and during iteration. It catches
|
|
147
|
+
obvious breakage without waiting.
|
|
148
|
+
- **Full gate** — the complete or slow suite (golden byte-determinism tests, cross-platform runs),
|
|
149
|
+
run at CI or at `keel gate change-close`.
|
|
150
|
+
|
|
151
|
+
A task's `Verify` checks stay fast; the slow or exhaustive layer belongs to the full gate, not the
|
|
152
|
+
local pre-push. Declare your fast check once in `keel/config.yaml`:
|
|
153
|
+
|
|
154
|
+
```yaml
|
|
155
|
+
fast_check: npm test -- --fast # your project's seconds-scale check
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Then opt into a repo-local fast pre-push:
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
keel --install --with-git-hooks # writes .githooks/pre-push, sets core.hooksPath (this repo only)
|
|
162
|
+
keel --doctor # reports fast_check, the pre-push hook, and core.hooksPath
|
|
163
|
+
keel --uninstall # reverts core.hooksPath when Keel set it
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`--with-git-hooks` is opt-in: a plain `keel --install` never touches git config, and the override
|
|
167
|
+
is repo-local and reversible.
|
|
168
|
+
|
|
142
169
|
## Domain lenses
|
|
143
170
|
|
|
144
171
|
Keel's core is pure process; it ships no domain knowledge of its own. Domain guidance lives in
|
package/README.zh-CN.md
CHANGED
|
@@ -133,6 +133,31 @@ keel --init → keel context → /opsx:apply(选一个 task)
|
|
|
133
133
|
所以日常使用里你真正要敲的只有两条:装配时的 `keel --init`,以及想体检时的 `keel --doctor`。
|
|
134
134
|
下面列出的,是 agent 替你使用的「命令词汇表」。
|
|
135
135
|
|
|
136
|
+
## 验证分层
|
|
137
|
+
|
|
138
|
+
Keel 把验证分成两层,让慢测试套件不再卡住你的 push:
|
|
139
|
+
|
|
140
|
+
- **快速内环检查(fast inner-loop)** —— 秒级,在本地 pre-push 和迭代时跑,挡住明显的破坏而无需等待。
|
|
141
|
+
- **全量门禁(full gate)** —— 完整或慢的套件(golden 字节确定性测试、跨平台运行),交给 CI 或
|
|
142
|
+
`keel gate change-close`。
|
|
143
|
+
|
|
144
|
+
任务的 `Verify` 检查保持快;慢的或穷尽的那一层归全量门禁,不放在本地 pre-push。在 `keel/config.yaml`
|
|
145
|
+
里声明一次你的快检命令:
|
|
146
|
+
|
|
147
|
+
```yaml
|
|
148
|
+
fast_check: npm test -- --fast # 你项目的秒级检查
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
然后按需装一个仓内快 pre-push:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
keel --install --with-git-hooks # 写 .githooks/pre-push,设 core.hooksPath(仅本仓)
|
|
155
|
+
keel --doctor # 报告 fast_check、pre-push hook、core.hooksPath
|
|
156
|
+
keel --uninstall # 当 core.hooksPath 由 Keel 设置时回退
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
`--with-git-hooks` 是显式 opt-in:普通 `keel --install` 绝不碰 git config,且这个覆盖仅限本仓、可逆。
|
|
160
|
+
|
|
136
161
|
## 命令参考
|
|
137
162
|
|
|
138
163
|
```bash
|
|
@@ -20,7 +20,11 @@
|
|
|
20
20
|
rendered-behavior, or evidence-first. Each M<n> check must prove the
|
|
21
21
|
resolved Acceptance through the public interface, not build-only or
|
|
22
22
|
shape-only evidence. Red-green strategies record per-label `.red` and
|
|
23
|
-
`.green` Evidence entries for the same check before completion.
|
|
23
|
+
`.green` Evidence entries for the same check before completion. An M<n>
|
|
24
|
+
check may carry an optional (fast) or (full) layer tag after its label
|
|
25
|
+
(e.g. `M1 (fast): …`) marking which checks the fast inner-loop pre-push
|
|
26
|
+
runs; an untagged check is full and change-close still needs every
|
|
27
|
+
M<n>'s Evidence. -->
|
|
24
28
|
- Strategy: <strategy>
|
|
25
29
|
- M1: <public behavior check>
|
|
26
30
|
- Evidence:
|
package/bin/keel.js
CHANGED
|
@@ -84,7 +84,7 @@ Usage:
|
|
|
84
84
|
keel lenses list|add [name] [repo] [--force]
|
|
85
85
|
keel openspec [args...]
|
|
86
86
|
keel --init [repo] [--target claude|codex|opencode] [--dry-run] [--force-template-update]
|
|
87
|
-
keel --install [repo] [--target claude|codex|opencode] [--dry-run] [--force-template-update]
|
|
87
|
+
keel --install [repo] [--target claude|codex|opencode] [--dry-run] [--force-template-update] [--with-git-hooks]
|
|
88
88
|
keel --clear [repo] [--target claude|codex|opencode] [--dry-run]
|
|
89
89
|
keel --uninstall [repo] [--target claude|codex|opencode] [--dry-run]
|
|
90
90
|
keel --update [--dry-run] [--source npm-package-or-git-spec]
|
|
@@ -127,6 +127,7 @@ Examples:
|
|
|
127
127
|
keel --check
|
|
128
128
|
keel --doctor
|
|
129
129
|
keel --install --force-template-update
|
|
130
|
+
keel --install --with-git-hooks
|
|
130
131
|
keel --update
|
|
131
132
|
keel --update --dry-run
|
|
132
133
|
keel --clear --dry-run
|
|
@@ -154,6 +155,7 @@ function parseArgs(argv) {
|
|
|
154
155
|
target: "claude",
|
|
155
156
|
dryRun: false,
|
|
156
157
|
forceTemplateUpdate: false,
|
|
158
|
+
withGitHooks: false,
|
|
157
159
|
updateSource: null,
|
|
158
160
|
help: false,
|
|
159
161
|
version: false,
|
|
@@ -363,6 +365,10 @@ function parseArgs(argv) {
|
|
|
363
365
|
parsed.forceTemplateUpdate = true;
|
|
364
366
|
continue;
|
|
365
367
|
}
|
|
368
|
+
if (arg === "--with-git-hooks") {
|
|
369
|
+
parsed.withGitHooks = true;
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
366
372
|
if (arg === "--target") {
|
|
367
373
|
index += 1;
|
|
368
374
|
if (index >= argv.length) {
|
|
@@ -820,6 +826,9 @@ function installerArgs(options, extra = []) {
|
|
|
820
826
|
if (options.forceTemplateUpdate) {
|
|
821
827
|
args.push("--force-template-update");
|
|
822
828
|
}
|
|
829
|
+
if (options.withGitHooks) {
|
|
830
|
+
args.push("--with-git-hooks");
|
|
831
|
+
}
|
|
823
832
|
return args;
|
|
824
833
|
}
|
|
825
834
|
|
|
@@ -1323,10 +1332,60 @@ function runDoctor(options) {
|
|
|
1323
1332
|
|
|
1324
1333
|
printTargetSurface(repo, options.target);
|
|
1325
1334
|
printLensSurface(repo, options.target);
|
|
1335
|
+
printFastPrePushSurface(repo);
|
|
1326
1336
|
|
|
1327
1337
|
return checkStatus;
|
|
1328
1338
|
}
|
|
1329
1339
|
|
|
1340
|
+
function readFastCheck(repo) {
|
|
1341
|
+
const configPath = path.join(repo, "keel", "config.yaml");
|
|
1342
|
+
if (!fs.existsSync(configPath)) return null;
|
|
1343
|
+
for (const line of fs.readFileSync(configPath, "utf8").split(/\r?\n/)) {
|
|
1344
|
+
const stripped = line.trim();
|
|
1345
|
+
if (stripped.startsWith("#")) continue;
|
|
1346
|
+
const match = stripped.match(/^fast_check\s*:\s*(.+?)\s*$/);
|
|
1347
|
+
if (match) return match[1];
|
|
1348
|
+
}
|
|
1349
|
+
return null;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
function gitConfigHooksPath(repo) {
|
|
1353
|
+
const result = spawnSync(
|
|
1354
|
+
"git",
|
|
1355
|
+
["-C", repo, "config", "--local", "--get", "core.hooksPath"],
|
|
1356
|
+
{ encoding: "utf8" }
|
|
1357
|
+
);
|
|
1358
|
+
if (result.status !== 0) return null;
|
|
1359
|
+
const value = (result.stdout || "").trim();
|
|
1360
|
+
return value || null;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
function printFastPrePushSurface(repo) {
|
|
1364
|
+
process.stdout.write("\nFast pre-push surface:\n");
|
|
1365
|
+
const fastCheck = readFastCheck(repo);
|
|
1366
|
+
printDoctorLine(
|
|
1367
|
+
"fast_check",
|
|
1368
|
+
fastCheck ? "ok" : "none",
|
|
1369
|
+
fastCheck
|
|
1370
|
+
? `declared in keel/config.yaml: ${fastCheck}`
|
|
1371
|
+
: "undeclared; add a fast_check line to keel/config.yaml"
|
|
1372
|
+
);
|
|
1373
|
+
const hookPresent = fs.existsSync(path.join(repo, ".githooks", "pre-push"));
|
|
1374
|
+
printDoctorLine(
|
|
1375
|
+
"pre-push hook",
|
|
1376
|
+
hookPresent ? "ok" : "none",
|
|
1377
|
+
hookPresent
|
|
1378
|
+
? ".githooks/pre-push present"
|
|
1379
|
+
: "run keel --install --with-git-hooks to scaffold it"
|
|
1380
|
+
);
|
|
1381
|
+
const hooksPath = gitConfigHooksPath(repo);
|
|
1382
|
+
printDoctorLine(
|
|
1383
|
+
"core.hooksPath",
|
|
1384
|
+
hooksPath === ".githooks" ? "ok" : hooksPath ? "other" : "unset",
|
|
1385
|
+
hooksPath || "default (.git/hooks)"
|
|
1386
|
+
);
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1330
1389
|
const SHIPPED_LENS_DIR = path.join(PACKAGE_ROOT, "assets", "lenses");
|
|
1331
1390
|
const EXPECTED_LENS_TEMPLATES = ["web", "hardware", "hardware-dsl"];
|
|
1332
1391
|
|
|
@@ -1445,6 +1504,9 @@ function runAction(options) {
|
|
|
1445
1504
|
if (options.updateSource !== null && options.action !== "update") {
|
|
1446
1505
|
fail("--source only applies to --update");
|
|
1447
1506
|
}
|
|
1507
|
+
if (options.withGitHooks && options.action !== "install") {
|
|
1508
|
+
fail("--with-git-hooks only applies to --install");
|
|
1509
|
+
}
|
|
1448
1510
|
|
|
1449
1511
|
if (options.action === "openspec") {
|
|
1450
1512
|
const openspec = findOpenSpecCommand();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.2.
|
|
3
|
+
"version": "5.2.2",
|
|
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.2.
|
|
3
|
+
"version": "5.2.2",
|
|
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",
|
|
@@ -6,7 +6,9 @@ from __future__ import annotations
|
|
|
6
6
|
import argparse
|
|
7
7
|
import hashlib
|
|
8
8
|
import json
|
|
9
|
+
import os
|
|
9
10
|
import re
|
|
11
|
+
import subprocess
|
|
10
12
|
import sys
|
|
11
13
|
from dataclasses import dataclass
|
|
12
14
|
from pathlib import Path
|
|
@@ -19,6 +21,20 @@ TEMPLATE_CHECKSUM_PREFIX = "<!-- keel:content-sha256 "
|
|
|
19
21
|
TEMPLATE_CHECKSUM_SUFFIX = " -->"
|
|
20
22
|
KEEL_ROOT = Path("keel")
|
|
21
23
|
HANDOFF_PATH = KEEL_ROOT / "HANDOFF.md"
|
|
24
|
+
KEEL_CONFIG_PATH = KEEL_ROOT / "config.yaml"
|
|
25
|
+
KEEL_CONFIG_TEMPLATE = (
|
|
26
|
+
"# Keel project configuration.\n"
|
|
27
|
+
"#\n"
|
|
28
|
+
"# fast_check (optional): your project's fast inner-loop check — a\n"
|
|
29
|
+
"# seconds-scale command run at a local pre-push (see\n"
|
|
30
|
+
"# `keel --install --with-git-hooks`) and during iteration. The full or slow\n"
|
|
31
|
+
"# suite belongs to CI or `keel gate change-close`, not the local pre-push.\n"
|
|
32
|
+
"#\n"
|
|
33
|
+
"# Example:\n"
|
|
34
|
+
"# fast_check: npm test -- --fast\n"
|
|
35
|
+
)
|
|
36
|
+
GITHOOKS_DIR = ".githooks"
|
|
37
|
+
PRE_PUSH_PATH = Path(GITHOOKS_DIR) / "pre-push"
|
|
22
38
|
OPENSPEC_ROOT = Path("openspec")
|
|
23
39
|
OPENSPEC_CONFIG_PATH = OPENSPEC_ROOT / "config.yaml"
|
|
24
40
|
OPENSPEC_SCHEMA_NAME = "keel-spec-driven"
|
|
@@ -198,6 +214,124 @@ def openspec_config_action() -> InstallAction:
|
|
|
198
214
|
)
|
|
199
215
|
|
|
200
216
|
|
|
217
|
+
def keel_config_action() -> InstallAction:
|
|
218
|
+
return InstallAction(
|
|
219
|
+
relative_path=KEEL_CONFIG_PATH,
|
|
220
|
+
content=KEEL_CONFIG_TEMPLATE,
|
|
221
|
+
strategy="keel-config-scaffold",
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def read_fast_check(repo: Path) -> str | None:
|
|
226
|
+
"""Return the project's declared fast inner-loop command, or None.
|
|
227
|
+
|
|
228
|
+
Parses keel/config.yaml with the same flat-key style Keel uses elsewhere;
|
|
229
|
+
a commented `# fast_check:` line does not count as declared.
|
|
230
|
+
"""
|
|
231
|
+
config_path = repo / KEEL_CONFIG_PATH
|
|
232
|
+
if not config_path.is_file():
|
|
233
|
+
return None
|
|
234
|
+
for line in config_path.read_text(encoding="utf-8").splitlines():
|
|
235
|
+
stripped = line.strip()
|
|
236
|
+
if stripped.startswith("#"):
|
|
237
|
+
continue
|
|
238
|
+
match = re.match(r"fast_check\s*:\s*(.+?)\s*$", stripped)
|
|
239
|
+
if match:
|
|
240
|
+
return match.group(1)
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def is_git_repo(repo: Path) -> bool:
|
|
245
|
+
result = subprocess.run(
|
|
246
|
+
["git", "-C", str(repo), "rev-parse", "--is-inside-work-tree"],
|
|
247
|
+
capture_output=True,
|
|
248
|
+
text=True,
|
|
249
|
+
)
|
|
250
|
+
return result.returncode == 0 and result.stdout.strip() == "true"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def git_config_get(repo: Path, key: str) -> str | None:
|
|
254
|
+
result = subprocess.run(
|
|
255
|
+
["git", "-C", str(repo), "config", "--local", "--get", key],
|
|
256
|
+
capture_output=True,
|
|
257
|
+
text=True,
|
|
258
|
+
)
|
|
259
|
+
if result.returncode != 0:
|
|
260
|
+
return None
|
|
261
|
+
return result.stdout.strip() or None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def pre_push_hook_content(fast_check: str) -> str:
|
|
265
|
+
return (
|
|
266
|
+
"#!/bin/sh\n"
|
|
267
|
+
"# Managed by keel --install --with-git-hooks: the fast inner-loop check.\n"
|
|
268
|
+
"# The full or slow suite belongs to CI or `keel gate change-close`.\n"
|
|
269
|
+
f"exec {fast_check}\n"
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def apply_git_hooks(repo: Path, dry_run: bool) -> int:
|
|
274
|
+
if not is_git_repo(repo):
|
|
275
|
+
print(
|
|
276
|
+
"keel --install --with-git-hooks: not a git repository; run "
|
|
277
|
+
"`git init` first",
|
|
278
|
+
file=sys.stderr,
|
|
279
|
+
)
|
|
280
|
+
return 1
|
|
281
|
+
fast_check = read_fast_check(repo)
|
|
282
|
+
if fast_check is None:
|
|
283
|
+
print(
|
|
284
|
+
"keel --install --with-git-hooks: no fast_check declared in "
|
|
285
|
+
f"{KEEL_CONFIG_PATH.as_posix()}; add a `fast_check:` line, then rerun",
|
|
286
|
+
file=sys.stderr,
|
|
287
|
+
)
|
|
288
|
+
return 1
|
|
289
|
+
if dry_run:
|
|
290
|
+
print(
|
|
291
|
+
f"would write {PRE_PUSH_PATH.as_posix()} running the fast_check and "
|
|
292
|
+
f"set core.hooksPath to {GITHOOKS_DIR}"
|
|
293
|
+
)
|
|
294
|
+
return 0
|
|
295
|
+
hook_path = repo / PRE_PUSH_PATH
|
|
296
|
+
hook_path.parent.mkdir(parents=True, exist_ok=True)
|
|
297
|
+
hook_path.write_text(pre_push_hook_content(fast_check), encoding="utf-8")
|
|
298
|
+
os.chmod(hook_path, 0o755)
|
|
299
|
+
result = subprocess.run(
|
|
300
|
+
["git", "-C", str(repo), "config", "--local", "core.hooksPath", GITHOOKS_DIR],
|
|
301
|
+
capture_output=True,
|
|
302
|
+
text=True,
|
|
303
|
+
)
|
|
304
|
+
if result.returncode != 0:
|
|
305
|
+
print(
|
|
306
|
+
"keel --install --with-git-hooks: failed to set core.hooksPath: "
|
|
307
|
+
+ (result.stderr.strip() or "git config error"),
|
|
308
|
+
file=sys.stderr,
|
|
309
|
+
)
|
|
310
|
+
return 1
|
|
311
|
+
print(
|
|
312
|
+
f"git hooks: wrote {PRE_PUSH_PATH.as_posix()} (runs the fast_check) and "
|
|
313
|
+
f"set core.hooksPath to {GITHOOKS_DIR}"
|
|
314
|
+
)
|
|
315
|
+
return 0
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def revert_git_hooks(repo: Path, dry_run: bool) -> None:
|
|
319
|
+
"""Unset core.hooksPath only when Keel is the one that set it to .githooks."""
|
|
320
|
+
if not is_git_repo(repo):
|
|
321
|
+
return
|
|
322
|
+
if git_config_get(repo, "core.hooksPath") != GITHOOKS_DIR:
|
|
323
|
+
return
|
|
324
|
+
if dry_run:
|
|
325
|
+
print(f"would unset core.hooksPath (currently {GITHOOKS_DIR})")
|
|
326
|
+
return
|
|
327
|
+
subprocess.run(
|
|
328
|
+
["git", "-C", str(repo), "config", "--local", "--unset", "core.hooksPath"],
|
|
329
|
+
capture_output=True,
|
|
330
|
+
text=True,
|
|
331
|
+
)
|
|
332
|
+
print(f"git hooks: unset core.hooksPath (was {GITHOOKS_DIR})")
|
|
333
|
+
|
|
334
|
+
|
|
201
335
|
def openspec_schema_actions() -> list[InstallAction]:
|
|
202
336
|
schema_root = PACKAGE_ROOT / OPENSPEC_ASSET_ROOT / "schemas" / OPENSPEC_SCHEMA_NAME
|
|
203
337
|
if not schema_root.is_dir():
|
|
@@ -310,6 +444,7 @@ def collect_actions(repo: Path, target: str) -> list[InstallAction]:
|
|
|
310
444
|
actions.append(managed_content_action("CLAUDE.md", CLAUDE_IMPORT_BLOCK))
|
|
311
445
|
|
|
312
446
|
actions.append(openspec_config_action())
|
|
447
|
+
actions.append(keel_config_action())
|
|
313
448
|
actions.extend(openspec_schema_actions())
|
|
314
449
|
return actions
|
|
315
450
|
|
|
@@ -768,6 +903,9 @@ def plan_action(
|
|
|
768
903
|
if action.strategy == "openspec-config":
|
|
769
904
|
merged, kind = merge_openspec_config(existing)
|
|
770
905
|
return PlannedAction(kind, action.relative_path, None if kind == "skip" else merged)
|
|
906
|
+
if action.strategy == "keel-config-scaffold":
|
|
907
|
+
# Scaffold once: never overwrite a project's own keel/config.yaml.
|
|
908
|
+
return PlannedAction("skip", action.relative_path)
|
|
771
909
|
|
|
772
910
|
if existing == source_content:
|
|
773
911
|
return PlannedAction("skip", action.relative_path)
|
|
@@ -1007,6 +1145,14 @@ def main() -> int:
|
|
|
1007
1145
|
action="store_true",
|
|
1008
1146
|
help="Remove managed protocol blocks and safe generated skeleton files.",
|
|
1009
1147
|
)
|
|
1148
|
+
parser.add_argument(
|
|
1149
|
+
"--with-git-hooks",
|
|
1150
|
+
action="store_true",
|
|
1151
|
+
help=(
|
|
1152
|
+
"Generate .githooks/pre-push from the declared fast_check and set "
|
|
1153
|
+
"core.hooksPath (install only); refuses without a fast_check."
|
|
1154
|
+
),
|
|
1155
|
+
)
|
|
1010
1156
|
parser.add_argument(
|
|
1011
1157
|
"--profile",
|
|
1012
1158
|
action="append",
|
|
@@ -1031,6 +1177,7 @@ def main() -> int:
|
|
|
1031
1177
|
describe_actions(actions)
|
|
1032
1178
|
if not args.dry_run:
|
|
1033
1179
|
apply_actions(repo, actions)
|
|
1180
|
+
revert_git_hooks(repo, args.dry_run)
|
|
1034
1181
|
return 0
|
|
1035
1182
|
repo.mkdir(parents=True, exist_ok=True)
|
|
1036
1183
|
actions = plan_actions(
|
|
@@ -1042,6 +1189,8 @@ def main() -> int:
|
|
|
1042
1189
|
report_handoff_status(repo)
|
|
1043
1190
|
if not args.dry_run:
|
|
1044
1191
|
apply_actions(repo, actions)
|
|
1192
|
+
if args.with_git_hooks:
|
|
1193
|
+
return apply_git_hooks(repo, args.dry_run)
|
|
1045
1194
|
return 0
|
|
1046
1195
|
except ValueError as exc:
|
|
1047
1196
|
print(f"Install failed: {exc}", file=sys.stderr)
|
|
@@ -37,8 +37,8 @@ REQUIRED_SCRIPTS = [
|
|
|
37
37
|
"scripts/validate_plugin.py",
|
|
38
38
|
]
|
|
39
39
|
|
|
40
|
-
PACKAGE_VERSION = "5.2.
|
|
41
|
-
PROTOCOL_VERSION = "5.2.
|
|
40
|
+
PACKAGE_VERSION = "5.2.2"
|
|
41
|
+
PROTOCOL_VERSION = "5.2.2"
|
|
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")
|
|
@@ -7687,6 +7687,297 @@ def validate_native_tasks_view_scenario() -> int:
|
|
|
7687
7687
|
return 0
|
|
7688
7688
|
|
|
7689
7689
|
|
|
7690
|
+
def validate_verification_layering_docs_scenario() -> int:
|
|
7691
|
+
en = (ROOT / "README.md").read_text(encoding="utf-8")
|
|
7692
|
+
for needle in (
|
|
7693
|
+
"## Verification layering",
|
|
7694
|
+
"inner-loop",
|
|
7695
|
+
"Full gate",
|
|
7696
|
+
"pre-push",
|
|
7697
|
+
"change-close",
|
|
7698
|
+
):
|
|
7699
|
+
if needle not in en:
|
|
7700
|
+
report(
|
|
7701
|
+
"verification-layering-docs: README.md lacks the fast/full "
|
|
7702
|
+
f"verification split marker: {needle}"
|
|
7703
|
+
)
|
|
7704
|
+
return 1
|
|
7705
|
+
|
|
7706
|
+
zh = (ROOT / "README.zh-CN.md").read_text(encoding="utf-8")
|
|
7707
|
+
for needle in ("## 验证分层", "快速内环", "全量门禁", "pre-push", "change-close"):
|
|
7708
|
+
if needle not in zh:
|
|
7709
|
+
report(
|
|
7710
|
+
"verification-layering-docs: README.zh-CN.md lacks the fast/full "
|
|
7711
|
+
f"verification split marker: {needle}"
|
|
7712
|
+
)
|
|
7713
|
+
return 1
|
|
7714
|
+
|
|
7715
|
+
if "verification-layering-docs" not in {name for name, _ in SCENARIOS}:
|
|
7716
|
+
report("verification-layering-docs: the scenario registry does not include it.")
|
|
7717
|
+
return 1
|
|
7718
|
+
|
|
7719
|
+
report("verification-layering-docs scenario passed.")
|
|
7720
|
+
return 0
|
|
7721
|
+
|
|
7722
|
+
|
|
7723
|
+
def validate_fast_check_config_scaffold_scenario() -> int:
|
|
7724
|
+
with tempfile.TemporaryDirectory(prefix="keel-fastcfg-") as raw_tmp:
|
|
7725
|
+
repo = Path(raw_tmp)
|
|
7726
|
+
first = run_keel(repo, "--install")
|
|
7727
|
+
if first.returncode != 0:
|
|
7728
|
+
report("fast-check-config-scaffold: keel --install failed.")
|
|
7729
|
+
report((first.stderr or first.stdout).strip())
|
|
7730
|
+
return 1
|
|
7731
|
+
|
|
7732
|
+
config_path = repo / "keel" / "config.yaml"
|
|
7733
|
+
if not config_path.is_file():
|
|
7734
|
+
report("fast-check-config-scaffold: install did not scaffold keel/config.yaml.")
|
|
7735
|
+
return 1
|
|
7736
|
+
scaffolded = config_path.read_text(encoding="utf-8")
|
|
7737
|
+
for needle in ("fast_check", "keel gate change-close", "--with-git-hooks"):
|
|
7738
|
+
if needle not in scaffolded:
|
|
7739
|
+
report(
|
|
7740
|
+
"fast-check-config-scaffold: scaffolded keel/config.yaml lacks the "
|
|
7741
|
+
f"fast_check guidance marker: {needle}"
|
|
7742
|
+
)
|
|
7743
|
+
return 1
|
|
7744
|
+
|
|
7745
|
+
# A project's own edits to keel/config.yaml must survive re-install.
|
|
7746
|
+
edited = "fast_check: pytest -m 'not slow' -q\n"
|
|
7747
|
+
config_path.write_text(edited, encoding="utf-8")
|
|
7748
|
+
second = run_keel(repo, "--install")
|
|
7749
|
+
if second.returncode != 0:
|
|
7750
|
+
report("fast-check-config-scaffold: second keel --install failed.")
|
|
7751
|
+
report((second.stderr or second.stdout).strip())
|
|
7752
|
+
return 1
|
|
7753
|
+
if config_path.read_text(encoding="utf-8") != edited:
|
|
7754
|
+
report(
|
|
7755
|
+
"fast-check-config-scaffold: re-install overwrote an existing "
|
|
7756
|
+
"keel/config.yaml."
|
|
7757
|
+
)
|
|
7758
|
+
return 1
|
|
7759
|
+
|
|
7760
|
+
report("fast-check-config-scaffold scenario passed.")
|
|
7761
|
+
return 0
|
|
7762
|
+
|
|
7763
|
+
|
|
7764
|
+
def validate_fast_pre_push_hooks_scenario() -> int:
|
|
7765
|
+
def git(repo: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
|
7766
|
+
return subprocess.run(
|
|
7767
|
+
["git", "-C", str(repo), *args], capture_output=True, text=True
|
|
7768
|
+
)
|
|
7769
|
+
|
|
7770
|
+
def init_repo(root: Path, name: str) -> Path:
|
|
7771
|
+
repo = root / name
|
|
7772
|
+
repo.mkdir()
|
|
7773
|
+
git(repo, "init", "-q")
|
|
7774
|
+
git(repo, "config", "user.email", "t@example.com")
|
|
7775
|
+
git(repo, "config", "user.name", "keel-test")
|
|
7776
|
+
return repo
|
|
7777
|
+
|
|
7778
|
+
def declare_fast_check(repo: Path, command: str) -> None:
|
|
7779
|
+
(repo / "keel").mkdir(exist_ok=True)
|
|
7780
|
+
(repo / "keel" / "config.yaml").write_text(
|
|
7781
|
+
f"fast_check: {command}\n", encoding="utf-8"
|
|
7782
|
+
)
|
|
7783
|
+
|
|
7784
|
+
def hooks_path(repo: Path) -> str | None:
|
|
7785
|
+
got = git(repo, "config", "--local", "--get", "core.hooksPath")
|
|
7786
|
+
return got.stdout.strip() if got.returncode == 0 else None
|
|
7787
|
+
|
|
7788
|
+
with tempfile.TemporaryDirectory(prefix="keel-prepush-") as raw_tmp:
|
|
7789
|
+
root = Path(raw_tmp)
|
|
7790
|
+
|
|
7791
|
+
# 1. A declared fast_check generates the hook and sets hooksPath.
|
|
7792
|
+
declared = init_repo(root, "declared")
|
|
7793
|
+
declare_fast_check(declared, "echo fast-check-ran")
|
|
7794
|
+
res = run_keel(declared, "--install", "--with-git-hooks")
|
|
7795
|
+
if res.returncode != 0:
|
|
7796
|
+
report("fast-pre-push-hooks: --with-git-hooks failed with a declared fast_check.")
|
|
7797
|
+
report((res.stderr or res.stdout).strip())
|
|
7798
|
+
return 1
|
|
7799
|
+
hook = declared / ".githooks" / "pre-push"
|
|
7800
|
+
if not hook.is_file():
|
|
7801
|
+
report("fast-pre-push-hooks: --with-git-hooks did not write .githooks/pre-push.")
|
|
7802
|
+
return 1
|
|
7803
|
+
hook_text = hook.read_text(encoding="utf-8")
|
|
7804
|
+
if not hook_text.startswith("#!/bin/sh") or "echo fast-check-ran" not in hook_text:
|
|
7805
|
+
report("fast-pre-push-hooks: pre-push does not run the declared fast_check under sh.")
|
|
7806
|
+
report(hook_text)
|
|
7807
|
+
return 1
|
|
7808
|
+
if hooks_path(declared) != ".githooks":
|
|
7809
|
+
report("fast-pre-push-hooks: --with-git-hooks did not set core.hooksPath to .githooks.")
|
|
7810
|
+
return 1
|
|
7811
|
+
|
|
7812
|
+
# 2. A plain install touches neither the hook nor git config.
|
|
7813
|
+
plain = init_repo(root, "plain")
|
|
7814
|
+
declare_fast_check(plain, "echo plain")
|
|
7815
|
+
if run_keel(plain, "--install").returncode != 0:
|
|
7816
|
+
report("fast-pre-push-hooks: plain install failed.")
|
|
7817
|
+
return 1
|
|
7818
|
+
if (plain / ".githooks" / "pre-push").exists():
|
|
7819
|
+
report("fast-pre-push-hooks: plain install wrote a pre-push hook.")
|
|
7820
|
+
return 1
|
|
7821
|
+
if hooks_path(plain) is not None:
|
|
7822
|
+
report("fast-pre-push-hooks: plain install set core.hooksPath.")
|
|
7823
|
+
return 1
|
|
7824
|
+
|
|
7825
|
+
# 3. Without a declared fast_check the flag refuses and writes nothing.
|
|
7826
|
+
undeclared = init_repo(root, "undeclared")
|
|
7827
|
+
res = run_keel(undeclared, "--install", "--with-git-hooks")
|
|
7828
|
+
if res.returncode == 0:
|
|
7829
|
+
report("fast-pre-push-hooks: --with-git-hooks did not refuse without a fast_check.")
|
|
7830
|
+
return 1
|
|
7831
|
+
if (undeclared / ".githooks" / "pre-push").exists():
|
|
7832
|
+
report("fast-pre-push-hooks: a refusal still wrote a pre-push hook.")
|
|
7833
|
+
return 1
|
|
7834
|
+
if hooks_path(undeclared) is not None:
|
|
7835
|
+
report("fast-pre-push-hooks: a refusal still set core.hooksPath.")
|
|
7836
|
+
return 1
|
|
7837
|
+
|
|
7838
|
+
# 4a. Uninstall reverts a keel-set core.hooksPath.
|
|
7839
|
+
if run_keel(declared, "--uninstall").returncode != 0:
|
|
7840
|
+
report("fast-pre-push-hooks: uninstall failed.")
|
|
7841
|
+
return 1
|
|
7842
|
+
if hooks_path(declared) is not None:
|
|
7843
|
+
report("fast-pre-push-hooks: uninstall did not unset a keel-set core.hooksPath.")
|
|
7844
|
+
return 1
|
|
7845
|
+
|
|
7846
|
+
# 4b. Uninstall leaves a non-.githooks core.hooksPath untouched.
|
|
7847
|
+
custom = init_repo(root, "custom")
|
|
7848
|
+
git(custom, "config", "--local", "core.hooksPath", ".customhooks")
|
|
7849
|
+
if run_keel(custom, "--uninstall").returncode != 0:
|
|
7850
|
+
report("fast-pre-push-hooks: uninstall failed on a custom hooksPath repo.")
|
|
7851
|
+
return 1
|
|
7852
|
+
if hooks_path(custom) != ".customhooks":
|
|
7853
|
+
report("fast-pre-push-hooks: uninstall clobbered a non-keel core.hooksPath.")
|
|
7854
|
+
return 1
|
|
7855
|
+
|
|
7856
|
+
report("fast-pre-push-hooks scenario passed.")
|
|
7857
|
+
return 0
|
|
7858
|
+
|
|
7859
|
+
|
|
7860
|
+
def validate_fast_pre_push_doctor_scenario() -> int:
|
|
7861
|
+
def git(repo: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
|
7862
|
+
return subprocess.run(
|
|
7863
|
+
["git", "-C", str(repo), *args], capture_output=True, text=True
|
|
7864
|
+
)
|
|
7865
|
+
|
|
7866
|
+
def init_repo(root: Path, name: str) -> Path:
|
|
7867
|
+
repo = root / name
|
|
7868
|
+
repo.mkdir()
|
|
7869
|
+
git(repo, "init", "-q")
|
|
7870
|
+
git(repo, "config", "user.email", "t@example.com")
|
|
7871
|
+
git(repo, "config", "user.name", "keel-test")
|
|
7872
|
+
return repo
|
|
7873
|
+
|
|
7874
|
+
with tempfile.TemporaryDirectory(prefix="keel-prepush-doc-") as raw_tmp:
|
|
7875
|
+
root = Path(raw_tmp)
|
|
7876
|
+
|
|
7877
|
+
# Surface active: fast_check declared and --with-git-hooks applied.
|
|
7878
|
+
active = init_repo(root, "active")
|
|
7879
|
+
(active / "keel").mkdir()
|
|
7880
|
+
(active / "keel" / "config.yaml").write_text(
|
|
7881
|
+
"fast_check: echo doc-check\n", encoding="utf-8"
|
|
7882
|
+
)
|
|
7883
|
+
if run_keel(active, "--install", "--with-git-hooks").returncode != 0:
|
|
7884
|
+
report("fast-pre-push-doctor: install --with-git-hooks failed.")
|
|
7885
|
+
return 1
|
|
7886
|
+
before = git(active, "config", "--local", "--get", "core.hooksPath").stdout.strip()
|
|
7887
|
+
out = run_keel(active, "--doctor").stdout
|
|
7888
|
+
for needle in (
|
|
7889
|
+
"Fast pre-push surface:",
|
|
7890
|
+
"fast_check: ok",
|
|
7891
|
+
"echo doc-check",
|
|
7892
|
+
"pre-push hook: ok",
|
|
7893
|
+
"core.hooksPath: ok",
|
|
7894
|
+
):
|
|
7895
|
+
if needle not in out:
|
|
7896
|
+
report(f"fast-pre-push-doctor: active-surface doctor output lacks: {needle}")
|
|
7897
|
+
report(out)
|
|
7898
|
+
return 1
|
|
7899
|
+
after = git(active, "config", "--local", "--get", "core.hooksPath").stdout.strip()
|
|
7900
|
+
if before != after:
|
|
7901
|
+
report("fast-pre-push-doctor: doctor mutated core.hooksPath.")
|
|
7902
|
+
return 1
|
|
7903
|
+
|
|
7904
|
+
# Surface absent: plain install, no fast_check, no hook.
|
|
7905
|
+
absent = init_repo(root, "absent")
|
|
7906
|
+
if run_keel(absent, "--install").returncode != 0:
|
|
7907
|
+
report("fast-pre-push-doctor: plain install failed.")
|
|
7908
|
+
return 1
|
|
7909
|
+
out = run_keel(absent, "--doctor").stdout
|
|
7910
|
+
for needle in ("fast_check: none", "pre-push hook: none", "core.hooksPath: unset"):
|
|
7911
|
+
if needle not in out:
|
|
7912
|
+
report(f"fast-pre-push-doctor: absent-surface doctor output lacks: {needle}")
|
|
7913
|
+
report(out)
|
|
7914
|
+
return 1
|
|
7915
|
+
|
|
7916
|
+
report("fast-pre-push-doctor scenario passed.")
|
|
7917
|
+
return 0
|
|
7918
|
+
|
|
7919
|
+
|
|
7920
|
+
def validate_verify_layer_tag_scenario() -> int:
|
|
7921
|
+
fixture = (
|
|
7922
|
+
"# Tasks\n\n"
|
|
7923
|
+
"- [ ] 1.1 Exercise the verification-layer tag\n"
|
|
7924
|
+
" - Covers:\n"
|
|
7925
|
+
" - E1: Public behavior passes.\n"
|
|
7926
|
+
" - Read:\n"
|
|
7927
|
+
" - README.md\n"
|
|
7928
|
+
" - Touch:\n"
|
|
7929
|
+
" - src/feature.js\n"
|
|
7930
|
+
" - Verify:\n"
|
|
7931
|
+
" - Strategy: evidence-first\n"
|
|
7932
|
+
" - M1 (fast): node fast.js\n"
|
|
7933
|
+
" - M2: node full.js\n"
|
|
7934
|
+
" - Autonomy boundary:\n"
|
|
7935
|
+
" - Default: hard-stop\n"
|
|
7936
|
+
" - Pre-authorized fallback: none\n"
|
|
7937
|
+
" - Stop Rules:\n"
|
|
7938
|
+
" - Stop on failure.\n"
|
|
7939
|
+
" - Evidence:\n"
|
|
7940
|
+
" - M1: pending\n"
|
|
7941
|
+
" - M2: pending\n"
|
|
7942
|
+
" - Stop if:\n"
|
|
7943
|
+
" - Requires files outside Touch.\n"
|
|
7944
|
+
)
|
|
7945
|
+
with tempfile.TemporaryDirectory(prefix="keel-verify-layer-") as raw_tmp:
|
|
7946
|
+
repo = Path(raw_tmp)
|
|
7947
|
+
write_text(repo / "openspec/changes/demo/tasks.md", fixture)
|
|
7948
|
+
started = run_keel(
|
|
7949
|
+
repo, "gate", "task-start", "--change", "demo", "--task", "1.1", "--json"
|
|
7950
|
+
)
|
|
7951
|
+
if started.returncode != 0:
|
|
7952
|
+
report("verify-layer-tag: task-start rejected the tagged fixture.")
|
|
7953
|
+
report((started.stderr or started.stdout).strip())
|
|
7954
|
+
return 1
|
|
7955
|
+
capsule = json.loads(started.stdout).get("contract", {}).get("capsule", {})
|
|
7956
|
+
commands = capsule.get("verification", {}).get("commands", [])
|
|
7957
|
+
by_label = {c.get("label"): c for c in commands}
|
|
7958
|
+
if by_label.get("M1", {}).get("layer") != "fast":
|
|
7959
|
+
report("verify-layer-tag: the (fast)-tagged check did not compile with layer fast.")
|
|
7960
|
+
report(json.dumps(commands))
|
|
7961
|
+
return 1
|
|
7962
|
+
if "layer" in by_label.get("M2", {}):
|
|
7963
|
+
report(
|
|
7964
|
+
"verify-layer-tag: an untagged check emitted a layer field; full is "
|
|
7965
|
+
"the implicit default and must stay off the capsule."
|
|
7966
|
+
)
|
|
7967
|
+
report(json.dumps(commands))
|
|
7968
|
+
return 1
|
|
7969
|
+
if (
|
|
7970
|
+
by_label.get("M1", {}).get("check") != "node fast.js"
|
|
7971
|
+
or by_label.get("M2", {}).get("check") != "node full.js"
|
|
7972
|
+
):
|
|
7973
|
+
report("verify-layer-tag: the layer tag altered the check text.")
|
|
7974
|
+
report(json.dumps(commands))
|
|
7975
|
+
return 1
|
|
7976
|
+
|
|
7977
|
+
report("verify-layer-tag scenario passed.")
|
|
7978
|
+
return 0
|
|
7979
|
+
|
|
7980
|
+
|
|
7690
7981
|
def validate_native_goal_gate_order_scenario() -> int:
|
|
7691
7982
|
with tempfile.TemporaryDirectory(prefix="keel-goal-order-") as raw_tmp:
|
|
7692
7983
|
root = Path(raw_tmp)
|
|
@@ -10104,6 +10395,11 @@ SCENARIOS: tuple = (
|
|
|
10104
10395
|
("doctor-openspec-honesty", validate_doctor_openspec_honesty_scenario),
|
|
10105
10396
|
("update-pack-install", validate_update_pack_install_scenario),
|
|
10106
10397
|
("update-default-registry", validate_update_default_registry_scenario),
|
|
10398
|
+
("verification-layering-docs", validate_verification_layering_docs_scenario),
|
|
10399
|
+
("fast-check-config-scaffold", validate_fast_check_config_scaffold_scenario),
|
|
10400
|
+
("fast-pre-push-hooks", validate_fast_pre_push_hooks_scenario),
|
|
10401
|
+
("fast-pre-push-doctor", validate_fast_pre_push_doctor_scenario),
|
|
10402
|
+
("verify-layer-tag", validate_verify_layer_tag_scenario),
|
|
10107
10403
|
("task-contract-core", validate_task_contract_core_scenario),
|
|
10108
10404
|
("task-capsule", validate_task_capsule_scenario),
|
|
10109
10405
|
("task-verification-strategies", validate_task_verification_strategies_scenario),
|
|
@@ -123,10 +123,12 @@ function verification(task) {
|
|
|
123
123
|
? compact.filter((entry) => !/^Strategy:\s*/i.test(entry))
|
|
124
124
|
: fieldValues(task, "Commands");
|
|
125
125
|
const commands = commandSource.map((entry) => {
|
|
126
|
-
|
|
126
|
+
// An optional (fast)/(full) layer tag after the M<n> label marks which
|
|
127
|
+
// checks the fast inner loop runs; an untagged check is full.
|
|
128
|
+
const match = entry.match(/^(M[1-9]\d*)(?:\s*\((fast|full)\))?:\s*(.*)$/);
|
|
127
129
|
return match
|
|
128
|
-
? { label: match[1], check: normalizeText(match[
|
|
129
|
-
: { label: null, check: entry };
|
|
130
|
+
? { label: match[1], layer: match[2] || "full", check: normalizeText(match[3]) }
|
|
131
|
+
: { label: null, layer: "full", check: entry };
|
|
130
132
|
});
|
|
131
133
|
return {
|
|
132
134
|
compact: compact.length > 0,
|
|
@@ -678,7 +680,15 @@ function compileTaskContract(repo, change, task) {
|
|
|
678
680
|
acceptance: [...new Set([...derivedAcceptance, ...explicitAcceptance])],
|
|
679
681
|
verification: {
|
|
680
682
|
strategy: taskVerification.strategy,
|
|
681
|
-
|
|
683
|
+
// Emit the layer only when a check opts into the fast inner loop, so
|
|
684
|
+
// untagged (full) checks keep their existing capsule shape and fingerprint.
|
|
685
|
+
commands: taskVerification.commands
|
|
686
|
+
.filter((entry) => entry.label)
|
|
687
|
+
.map((entry) =>
|
|
688
|
+
entry.layer && entry.layer !== "full"
|
|
689
|
+
? { label: entry.label, check: entry.check, layer: entry.layer }
|
|
690
|
+
: { label: entry.label, check: entry.check }
|
|
691
|
+
),
|
|
682
692
|
},
|
|
683
693
|
boundaries: {
|
|
684
694
|
autonomy,
|