@christang/keel 5.2.1 → 5.2.3

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 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
@@ -1,4 +1,4 @@
1
- <!-- keel:start version=5.2.1 -->
1
+ <!-- keel:start version=5.2.3 -->
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.
@@ -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
@@ -15,6 +15,7 @@ const {
15
15
  runGate,
16
16
  } = require("../src/core/gates");
17
17
  const {
18
+ isKeelSourceRepo,
18
19
  probeCapabilities,
19
20
  renderCapabilities,
20
21
  } = require("../src/core/capabilities");
@@ -84,7 +85,7 @@ Usage:
84
85
  keel lenses list|add [name] [repo] [--force]
85
86
  keel openspec [args...]
86
87
  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]
88
+ keel --install [repo] [--target claude|codex|opencode] [--dry-run] [--force-template-update] [--with-git-hooks]
88
89
  keel --clear [repo] [--target claude|codex|opencode] [--dry-run]
89
90
  keel --uninstall [repo] [--target claude|codex|opencode] [--dry-run]
90
91
  keel --update [--dry-run] [--source npm-package-or-git-spec]
@@ -127,6 +128,7 @@ Examples:
127
128
  keel --check
128
129
  keel --doctor
129
130
  keel --install --force-template-update
131
+ keel --install --with-git-hooks
130
132
  keel --update
131
133
  keel --update --dry-run
132
134
  keel --clear --dry-run
