@christang/keel 5.2.0 → 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 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.0 -->
1
+ <!-- keel:start version=5.2.2 -->
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.
@@ -21,7 +21,7 @@ Ordinary narrative stays unnumbered. -->
21
21
  ## Hidden Knowledge / Assumptions
22
22
 
23
23
  <!-- Accepted hidden-knowledge assumptions from risk-triggered grill or domain
24
- profiles. Critical assumptions use A<n>, record Basis, and name Resolve by or a
24
+ lenses. Critical assumptions use A<n>, record Basis, and name Resolve by or a
25
25
  durable Owner. Put compressed recovery context here when future sessions need
26
26
  it; keel/HANDOFF.md should only point to durable owners. Use "None." when empty. -->
27
27
 
@@ -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
@@ -47,7 +47,7 @@ const {
47
47
  const PACKAGE_ROOT = path.resolve(__dirname, "..");
48
48
  const PACKAGE_JSON = require(path.join(PACKAGE_ROOT, "package.json"));
49
49
  const INSTALL_SCRIPT = path.join(PACKAGE_ROOT, "scripts", "install_to_repo.py");
50
- const DEFAULT_UPDATE_SOURCE = "github:TanglmChris/keel";
50
+ const DEFAULT_UPDATE_SOURCE = "@christang/keel";
51
51
  const VALID_TARGETS = new Set(["claude", "codex", "opencode", "both"]);
52
52
  const KEEL_SKILLS = [
53
53
  "keel-align-expectations",
@@ -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) {
@@ -378,8 +384,8 @@ function parseArgs(argv) {
378
384
  if (arg === "--profile" || arg.startsWith("--profile=")) {
379
385
  fail(
380
386
  "--profile is no longer supported: web, hardware, and hardware-dsl "
381
- + "guidance is bundled with the keel-align-expectations skill as "
382
- + "on-demand references"
387
+ + "guidance is now user-authored lenses in keel/lenses/*.md "
388
+ + "(scaffold with `keel lenses add`)"
383
389
  );
384
390
  }
385
391
  if (arg === "--repo") {
@@ -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
@@ -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.0",
5
+ "version": "5.2.2",
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.0",
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.0",
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,10 +1145,18 @@ 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",
1013
- help="Obsolete in v4; domain references are bundled with keel-align-expectations.",
1159
+ help="Obsolete; domain guidance is now user-authored lenses in keel/lenses/*.md.",
1014
1160
  )
1015
1161
  args = parser.parse_args()
1016
1162
 
@@ -1019,8 +1165,8 @@ def main() -> int:
1019
1165
  if args.profile:
1020
1166
  print(
1021
1167
  "Install failed: --profile is no longer supported; web, hardware, "
1022
- "and hardware-dsl guidance is bundled with the "
1023
- "keel-align-expectations skill as on-demand references",
1168
+ "and hardware-dsl guidance is now user-authored lenses in "
1169
+ "keel/lenses/*.md (scaffold with `keel lenses add`)",
1024
1170
  file=sys.stderr,
1025
1171
  )
1026
1172
  return 1
@@ -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.0"
41
- PROTOCOL_VERSION = "5.2.0"
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")
@@ -1750,23 +1750,23 @@ def validate_authoring_continuity_scenario() -> int:
1750
1750
  return 0
1751
1751
 
1752
1752
 
1753
- def validate_domain_profiles_scenario() -> int:
1753
+ def validate_domain_lenses_scenario() -> int:
1754
1754
  plugin_skills_root = ROOT / PLUGIN_ROOT / "skills"
1755
1755
  if not (plugin_skills_root / "keel-align-expectations/SKILL.md").is_file():
1756
- report("domain-profiles scenario plugin misses the alignment skill")
1756
+ report("domain-lenses scenario plugin misses the alignment skill")
1757
1757
  return 1
1758
1758
  lenses_root = ROOT / "assets/lenses"
1759
1759
  for template in ("web.md", "hardware.md", "hardware-dsl.md"):
1760
1760
  if not (lenses_root / template).is_file():
1761
1761
  report(
1762
- "domain-profiles scenario misses the shipped lens template: "
1762
+ "domain-lenses scenario misses the shipped lens template: "
1763
1763
  f"{template}"
1764
1764
  )
1765
1765
  return 1
1766
1766
  for legacy_skill in LEGACY_PROFILE_SKILLS:
1767
1767
  if (plugin_skills_root / legacy_skill).exists():
1768
1768
  report(
1769
- f"domain-profiles scenario plugin packages a legacy profile: {legacy_skill}"
1769
+ f"domain-lenses scenario plugin packages a legacy profile: {legacy_skill}"
1770
1770
  )
1771
1771
  return 1
1772
1772
 
@@ -1776,20 +1776,20 @@ def validate_domain_profiles_scenario() -> int:
1776
1776
  repo.mkdir()
1777
1777
  install = run_keel(repo, "--install", "--target", "codex")
1778
1778
  if install.returncode != 0:
1779
- report("domain-profiles scenario default install failed:")
1779
+ report("domain-lenses scenario default install failed:")
1780
1780
  report((install.stderr or install.stdout).strip())
1781
1781
  return 1
1782
1782
  if (repo / TARGET_SKILL_ROOTS["codex"]).exists():
1783
1783
  report(
1784
- "domain-profiles scenario thin install copied Keel skill trees; "
1784
+ "domain-lenses scenario thin install copied Keel skill trees; "
1785
1785
  "skills are plugin-owned in v4."
1786
1786
  )
1787
1787
  return 1
1788
1788
 
1789
1789
  rejected = run_keel(repo, "--install", "--target", "codex", "--profile", "web")
1790
1790
  rejected_text = (rejected.stderr or "") + (rejected.stdout or "")
1791
- if rejected.returncode == 0 or "bundled" not in rejected_text:
1792
- report("domain-profiles scenario still accepts --profile.")
1791
+ if rejected.returncode == 0 or "keel/lenses" not in rejected_text:
1792
+ report("domain-lenses scenario still accepts --profile.")
1793
1793
  report(rejected_text.strip())
1794
1794
  return 1
1795
1795
 
@@ -1801,7 +1801,7 @@ def validate_domain_profiles_scenario() -> int:
1801
1801
  or "Keel profiles" in doctor_text
1802
1802
  ):
1803
1803
  report(
1804
- "domain-profiles scenario doctor still reports profile state or "
1804
+ "domain-lenses scenario doctor still reports profile state or "
1805
1805
  "misses the native plugin surface."
1806
1806
  )
1807
1807
  report(doctor_text.strip())
@@ -1814,11 +1814,11 @@ def validate_domain_profiles_scenario() -> int:
1814
1814
  else ""
1815
1815
  )
1816
1816
  if uninstall.returncode != 0 or "keel:start" in agents_text:
1817
- report("domain-profiles scenario uninstall left the managed bootstrap.")
1817
+ report("domain-lenses scenario uninstall left the managed bootstrap.")
1818
1818
  report((uninstall.stderr or uninstall.stdout).strip())
1819
1819
  return 1
1820
1820
 
1821
- report("domain-profiles scenario passed.")
1821
+ report("domain-lenses scenario passed.")
1822
1822
  return 0
1823
1823
 
1824
1824
 
@@ -2396,6 +2396,42 @@ def validate_update_pack_install_scenario() -> int:
2396
2396
  return 0
2397
2397
 
2398
2398
 
2399
+ def validate_update_default_registry_scenario() -> int:
2400
+ with tempfile.TemporaryDirectory(prefix="keel-update-default-") as raw_tmp:
2401
+ tmp = Path(raw_tmp)
2402
+ update = run_keel(tmp, "--update", "--dry-run")
2403
+ if update.returncode != 0:
2404
+ report("update-default-registry scenario keel --update --dry-run failed:")
2405
+ report((update.stderr or update.stdout).strip())
2406
+ return 1
2407
+
2408
+ pack_plan = (
2409
+ "would run npm pack" in update.stdout
2410
+ or "would run npm.cmd pack" in update.stdout
2411
+ )
2412
+ if not pack_plan:
2413
+ report("update-default-registry scenario did not report a pack plan.")
2414
+ report(update.stdout.strip())
2415
+ return 1
2416
+ if "@christang/keel" not in update.stdout:
2417
+ report(
2418
+ "update-default-registry scenario default source is not the "
2419
+ "published registry package @christang/keel."
2420
+ )
2421
+ report(update.stdout.strip())
2422
+ return 1
2423
+ if "github:" in update.stdout:
2424
+ report(
2425
+ "update-default-registry scenario default source is a git-type "
2426
+ "spec; self-update must default to the registry package."
2427
+ )
2428
+ report(update.stdout.strip())
2429
+ return 1
2430
+
2431
+ report("update-default-registry scenario passed.")
2432
+ return 0
2433
+
2434
+
2399
2435
  def run_keel_hook(repo: Path, event: dict) -> subprocess.CompletedProcess[str]:
2400
2436
  env = dict(os.environ)
2401
2437
  env["KEEL_CLI"] = str(ROOT / "bin/keel.js")
@@ -2642,7 +2678,7 @@ def validate_cli_scenario() -> int:
2642
2678
  if (
2643
2679
  update.returncode != 0
2644
2680
  or not pack_plan
2645
- or "github:TanglmChris/keel" not in update.stdout
2681
+ or "@christang/keel" not in update.stdout
2646
2682
  or not install_plan
2647
2683
  ):
2648
2684
  report("cli scenario keel --update did not report global CLI update plan.")
@@ -5184,6 +5220,107 @@ def validate_core_gates_scenario() -> int:
5184
5220
  return 0
5185
5221
 
5186
5222
 
5223
+ def validate_scope_rename_attribution_scenario() -> int:
5224
+ rename_task = (
5225
+ "# Tasks\n\n"
5226
+ "- [ ] 1.1 Complete behavior\n"
5227
+ " - Owner: keel-agent\n"
5228
+ " - Mode: implementation\n"
5229
+ " - Covers:\n"
5230
+ " - E1: public behavior\n"
5231
+ " - Read:\n"
5232
+ " - README.md\n"
5233
+ " - Touch:\n"
5234
+ " - src/renamed-from.js\n"
5235
+ " - src/renamed-to.js\n"
5236
+ " - openspec/changes/demo/tasks.md\n"
5237
+ " - Commands:\n"
5238
+ " - M1: node test.js\n"
5239
+ " - Acceptance:\n"
5240
+ " - Public behavior passes.\n"
5241
+ " - Autonomy boundary:\n"
5242
+ " - Default: hard-stop\n"
5243
+ " - Pre-authorized fallback: none\n"
5244
+ " - Coupling: none\n"
5245
+ " - Candidate Boundary:\n"
5246
+ " - One complete candidate reaches M1.\n"
5247
+ " - Stop Rules:\n"
5248
+ " - Stop on final assertion failure.\n"
5249
+ " - Evidence:\n"
5250
+ " - M1: passed\n"
5251
+ " - Review:\n"
5252
+ " - Status: pass\n"
5253
+ " - Acceptance check: public behavior reviewed\n"
5254
+ " - Scope check: Touch reviewed semantically\n"
5255
+ " - Findings: none\n"
5256
+ " - Blocker: none\n"
5257
+ " - Stop if:\n"
5258
+ " - Requires files outside Touch.\n"
5259
+ " - Report:\n"
5260
+ " - Summary\n"
5261
+ )
5262
+ with tempfile.TemporaryDirectory(prefix="keel-scope-rename-") as raw_tmp:
5263
+ repo = Path(raw_tmp)
5264
+ write_text(repo / "openspec/changes/demo/tasks.md", rename_task)
5265
+ write_text(repo / "src/renamed-from.js", "module.exports = 1;\n")
5266
+ write_text(repo / "README.md", "readme\n")
5267
+ for args in (
5268
+ ["init", "--quiet"],
5269
+ ["config", "user.email", "keel@example.invalid"],
5270
+ ["config", "user.name", "Keel Fixture"],
5271
+ ["add", "."],
5272
+ ["commit", "--quiet", "-m", "baseline"],
5273
+ ):
5274
+ result = subprocess.run(
5275
+ ["git", *args], cwd=repo, capture_output=True, text=True
5276
+ )
5277
+ if result.returncode != 0:
5278
+ report("scope-rename scenario git setup failed:")
5279
+ report((result.stderr or result.stdout).strip())
5280
+ return 1
5281
+ # A staged rename whose old and new paths are both in Touch must not be a
5282
+ # false outside-Touch failure (git reports it as one `old -> new` entry).
5283
+ moved = subprocess.run(
5284
+ ["git", "mv", "src/renamed-from.js", "src/renamed-to.js"],
5285
+ cwd=repo,
5286
+ capture_output=True,
5287
+ text=True,
5288
+ )
5289
+ if moved.returncode != 0:
5290
+ report("scope-rename scenario git mv failed:")
5291
+ report((moved.stderr or moved.stdout).strip())
5292
+ return 1
5293
+ completed = run_keel(
5294
+ repo,
5295
+ "gate",
5296
+ "task-complete",
5297
+ "--change",
5298
+ "demo",
5299
+ "--task",
5300
+ "1.1",
5301
+ "--base",
5302
+ "HEAD",
5303
+ "--json",
5304
+ )
5305
+ payload = json.loads(completed.stdout or "{}")
5306
+ outside = " ".join(
5307
+ problem.get("message", "") for problem in payload.get("problems", [])
5308
+ )
5309
+ if (
5310
+ completed.returncode != 0
5311
+ or payload.get("status") != "pass"
5312
+ or "outside" in outside.lower()
5313
+ ):
5314
+ report(
5315
+ "scope-rename scenario reported a false outside-touch failure for a "
5316
+ "git mv rename whose old and new paths are both in Touch."
5317
+ )
5318
+ report((completed.stderr or completed.stdout).strip())
5319
+ return 1
5320
+ report("scope-rename scenario passed.")
5321
+ return 0
5322
+
5323
+
5187
5324
  def validate_target_capability_adapters_scenario() -> int:
5188
5325
  capability_keys = (
5189
5326
  "continuity.start",
@@ -7550,6 +7687,297 @@ def validate_native_tasks_view_scenario() -> int:
7550
7687
  return 0
7551
7688
 
7552
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
+
7553
7981
  def validate_native_goal_gate_order_scenario() -> int:
7554
7982
  with tempfile.TemporaryDirectory(prefix="keel-goal-order-") as raw_tmp:
7555
7983
  root = Path(raw_tmp)
@@ -9950,6 +10378,7 @@ def validate_doctor_openspec_honesty_scenario() -> int:
9950
10378
  SCENARIOS: tuple = (
9951
10379
  ("stateless-continuity", validate_stateless_continuity_scenario),
9952
10380
  ("core-gates", validate_core_gates_scenario),
10381
+ ("scope-rename-attribution", validate_scope_rename_attribution_scenario),
9953
10382
  ("target-capability-adapters", validate_target_capability_adapters_scenario),
9954
10383
  ("native-runtime-projection", validate_native_runtime_projection_scenario),
9955
10384
  ("target-surface", validate_target_surface_scenario),
@@ -9957,7 +10386,7 @@ SCENARIOS: tuple = (
9957
10386
  ("expectation-slice-gates", validate_expectation_slice_gates_scenario),
9958
10387
  ("expectation-completion-gates", validate_expectation_completion_gates_scenario),
9959
10388
  ("authoring-continuity", validate_authoring_continuity_scenario),
9960
- ("domain-profiles", validate_domain_profiles_scenario),
10389
+ ("domain-lenses", validate_domain_lenses_scenario),
9961
10390
  ("skill-portability-policy", validate_skill_portability_policy_scenario),
9962
10391
  ("version-alignment", validate_version_alignment_scenario),
9963
10392
  ("openspec-surface-overlay", validate_openspec_surface_overlay_scenario),
@@ -9965,6 +10394,12 @@ SCENARIOS: tuple = (
9965
10394
  ("cli", validate_cli_scenario),
9966
10395
  ("doctor-openspec-honesty", validate_doctor_openspec_honesty_scenario),
9967
10396
  ("update-pack-install", validate_update_pack_install_scenario),
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),
9968
10403
  ("task-contract-core", validate_task_contract_core_scenario),
9969
10404
  ("task-capsule", validate_task_capsule_scenario),
9970
10405
  ("task-verification-strategies", validate_task_verification_strategies_scenario),
package/src/core/gates.js CHANGED
@@ -237,7 +237,16 @@ function gitPaths(repo) {
237
237
  return status.stdout
238
238
  .split(/\r?\n/)
239
239
  .filter(Boolean)
240
- .map((line) => line.slice(3).trim().replace(/\\/g, "/"));
240
+ .flatMap((line) => {
241
+ // A staged rename/copy is one porcelain line, `R old -> new`; attribute
242
+ // both endpoints so a rename whose old and new paths are in Touch is not
243
+ // a false outside-Touch failure. Every other line carries one path.
244
+ const entry = line.slice(3).trim().replace(/\\/g, "/");
245
+ const arrow = entry.indexOf(" -> ");
246
+ return arrow === -1
247
+ ? [entry]
248
+ : [entry.slice(0, arrow), entry.slice(arrow + 4)];
249
+ });
241
250
  }
242
251
 
243
252
  function touchEntries(task, contract = null) {
@@ -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
- const match = entry.match(/^(M[1-9]\d*):\s*(.*)$/);
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[2]) }
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
- commands: taskVerification.commands.filter((entry) => entry.label),
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,