@@ -154,6 +156,7 @@ function parseArgs(argv) {
154
156
  target: "claude",
155
157
  dryRun: false,
156
158
  forceTemplateUpdate: false,
159
+ withGitHooks: false,
157
160
  updateSource: null,
158
161
  help: false,
159
162
  version: false,
@@ -363,6 +366,10 @@ function parseArgs(argv) {
363
366
  parsed.forceTemplateUpdate = true;
364
367
  continue;
365
368
  }
369
+ if (arg === "--with-git-hooks") {
370
+ parsed.withGitHooks = true;
371
+ continue;
372
+ }
366
373
  if (arg === "--target") {
367
374
  index += 1;
368
375
  if (index >= argv.length) {
@@ -820,6 +827,9 @@ function installerArgs(options, extra = []) {
820
827
  if (options.forceTemplateUpdate) {
821
828
  args.push("--force-template-update");
822
829
  }
830
+ if (options.withGitHooks) {
831
+ args.push("--with-git-hooks");
832
+ }
823
833
  return args;
824
834
  }
825
835
 
@@ -1193,7 +1203,12 @@ function printTargetSurface(repo, target) {
1193
1203
  sourceDetail = `plugin source unreadable at ${manifestRelative}`;
1194
1204
  }
1195
1205
  }
1196
- printDoctorLine("native plugin source", sourceStatus, sourceDetail);
1206
+ // Development-only check: plugins/keel/ exists only in Keel's own source
1207
+ // repository, so in a consuming project it is permanently `missing` and
1208
+ // there is nothing the author can do about it.
1209
+ if (isKeelSourceRepo(repo)) {
1210
+ printDoctorLine("native plugin source", sourceStatus, sourceDetail);
1211
+ }
1197
1212
  printDoctorLine(
1198
1213
  "native plugin runtime",
1199
1214
  "manual",
@@ -1220,7 +1235,12 @@ function printTargetSurface(repo, target) {
1220
1235
  printDoctorLine(
1221
1236
  "Keel behavioral skills",
1222
1237
  "plugin",
1223
- "keel-* skills are delivered by the installed Keel plugin (see native plugin status above); install the plugin if it is missing"
1238
+ isKeelSourceRepo(repo)
1239
+ ? "keel-* skills are delivered by the installed Keel plugin (see native "
1240
+ + "plugin status above); install the plugin if it is missing"
1241
+ : "keel-* skills are delivered by the installed Keel plugin; verify it "
1242
+ + "with the runtime's own plugin listing, since Keel cannot observe "
1243
+ + "installation from this repository"
1224
1244
  );
1225
1245
 
1226
1246
  const commands = commandSurfaceForTarget(target, repo);
@@ -1323,10 +1343,60 @@ function runDoctor(options) {
1323
1343
 
1324
1344
  printTargetSurface(repo, options.target);
1325
1345
  printLensSurface(repo, options.target);
1346
+ printFastPrePushSurface(repo);
1326
1347
 
1327
1348
  return checkStatus;
1328
1349
  }
1329
1350
 
1351
+ function readFastCheck(repo) {
1352
+ const configPath = path.join(repo, "keel", "config.yaml");
1353
+ if (!fs.existsSync(configPath)) return null;
1354
+ for (const line of fs.readFileSync(configPath, "utf8").split(/\r?\n/)) {
1355
+ const stripped = line.trim();
1356
+ if (stripped.startsWith("#")) continue;
1357
+ const match = stripped.match(/^fast_check\s*:\s*(.+?)\s*$/);
1358
+ if (match) return match[1];
1359
+ }
1360
+ return null;
1361
+ }
1362
+
1363
+ function gitConfigHooksPath(repo) {
1364
+ const result = spawnSync(
1365
+ "git",
1366
+ ["-C", repo, "config", "--local", "--get", "core.hooksPath"],
1367
+ { encoding: "utf8" }
1368
+ );
1369
+ if (result.status !== 0) return null;
1370
+ const value = (result.stdout || "").trim();
1371
+ return value || null;
1372
+ }
1373
+
1374
+ function printFastPrePushSurface(repo) {
1375
+ process.stdout.write("\nFast pre-push surface:\n");
1376
+ const fastCheck = readFastCheck(repo);
1377
+ printDoctorLine(
1378
+ "fast_check",
1379
+ fastCheck ? "ok" : "none",
1380
+ fastCheck
1381
+ ? `declared in keel/config.yaml: ${fastCheck}`
1382
+ : "undeclared; add a fast_check line to keel/config.yaml"
1383
+ );
1384
+ const hookPresent = fs.existsSync(path.join(repo, ".githooks", "pre-push"));
1385
+ printDoctorLine(
1386
+ "pre-push hook",
1387
+ hookPresent ? "ok" : "none",
1388
+ hookPresent
1389
+ ? ".githooks/pre-push present"
1390
+ : "run keel --install --with-git-hooks to scaffold it"
1391
+ );
1392
+ const hooksPath = gitConfigHooksPath(repo);
1393
+ printDoctorLine(
1394
+ "core.hooksPath",
1395
+ hooksPath === ".githooks" ? "ok" : hooksPath ? "other" : "unset",
1396
+ hooksPath || "default (.git/hooks)"
1397
+ );
1398
+ }
1399
+
1330
1400
  const SHIPPED_LENS_DIR = path.join(PACKAGE_ROOT, "assets", "lenses");
1331
1401
  const EXPECTED_LENS_TEMPLATES = ["web", "hardware", "hardware-dsl"];
1332
1402
 
@@ -1445,6 +1515,9 @@ function runAction(options) {
1445
1515
  if (options.updateSource !== null && options.action !== "update") {
1446
1516
  fail("--source only applies to --update");
1447
1517
  }
1518
+ if (options.withGitHooks && options.action !== "install") {
1519
+ fail("--with-git-hooks only applies to --install");
1520
+ }
1448
1521
 
1449
1522
  if (options.action === "openspec") {
1450
1523
  const openspec = findOpenSpecCommand();
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@christang/keel",
3
3
  "displayName": "Keel",
4
4
  "description": "Keel OpenSpec execution discipline CLI for Claude Code, Codex, and OpenCode.",
5
- "version": "5.2.1",
5
+ "version": "5.2.3",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keel",
3
- "version": "5.2.1",
3
+ "version": "5.2.3",
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.1",
3
+ "version": "5.2.3",
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():
@@ -298,11 +432,38 @@ def unmanaged_keel_content_warning(repo: Path, relative_path: str) -> bool:
298
432
  return False
299
433
 
300
434
 
435
+ KEEL_PACKAGE_NAME = "@christang/keel"
436
+
437
+
438
+ def is_keel_source_repo(repo: Path) -> bool:
439
+ """Whether `repo` is Keel's own source repository.
440
+
441
+ Mirrors `isKeelSourceRepo` in src/core/capabilities.js. Both signals are
442
+ required so a project that merely vendors a plugins/keel/ directory is not
443
+ misclassified as Keel's own source.
444
+ """
445
+ try:
446
+ manifest = json.loads((repo / "package.json").read_text(encoding="utf-8"))
447
+ except (OSError, ValueError):
448
+ return False
449
+ if manifest.get("name") != KEEL_PACKAGE_NAME:
450
+ return False
451
+ return (repo / "plugins" / "keel").is_dir()
452
+
453
+
301
454
  def collect_actions(repo: Path, target: str) -> list[InstallAction]:
302
455
  actions: list[InstallAction] = []
303
456
  targets = target_set(target)
304
457
 
305
- if not unmanaged_keel_content_warning(repo, "AGENTS.md"):
458
+ # Keel's own AGENTS.md carries the full protocol that the validation suite
459
+ # asserts on; the packaged bootstrap is the shorter consumer-facing text.
460
+ # Writing it here replaces the protocol and turns the repository red.
461
+ if is_keel_source_repo(repo):
462
+ print(
463
+ "skip AGENTS.md: Keel source repository, whose AGENTS.md carries "
464
+ "the full protocol; the consumer bootstrap is not written here"
465
+ )
466
+ elif not unmanaged_keel_content_warning(repo, "AGENTS.md"):
306
467
  actions.append(
307
468
  managed_file_action("AGENTS.md", PACKAGE_ROOT / BOOTSTRAP_ASSET)
308
469
  )
@@ -310,6 +471,7 @@ def collect_actions(repo: Path, target: str) -> list[InstallAction]:
310
471
  actions.append(managed_content_action("CLAUDE.md", CLAUDE_IMPORT_BLOCK))
311
472
 
312
473
  actions.append(openspec_config_action())
474
+ actions.append(keel_config_action())
313
475
  actions.extend(openspec_schema_actions())
314
476
  return actions
315
477
 
@@ -768,6 +930,9 @@ def plan_action(
768
930
  if action.strategy == "openspec-config":
769
931
  merged, kind = merge_openspec_config(existing)
770
932
  return PlannedAction(kind, action.relative_path, None if kind == "skip" else merged)
933
+ if action.strategy == "keel-config-scaffold":
934
+ # Scaffold once: never overwrite a project's own keel/config.yaml.
935
+ return PlannedAction("skip", action.relative_path)
771
936
 
772
937
  if existing == source_content:
773
938
  return PlannedAction("skip", action.relative_path)
@@ -1007,6 +1172,14 @@ def main() -> int:
1007
1172
  action="store_true",
1008
1173
  help="Remove managed protocol blocks and safe generated skeleton files.",
1009
1174
  )
1175
+ parser.add_argument(
1176
+ "--with-git-hooks",
1177
+ action="store_true",
1178
+ help=(
1179
+ "Generate .githooks/pre-push from the declared fast_check and set "
1180
+ "core.hooksPath (install only); refuses without a fast_check."
1181
+ ),
1182
+ )
1010
1183
  parser.add_argument(
1011
1184
  "--profile",
1012
1185
  action="append",
@@ -1031,6 +1204,7 @@ def main() -> int:
1031
1204
  describe_actions(actions)
1032
1205
  if not args.dry_run:
1033
1206
  apply_actions(repo, actions)
1207
+ revert_git_hooks(repo, args.dry_run)
1034
1208
  return 0
1035
1209
  repo.mkdir(parents=True, exist_ok=True)
1036
1210
  actions = plan_actions(
@@ -1042,6 +1216,8 @@ def main() -> int:
1042
1216
  report_handoff_status(repo)
1043
1217
  if not args.dry_run:
1044
1218
  apply_actions(repo, actions)
1219
+ if args.with_git_hooks:
1220
+ return apply_git_hooks(repo, args.dry_run)
1045
1221
  return 0
1046
1222
  except ValueError as exc:
1047
1223
  print(f"Install failed: {exc}", file=sys.stderr)