@christang/keel 5.44.0 → 5.47.0

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.
@@ -1,4 +1,4 @@
1
- <!-- keel:start version=5.44.0 -->
1
+ <!-- keel:start version=5.47.0 -->
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.
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.44.0",
5
+ "version": "5.47.0",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
@@ -17,7 +17,9 @@
17
17
  "src/core/",
18
18
  "assets/",
19
19
  "README.md",
20
- "plugins/"
20
+ "plugins/",
21
+ "!**/__pycache__",
22
+ "!**/*.pyc"
21
23
  ],
22
24
  "scripts": {
23
25
  "validate": "node scripts/run_python.js scripts/validate_plugin.py",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keel",
3
- "version": "5.44.0",
3
+ "version": "5.47.0",
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.44.0",
3
+ "version": "5.47.0",
4
4
  "description": "Keel OpenSpec execution discipline: stateless continuity, task capsules, deterministic gates, and expectation alignment for Codex and Claude Code.",
5
5
  "author": {
6
6
  "name": "TanglmChris",
@@ -37,8 +37,8 @@ REQUIRED_SCRIPTS = [
37
37
  "scripts/validate_plugin.py",
38
38
  ]
39
39
 
40
- PACKAGE_VERSION = "5.44.0"
41
- PROTOCOL_VERSION = "5.44.0"
40
+ PACKAGE_VERSION = "5.47.0"
41
+ PROTOCOL_VERSION = "5.47.0"
42
42
  LEGACY_MANAGED_START = "<!-- keel:start version=2.1 -->"
43
43
  OPENSPEC_SCHEMA_NAME = "keel-spec-driven"
44
44
  # Mirrors KEEL_PACKAGE_NAME in scripts/install_to_repo.py, one of the two
@@ -5052,7 +5052,10 @@ def validate_authoring_surface_owner_and_tags_scenario() -> int:
5052
5052
  return 1
5053
5053
  created = run_openspec(repo, "new", "change", "surface-probe")
5054
5054
  if created is None:
5055
- report(f"{label} skipped: the openspec CLI is not on PATH.")
5055
+ report(
5056
+ f"{label} skipped: the openspec CLI could not be found. "
5057
+ "Searched " + ", then ".join(OPENSPEC_SEARCH_ORDER) + "."
5058
+ )
5056
5059
  return 3
5057
5060
  if created.returncode != 0:
5058
5061
  report(f"{label} could not scaffold a change to read the instruction.")
@@ -5268,6 +5271,665 @@ def validate_the_spec_names_the_managed_set_scenario() -> int:
5268
5271
  return 0
5269
5272
 
5270
5273
 
5274
+ def validate_an_invalidates_phrase_may_wrap_scenario() -> int:
5275
+ """Issue #108: the quoted phrase had to fit on one line.
5276
+
5277
+ An `## Invalidates` entry carries a quotation, a location, and a closure,
5278
+ and wraps as often as it needs to. The phrase test was
5279
+ `/"[^"\\n]{3,}"/`, so a quotation that ran past the end of a line was
5280
+ reported as `names where to look but not what to look for` — when it named
5281
+ exactly that, and the only repair was to reflow the text.
5282
+
5283
+ The corpus looked clean for the wrong reason: 42 of this repository's 194
5284
+ archived entries span more than one line and none carries a wrapped
5285
+ quotation, because the gate refused every one that did. Five entries in a
5286
+ single session were shortened to fit.
5287
+
5288
+ The bound is the entry, set by the section parser, so a quotation cannot
5289
+ reach past its own entry — asserted below by refusing a second, unquoted
5290
+ entry while the wrapped one is accepted.
5291
+ """
5292
+ label = "an-invalidates-phrase-may-wrap"
5293
+
5294
+ with tempfile.TemporaryDirectory(prefix="keel-wrapped-phrase-") as raw:
5295
+ repo = Path(raw) / "repo"
5296
+ repo.mkdir()
5297
+ tasks_path = repo / "openspec/changes/demo/tasks.md"
5298
+ write_text(repo / "openspec/changes/demo/proposal.md", "# Proposal\n")
5299
+ write_text(repo / "openspec/changes/demo/design.md", "## Context\n\nfixture\n")
5300
+ write_text(
5301
+ repo / "openspec/changes/demo/specs/demo/spec.md",
5302
+ "## ADDED Requirements\n",
5303
+ )
5304
+
5305
+ def start(section: str) -> dict:
5306
+ write_text(
5307
+ tasks_path,
5308
+ task_contract_fixture().replace(
5309
+ "## Invalidates\n\n- None.\n\n", section
5310
+ ),
5311
+ )
5312
+ result = run_keel(
5313
+ repo, "gate", "task-start", "--change", "demo", "--task", "1.1",
5314
+ "--json",
5315
+ )
5316
+ return json.loads(result.stdout)
5317
+
5318
+ def problems(payload: dict) -> str:
5319
+ return " ".join(
5320
+ item.get("code", "") + ": " + item.get("message", "")
5321
+ for item in payload.get("problems", [])
5322
+ )
5323
+
5324
+ wrapped = (
5325
+ '## Invalidates\n\n'
5326
+ '- I1: "the wording that is now wrong and runs on\n'
5327
+ ' past the end of this line" — somewhere in the repo.\n'
5328
+ ' Updated by: 1.1\n\n'
5329
+ )
5330
+ payload = start(wrapped)
5331
+ if payload.get("status") != "pass":
5332
+ report(
5333
+ f"{label}: an entry whose quotation wraps was refused. It named "
5334
+ "the wording a reader would search for; what it did not do was "
5335
+ "fit on one line, and reflowing text is not a repair."
5336
+ )
5337
+ report(json.dumps(payload.get("problems", []), indent=2))
5338
+ return 1
5339
+
5340
+ # The requirement itself is unchanged: an entry with no quotation
5341
+ # anywhere in its body is still refused.
5342
+ unquoted = (
5343
+ '## Invalidates\n\n'
5344
+ '- I1: the wording that is now wrong and runs on\n'
5345
+ ' past the end of this line — somewhere in the repo.\n'
5346
+ ' Updated by: 1.1\n\n'
5347
+ )
5348
+ payload = start(unquoted)
5349
+ if payload.get("status") == "pass":
5350
+ report(
5351
+ f"{label}: an entry carrying no quotation at all was accepted. "
5352
+ "Reading the phrase across lines must widen where the quotation "
5353
+ "may sit, not remove the requirement to write one."
5354
+ )
5355
+ return 1
5356
+ if "invalidation-phrase" not in problems(payload):
5357
+ report(
5358
+ f"{label}: the unquoted entry was refused for some other reason, "
5359
+ "so this assertion proves nothing about the phrase check. Got: "
5360
+ + problems(payload)
5361
+ )
5362
+ return 1
5363
+
5364
+ # The bound is the entry. A wrapped quotation in I1 must not satisfy an
5365
+ # unquoted I2 sitting below it.
5366
+ mixed = (
5367
+ '## Invalidates\n\n'
5368
+ '- I1: "the wording that is now wrong and runs on\n'
5369
+ ' past the end of this line" — somewhere in the repo.\n'
5370
+ ' Updated by: 1.1\n'
5371
+ '- I2: some other statement — elsewhere in the repo.\n'
5372
+ ' Updated by: 1.1\n\n'
5373
+ )
5374
+ payload = start(mixed)
5375
+ text = problems(payload)
5376
+ if payload.get("status") == "pass":
5377
+ report(
5378
+ f"{label}: an unquoted entry below a wrapped one was accepted. "
5379
+ "The quotation is bounded by its own entry; if it reaches the "
5380
+ "next one, every entry after a quoted one passes for free."
5381
+ )
5382
+ return 1
5383
+ if "I2" not in text:
5384
+ report(
5385
+ f"{label}: the run failed, but not for I2 — so this assertion "
5386
+ "proves nothing about the entry bound. The unquoted entry is "
5387
+ "the one that must be named. Got: " + text
5388
+ )
5389
+ return 1
5390
+ if "I1" in text:
5391
+ report(
5392
+ f"{label}: the wrapped entry was reported alongside the unquoted "
5393
+ "one. It carries its quotation and must not be named."
5394
+ )
5395
+ report(text)
5396
+ return 1
5397
+
5398
+ if label not in {name for name, _ in SCENARIOS}:
5399
+ report(f"{label}: the scenario registry does not include it.")
5400
+ return 1
5401
+ report(f"{label} scenario passed.")
5402
+ return 0
5403
+
5404
+
5405
+ def validate_the_tarball_is_the_repository_scenario() -> int:
5406
+ """Issue #110: what ships depended on the packer's working tree.
5407
+
5408
+ `files` names `scripts/`, and declaring a `files` array means `.gitignore`
5409
+ stops filtering inside it — while `__pycache__` is not on npm's default
5410
+ exclusion list. Packing one commit twice: 41 files on a clean checkout, 43
5411
+ after anyone has run the Python in `scripts/`.
5412
+
5413
+ The assertion is that every packed file is tracked by Git. That states the
5414
+ requirement directly and cannot be satisfied by a machine's leftovers; a
5415
+ recomputed list of expected files would have to reimplement npm's inclusion
5416
+ rules and would drift from them.
5417
+ """
5418
+ label = "the-tarball-is-the-repository"
5419
+
5420
+ npm = shutil.which("npm")
5421
+ if npm is None:
5422
+ report(f"{label} skipped: npm is not on PATH, and it is what packs.")
5423
+ return 3
5424
+
5425
+ # Reproduce the state the issue reports before asserting anything, so the
5426
+ # check runs against a tree that has the residue rather than one that
5427
+ # happens not to.
5428
+ residue = ROOT / "scripts" / "__pycache__" / "keel-pack-probe.pyc"
5429
+ created_dir = not residue.parent.exists()
5430
+ residue.parent.mkdir(parents=True, exist_ok=True)
5431
+ residue.write_bytes(b"probe\n")
5432
+ try:
5433
+ packed = subprocess.run(
5434
+ [npm, "pack", "--dry-run", "--json"],
5435
+ cwd=ROOT,
5436
+ text=True,
5437
+ encoding="utf-8",
5438
+ errors="replace",
5439
+ capture_output=True,
5440
+ check=False,
5441
+ )
5442
+ if packed.returncode != 0:
5443
+ report(f"{label}: npm pack --dry-run failed.")
5444
+ report((packed.stderr or packed.stdout).strip())
5445
+ return 1
5446
+ try:
5447
+ files = [item["path"] for item in json.loads(packed.stdout)[0]["files"]]
5448
+ except (ValueError, KeyError, IndexError):
5449
+ report(f"{label}: could not read the file list from npm pack --json.")
5450
+ report((packed.stdout or "").strip()[:400])
5451
+ return 1
5452
+
5453
+ tracked = subprocess.run(
5454
+ ["git", "ls-files", "-z"],
5455
+ cwd=ROOT,
5456
+ capture_output=True,
5457
+ check=False,
5458
+ )
5459
+ if tracked.returncode != 0:
5460
+ report(f"{label}: git ls-files failed, so tracked state is unknown.")
5461
+ return 1
5462
+ known = {
5463
+ name for name in tracked.stdout.decode("utf-8").split("\0") if name
5464
+ }
5465
+
5466
+ untracked = sorted(path for path in files if path not in known)
5467
+ if untracked:
5468
+ report(
5469
+ f"{label}: {len(untracked)} packed file(s) are not tracked by "
5470
+ "Git, so what ships depends on the machine that packs it "
5471
+ "rather than on the repository:"
5472
+ )
5473
+ for path in untracked:
5474
+ report(f" {path}")
5475
+ return 1
5476
+ if not files:
5477
+ report(f"{label}: npm pack reported no files, so nothing was checked.")
5478
+ return 1
5479
+ finally:
5480
+ residue.unlink(missing_ok=True)
5481
+ if created_dir:
5482
+ try:
5483
+ residue.parent.rmdir()
5484
+ except OSError:
5485
+ pass
5486
+
5487
+ if label not in {name for name, _ in SCENARIOS}:
5488
+ report(f"{label}: the scenario registry does not include it.")
5489
+ return 1
5490
+ report(f"{label} scenario passed: {len(files)} packed files, all tracked.")
5491
+ return 0
5492
+
5493
+
5494
+ def validate_a_declared_dependency_is_resolved_scenario() -> int:
5495
+ """Issue #105: the suite looked for its own dependency only on PATH.
5496
+
5497
+ `@fission-ai/openspec` is declared in `package.json` and lands at
5498
+ `node_modules/.bin/openspec`. npm scripts see that directory; a Python
5499
+ subprocess started by `node scripts/run_python.js` does not. So on a
5500
+ checkout that had only run `npm install`, `--all` reported
5501
+ `validation --all failed for: compact-task-authoring` while the schema it
5502
+ was said to be unable to resolve resolved perfectly.
5503
+
5504
+ The second half is that scenario's own condition: an unresolvable CLI and a
5505
+ CLI that ran and refused shared one message, so the reader was sent to a
5506
+ subject with nothing wrong with it.
5507
+ """
5508
+ label = "a-declared-dependency-is-resolved"
5509
+
5510
+ # `no openspec on PATH`, and nothing else removed. Emptying PATH outright
5511
+ # would also remove `node`, and the openspec shim needs it — the scenario
5512
+ # would then be asserting that a shell without an interpreter fails.
5513
+ without = dict(os.environ)
5514
+ without["PATH"] = os.pathsep.join(
5515
+ entry
5516
+ for entry in os.environ.get("PATH", "").split(os.pathsep)
5517
+ if entry and not (Path(entry) / "openspec").exists()
5518
+ )
5519
+ if shutil.which("openspec", path=without["PATH"]) is not None:
5520
+ report(
5521
+ f"{label}: could not build a PATH without openspec on it, so the "
5522
+ "reproduction cannot be set up."
5523
+ )
5524
+ return 1
5525
+ resolved = run_openspec(ROOT, "--version", env=without)
5526
+ if resolved is None:
5527
+ report(
5528
+ f"{label}: with no `openspec` on PATH the runner resolved nothing, "
5529
+ "but this package declares it as a dependency and installs it at "
5530
+ "node_modules/.bin. A tool the repository ships is not a tool the "
5531
+ "host has to provide."
5532
+ )
5533
+ return 1
5534
+ if resolved.returncode != 0 or not re.search(r"\d+\.\d+\.\d+", resolved.stdout):
5535
+ report(
5536
+ f"{label}: the resolved openspec did not report a version. "
5537
+ f"exit={resolved.returncode} out={(resolved.stdout or '').strip()!r}"
5538
+ )
5539
+ return 1
5540
+
5541
+ # And it is the declared one, not whatever a host happens to carry.
5542
+ declared = ROOT / "node_modules" / ".bin" / "openspec"
5543
+ if not declared.exists():
5544
+ report(
5545
+ f"{label}: {declared} is absent, so this scenario cannot tell a "
5546
+ "resolved dependency from a lucky PATH entry. Run `npm install`."
5547
+ )
5548
+ return 3
5549
+
5550
+ # The two failures carry their own messages. Asserted on the emitted text
5551
+ # of the scenario that had them fused, not on the source.
5552
+ fused = (ROOT / "scripts" / "validate_plugin.py").read_text(encoding="utf-8")
5553
+ marker = 'label = "compact-task-authoring"'
5554
+ start = fused.find(marker)
5555
+ if start < 0:
5556
+ report(f"{label}: compact-task-authoring is gone, so its branch cannot be read.")
5557
+ return 1
5558
+ body = fused[start : start + 3000]
5559
+ for needle, why in (
5560
+ ("could not be found", "the unresolvable branch must say the tool was not found"),
5561
+ ("searched", "the unresolvable branch must name where it looked"),
5562
+ ("return 3", "an unresolvable tool reports the skip contract, not a failure"),
5563
+ ):
5564
+ if needle not in body:
5565
+ report(f"{label}: {why}; `{needle}` is absent from its branch.")
5566
+ return 1
5567
+ if re.search(r"if which is None or which\.returncode", body):
5568
+ report(
5569
+ f"{label}: compact-task-authoring still guards two distinct "
5570
+ "failures behind one condition. A tool that was never found and a "
5571
+ "tool that ran and refused are different facts, and reporting the "
5572
+ "first message when the second happened names the wrong cause."
5573
+ )
5574
+ return 1
5575
+
5576
+ if label not in {name for name, _ in SCENARIOS}:
5577
+ report(f"{label}: the scenario registry does not include it.")
5578
+ return 1
5579
+ report(f"{label} scenario passed.")
5580
+ return 0
5581
+
5582
+
5583
+ def validate_a_path_is_what_the_value_declares_scenario() -> int:
5584
+ """Issue #113: the extractor answered with a path the value did not declare.
5585
+
5586
+ Two shapes, measured across five consuming repositories — 624 path-shaped
5587
+ declarations, 34 of them extracting something else.
5588
+
5589
+ A sentence supplies no whitespace. `openspec/FOLLOWUP.md。②接着说别的`
5590
+ extracted whole, because the run is bounded by whitespace and Chinese prose
5591
+ puts none after a path. That half refuses loudly, naming a path nobody
5592
+ wrote.
5593
+
5594
+ A citation outranked the declaration. The backtick branch ran first and
5595
+ searched the whole value, so a `Durable owner:` that named its owner and
5596
+ then quoted another file resolved to the quotation. That half is worse: the
5597
+ quoted file usually exists, so the gate accepts and checks a file the
5598
+ author never declared.
5599
+ """
5600
+ label = "a-path-is-what-the-value-declares"
5601
+
5602
+ with tempfile.TemporaryDirectory(prefix="keel-declared-path-") as raw:
5603
+ repo = Path(raw) / "repo"
5604
+ repo.mkdir()
5605
+ tasks_path = repo / "openspec/changes/demo/tasks.md"
5606
+ write_text(repo / "openspec/changes/demo/proposal.md", "# Proposal\n")
5607
+ write_text(repo / "openspec/changes/demo/design.md", "## Context\n\nfixture\n")
5608
+ write_text(
5609
+ repo / "openspec/changes/demo/specs/demo/spec.md",
5610
+ "## ADDED Requirements\n",
5611
+ )
5612
+ for name in (
5613
+ "docs/owner.md",
5614
+ "docs/cited.md",
5615
+ "docs/\u7b2c\u4e00\u7ae0/\u603b\u8bba.md",
5616
+ "AGENTS.md",
5617
+ ):
5618
+ write_text(repo / name, "x\n")
5619
+ write_text(repo / "docs/a b.md", "x\n")
5620
+
5621
+ def completion(findings: str) -> dict:
5622
+ write_text(
5623
+ tasks_path,
5624
+ task_contract_fixture(evidence=("M1: check exercised.",))
5625
+ .replace("- [ ] 1.1", "- [x] 1.1")
5626
+ .replace(" - Status: pending\n", " - Status: pass\n")
5627
+ .replace(
5628
+ " - Acceptance check: pending\n",
5629
+ " - Acceptance check: behavior proven through the public CLI.\n",
5630
+ )
5631
+ .replace(
5632
+ " - Scope check: pending\n",
5633
+ " - Scope check: writes stayed inside Touch.\n",
5634
+ )
5635
+ .replace(" - Findings: pending\n", f" - Findings: {findings}\n")
5636
+ )
5637
+ record_contract_anchor(repo, "demo")
5638
+ result = run_keel(
5639
+ repo, "gate", "task-complete", "--change", "demo", "--task", "1.1",
5640
+ "--json",
5641
+ )
5642
+ return json.loads(result.stdout)
5643
+
5644
+ def messages(payload: dict) -> str:
5645
+ return " ".join(
5646
+ item.get("message", "") for item in payload.get("problems", [])
5647
+ )
5648
+
5649
+ # --- the citation must not outrank the declaration ---------------
5650
+ cited = "\u5224\u636e\u89c1 `docs/cited.md`"
5651
+ payload = completion(f"still open. Durable owner: docs/owner.md\uff0c{cited}")
5652
+ if payload.get("status") != "pass":
5653
+ report(
5654
+ f"{label}: a value naming an existing owner and then citing "
5655
+ "another existing file was refused."
5656
+ )
5657
+ report(json.dumps(payload.get("problems", []), indent=2))
5658
+ return 1
5659
+
5660
+ # Proven by removing the declared owner: the refusal must name it, not
5661
+ # the citation. Without this the assertion above passes either way,
5662
+ # because both files exist.
5663
+ (repo / "docs/owner.md").unlink()
5664
+ payload = completion(f"still open. Durable owner: docs/owner.md\uff0c{cited}")
5665
+ if payload.get("status") == "pass":
5666
+ report(
5667
+ f"{label}: with the declared owner deleted the value still "
5668
+ "passed, so the gate checked the cited file instead. A "
5669
+ "citation that outranks the declaration lets a finding be "
5670
+ "owned by a file the author never named."
5671
+ )
5672
+ return 1
5673
+
5674
+ # Which path the gate looked for, asserted through the reader that
5675
+ # names one. `Findings` reports a single owner refusal for every
5676
+ # unusable owner and never names the path, so the naming half is
5677
+ # asserted where it is actually emitted.
5678
+ def invalidates(closure: str) -> dict:
5679
+ write_text(
5680
+ tasks_path,
5681
+ task_contract_fixture().replace(
5682
+ "## Invalidates\n\n- None.\n\n",
5683
+ '## Invalidates\n\n- I1: "the wording that is now wrong" '
5684
+ f"\u2014 somewhere in the repo. {closure}\n\n",
5685
+ ),
5686
+ )
5687
+ result = run_keel(
5688
+ repo, "gate", "task-start", "--change", "demo", "--task", "1.1",
5689
+ "--json",
5690
+ )
5691
+ return json.loads(result.stdout)
5692
+
5693
+ payload = invalidates(f"Durable owner: docs/owner.md\uff0c{cited}")
5694
+ text = messages(payload)
5695
+ if payload.get("status") == "pass":
5696
+ report(
5697
+ f"{label}: an invalidation entry whose declared owner is "
5698
+ "missing passed, so the gate resolved the citation."
5699
+ )
5700
+ return 1
5701
+ if "docs/cited.md" in text or "docs/owner.md" not in text:
5702
+ report(
5703
+ f"{label}: the refusal named the citation rather than the "
5704
+ f"declared path. Got: {text or '(none)'}"
5705
+ )
5706
+ return 1
5707
+ write_text(repo / "docs/owner.md", "x\n")
5708
+
5709
+ payload = invalidates(f"Durable owner: docs/owner.md\uff0c{cited}")
5710
+ if payload.get("status") != "pass":
5711
+ report(
5712
+ f"{label}: with the declared owner restored the invalidation "
5713
+ "entry was still refused."
5714
+ )
5715
+ report(json.dumps(payload.get("problems", []), indent=2))
5716
+ return 1
5717
+
5718
+ # --- a bare run ends where the sentence does ---------------------
5719
+ accepted = (
5720
+ (
5721
+ "a path followed by a CJK terminator and more prose",
5722
+ "still open. Durable owner: docs/owner.md\u3002\u2461\u63a5\u7740\u8bf4\u522b\u7684",
5723
+ ),
5724
+ (
5725
+ "a path followed by a CJK comma",
5726
+ "still open. Durable owner: docs/owner.md\uff0c\u968f\u540e\u8bf4\u660e",
5727
+ ),
5728
+ (
5729
+ "a path whose own segments are CJK words",
5730
+ "still open. Durable owner: docs/\u7b2c\u4e00\u7ae0/\u603b\u8bba.md\u3002\u540e\u6587",
5731
+ ),
5732
+ (
5733
+ "a backticked path containing a space, opening the value",
5734
+ "still open. Durable owner: `docs/a b.md` \u8bf4\u660e",
5735
+ ),
5736
+ (
5737
+ "a backticked path with nothing declared before it",
5738
+ "still open. Durable owner: \u89c1 `docs/cited.md`",
5739
+ ),
5740
+ ("a repository-root file", "still open. Durable owner: AGENTS.md"),
5741
+ ("the same file with a leading dot-slash", "still open. Durable owner: ./AGENTS.md"),
5742
+ (
5743
+ "an ASCII path ending a sentence",
5744
+ "still open. Durable owner: docs/owner.md. Then more text",
5745
+ ),
5746
+ )
5747
+ for description, findings in accepted:
5748
+ payload = completion(findings)
5749
+ if payload.get("status") != "pass":
5750
+ report(f"{label}: {description} was refused.")
5751
+ report(json.dumps(payload.get("problems", []), indent=2))
5752
+ return 1
5753
+
5754
+ # --- the boundary: a non-path stays unrecognized ------------------
5755
+ for description, findings in (
5756
+ ("a bare word", "still open. Durable owner: pending"),
5757
+ ("a version string", "still open. Durable owner: 5.44.0"),
5758
+ (
5759
+ "a separator between two adjacent inline code spans",
5760
+ "still open. Durable owner: \u9996\u7248\u53ea\u88c5 `networkx`/`PyYAML`",
5761
+ ),
5762
+ ):
5763
+ payload = completion(findings)
5764
+ text = messages(payload)
5765
+ if payload.get("status") == "pass":
5766
+ report(f"{label}: {description} was accepted as a durable owner.")
5767
+ return 1
5768
+ if "does not exist" in text:
5769
+ report(
5770
+ f"{label}: {description} was reported as a missing file. "
5771
+ "Got: " + text
5772
+ )
5773
+ return 1
5774
+
5775
+ if label not in {name for name, _ in SCENARIOS}:
5776
+ report(f"{label}: the scenario registry does not include it.")
5777
+ return 1
5778
+ report(f"{label} scenario passed.")
5779
+ return 0
5780
+
5781
+
5782
+ def validate_a_root_file_is_a_path_scenario() -> int:
5783
+ """Issue #107: a file at the repository root had no separator to find.
5784
+
5785
+ `declaredPath()` located a path by finding a run of non-whitespace holding
5786
+ a path separator. Nine files sit at this repository's root and every one of
5787
+ them is a legitimate owner, so `Durable owner: AGENTS.md` was refused with
5788
+ `it names neither a check nor a path` — for a path whose file exists. The
5789
+ way past it was `./AGENTS.md`, which is a concession to the extractor
5790
+ rather than a path anyone meant.
5791
+
5792
+ The boundary is what keeps this from becoming worse than the defect: a bare
5793
+ word must stay unrecognized, or `Durable owner: pending` would be reported
5794
+ as a file that does not exist and send the author to create one.
5795
+ """
5796
+ label = "a-root-file-is-a-path"
5797
+
5798
+ with tempfile.TemporaryDirectory(prefix="keel-root-file-") as raw:
5799
+ repo = Path(raw) / "repo"
5800
+ repo.mkdir()
5801
+ tasks_path = repo / "openspec/changes/demo/tasks.md"
5802
+ write_text(repo / "openspec/changes/demo/proposal.md", "# Proposal\n")
5803
+ write_text(repo / "openspec/changes/demo/design.md", "## Context\n\nfixture\n")
5804
+ write_text(
5805
+ repo / "openspec/changes/demo/specs/demo/spec.md",
5806
+ "## ADDED Requirements\n",
5807
+ )
5808
+ write_text(repo / "AGENTS.md", "# Agents\n")
5809
+
5810
+ def completion(findings: str) -> dict:
5811
+ write_text(
5812
+ tasks_path,
5813
+ task_contract_fixture(evidence=("M1: check exercised.",))
5814
+ .replace("- [ ] 1.1", "- [x] 1.1")
5815
+ .replace(" - Status: pending\n", " - Status: pass\n")
5816
+ .replace(
5817
+ " - Acceptance check: pending\n",
5818
+ " - Acceptance check: behavior proven through the public CLI.\n",
5819
+ )
5820
+ .replace(
5821
+ " - Scope check: pending\n",
5822
+ " - Scope check: writes stayed inside Touch.\n",
5823
+ )
5824
+ .replace(" - Findings: pending\n", f" - Findings: {findings}\n")
5825
+ )
5826
+ record_contract_anchor(repo, "demo")
5827
+ result = run_keel(
5828
+ repo, "gate", "task-complete", "--change", "demo", "--task", "1.1",
5829
+ "--json",
5830
+ )
5831
+ return json.loads(result.stdout)
5832
+
5833
+ def messages(payload: dict) -> str:
5834
+ return " ".join(
5835
+ item.get("message", "") for item in payload.get("problems", [])
5836
+ )
5837
+
5838
+ accepted = (
5839
+ ("a root file as a durable owner", "still open. Durable owner: AGENTS.md"),
5840
+ ("a root file as resolution evidence", "repaired. Resolved here: AGENTS.md"),
5841
+ ("the same file spelled with ./", "still open. Durable owner: ./AGENTS.md"),
5842
+ (
5843
+ "a root file ending a sentence",
5844
+ "still open. Durable owner: AGENTS.md.",
5845
+ ),
5846
+ )
5847
+ for description, findings in accepted:
5848
+ payload = completion(findings)
5849
+ if payload.get("status") != "pass":
5850
+ report(
5851
+ f"{label}: {description} was refused. The file exists at the "
5852
+ "repository root; requiring a separator refuses a path the "
5853
+ "author may name and leaves them only a notation to change."
5854
+ )
5855
+ report(json.dumps(payload.get("problems", []), indent=2))
5856
+ return 1
5857
+
5858
+ # Existence still decides.
5859
+ payload = completion("still open. Durable owner: NOT-THERE.md")
5860
+ if payload.get("status") == "pass":
5861
+ report(f"{label}: a root file that does not exist was accepted.")
5862
+ return 1
5863
+
5864
+ # And where the gate names a missing path, it names a root file the
5865
+ # same way. `Findings` reports the generic owner refusal for every
5866
+ # unusable owner and always has; `## Invalidates` is the reader that
5867
+ # names the file, so the naming is asserted there rather than claimed
5868
+ # of a reader that never did it.
5869
+ def invalidates(closure: str) -> dict:
5870
+ write_text(
5871
+ tasks_path,
5872
+ task_contract_fixture().replace(
5873
+ "## Invalidates\n\n- None.\n\n",
5874
+ '## Invalidates\n\n- I1: "the wording that is now wrong" '
5875
+ f"— somewhere in the repo. {closure}\n\n",
5876
+ ),
5877
+ )
5878
+ result = run_keel(
5879
+ repo, "gate", "task-start", "--change", "demo", "--task", "1.1",
5880
+ "--json",
5881
+ )
5882
+ return json.loads(result.stdout)
5883
+
5884
+ payload = invalidates("Durable owner: AGENTS.md")
5885
+ if payload.get("status") != "pass":
5886
+ report(
5887
+ f"{label}: a root file was refused as an invalidation owner. "
5888
+ "One extractor serves every reader, so a form accepted in one "
5889
+ "must be accepted in all."
5890
+ )
5891
+ report(json.dumps(payload.get("problems", []), indent=2))
5892
+ return 1
5893
+
5894
+ payload = invalidates("Durable owner: NOT-THERE.md")
5895
+ text = messages(payload)
5896
+ if payload.get("status") == "pass":
5897
+ report(f"{label}: a missing root file closed an invalidation entry.")
5898
+ return 1
5899
+ if "NOT-THERE.md" not in text:
5900
+ report(
5901
+ f"{label}: the refusal for a missing root file did not name it. "
5902
+ "A path is the one form a gate can check, and naming what it "
5903
+ f"looked for is what makes the refusal repairable. Got: {text}"
5904
+ )
5905
+ return 1
5906
+
5907
+ # The boundary: a value with no path shape stays unrecognized, so the
5908
+ # author is not sent to create a file named `pending`.
5909
+ for description, findings in (
5910
+ ("a bare word", "still open. Durable owner: pending"),
5911
+ ("a version string", "still open. Durable owner: 5.44.0"),
5912
+ ):
5913
+ payload = completion(findings)
5914
+ text = messages(payload)
5915
+ if payload.get("status") == "pass":
5916
+ report(f"{label}: {description} was accepted as a durable owner.")
5917
+ return 1
5918
+ if "does not exist" in text:
5919
+ report(
5920
+ f"{label}: {description} was reported as a file that does "
5921
+ "not exist. It is not a path, and saying it is missing "
5922
+ "sends the author to create it. Got: " + text
5923
+ )
5924
+ return 1
5925
+
5926
+ if label not in {name for name, _ in SCENARIOS}:
5927
+ report(f"{label}: the scenario registry does not include it.")
5928
+ return 1
5929
+ report(f"{label} scenario passed.")
5930
+ return 0
5931
+
5932
+
5271
5933
  def validate_an_owner_outlives_the_change_scenario() -> int:
5272
5934
  """Issue #100: the existence check expires at archive.
5273
5935
 
@@ -9355,7 +10017,10 @@ def validate_spec_template_validates_scenario() -> int:
9355
10017
  )
9356
10018
  return 1
9357
10019
  if run_openspec(ROOT, "--version") is None:
9358
- report("spec-template-validates skipped: the openspec CLI is not on PATH.")
10020
+ report(
10021
+ "spec-template-validates skipped: the openspec CLI could not be "
10022
+ "found. Searched " + ", then ".join(OPENSPEC_SEARCH_ORDER) + "."
10023
+ )
9359
10024
  return 0
9360
10025
 
9361
10026
  filled = fill_template_slots(shipped.read_text(encoding="utf-8"))
@@ -13309,13 +13974,42 @@ SUPPORTED_VERIFICATION_STRATEGIES = (
13309
13974
  )
13310
13975
 
13311
13976
 
13312
- def run_openspec(cwd: Path, *args: str) -> subprocess.CompletedProcess[str] | None:
13313
- executable = shutil.which("openspec")
13977
+ # `@fission-ai/openspec` is this package's own declared dependency, and
13978
+ # `npm install` puts an executable at `node_modules/.bin/openspec`. npm scripts
13979
+ # see that directory on PATH; a Python subprocess started by
13980
+ # `node scripts/run_python.js` does not — so a checkout that had installed
13981
+ # everything it declares reported `validation --all failed for:
13982
+ # compact-task-authoring` while the schema it could supposedly not resolve
13983
+ # resolved perfectly (issue #105).
13984
+ #
13985
+ # The declared dependency wins over PATH. It is the version this repository is
13986
+ # tested against; a global install that happens to be on PATH is a different
13987
+ # version answering for it.
13988
+ OPENSPEC_SEARCH_ORDER = (
13989
+ "the package's own node_modules/.bin",
13990
+ "PATH",
13991
+ )
13992
+
13993
+
13994
+ def resolve_openspec(env: dict[str, str] | None = None) -> str | None:
13995
+ local = ROOT / "node_modules" / ".bin" / "openspec"
13996
+ if local.is_file():
13997
+ return str(local)
13998
+ return shutil.which("openspec", path=(env or os.environ).get("PATH"))
13999
+
14000
+
14001
+ def run_openspec(
14002
+ cwd: Path,
14003
+ *args: str,
14004
+ env: dict[str, str] | None = None,
14005
+ ) -> subprocess.CompletedProcess[str] | None:
14006
+ executable = resolve_openspec(env)
13314
14007
  if executable is None:
13315
14008
  return None
13316
14009
  return subprocess.run(
13317
14010
  [executable, *args],
13318
14011
  cwd=cwd,
14012
+ env=env,
13319
14013
  text=True,
13320
14014
  encoding="utf-8",
13321
14015
  errors="replace",
@@ -14812,10 +15506,22 @@ def validate_compact_task_authoring_scenario() -> int:
14812
15506
  local_root = ROOT / "openspec" / "schemas" / OPENSPEC_SCHEMA_NAME
14813
15507
 
14814
15508
  which = run_openspec(ROOT, "schema", "which", OPENSPEC_SCHEMA_NAME, "--json")
14815
- if which is None or which.returncode != 0:
14816
- report("compact-task-authoring could not resolve the schema through OpenSpec.")
14817
- if which is not None:
14818
- report((which.stderr or which.stdout).strip())
15509
+ # Two distinct facts, kept apart. A CLI that was never found says nothing
15510
+ # about the schema, and reporting the schema as unresolvable sends the
15511
+ # reader to a subject with nothing wrong with it (issue #105).
15512
+ if which is None:
15513
+ report(
15514
+ "compact-task-authoring skipped: the openspec CLI could not be "
15515
+ "found. Searched " + ", then ".join(OPENSPEC_SEARCH_ORDER)
15516
+ + ". Run `npm install` to provide the declared dependency."
15517
+ )
15518
+ return 3
15519
+ if which.returncode != 0:
15520
+ report(
15521
+ "compact-task-authoring could not resolve the schema through "
15522
+ "OpenSpec. The CLI ran and refused:"
15523
+ )
15524
+ report((which.stderr or which.stdout).strip())
14819
15525
  return 1
14820
15526
  which_payload = json.loads(which.stdout[which.stdout.index("{"):])
14821
15527
  resolved = Path(which_payload.get("path", ""))
@@ -24362,6 +25068,23 @@ SCENARIOS: tuple = (
24362
25068
  "an-owner-outlives-the-change",
24363
25069
  validate_an_owner_outlives_the_change_scenario,
24364
25070
  ),
25071
+ ("a-root-file-is-a-path", validate_a_root_file_is_a_path_scenario),
25072
+ (
25073
+ "a-path-is-what-the-value-declares",
25074
+ validate_a_path_is_what_the_value_declares_scenario,
25075
+ ),
25076
+ (
25077
+ "a-declared-dependency-is-resolved",
25078
+ validate_a_declared_dependency_is_resolved_scenario,
25079
+ ),
25080
+ (
25081
+ "the-tarball-is-the-repository",
25082
+ validate_the_tarball_is_the_repository_scenario,
25083
+ ),
25084
+ (
25085
+ "an-invalidates-phrase-may-wrap",
25086
+ validate_an_invalidates_phrase_may_wrap_scenario,
25087
+ ),
24365
25088
  (
24366
25089
  "the-spec-names-the-managed-set",
24367
25090
  validate_the_spec_names_the_managed_set_scenario,
package/src/core/gates.js CHANGED
@@ -417,26 +417,80 @@ const DURABLE_OWNER_FORMS =
417
417
  // swallows it and the gate looks for a file that cannot exist.
418
418
  const DECLARED_PATH_TRAILING = /[.,;:!?)\]}"'\u2019\u201d\u3002\uff0c\u3001\uff1b\uff1a\uff01\uff1f\uff09\u3011\u300b\u300d\u300f]+$/;
419
419
 
420
- // A declared path is a run of non-whitespace holding a separator. What ends a
421
- // path is whitespace; what a path is *made of* is the filesystem's business,
422
- // and answering the first question with the second is what refused
423
- // `notes/note-006-转岗最难的不是流程/note.md` by reporting that
424
- // `notes/note-006-` does not exist a path nobody wrote (issue #60). It is the
425
- // same class as #40 on the worktree-reading side, which survived because that
426
- // fix repaired one reader rather than how paths are extracted; this is the one
427
- // extractor every gate reader of a declared path now uses.
420
+ // A file at the repository root carries no separator, and nine of them sit at
421
+ // this repository's root `AGENTS.md`, `README.md`, `package.json` among them
422
+ // every one a legitimate owner. Requiring a separator refused them with "it
423
+ // names neither a check nor a path", for a path whose file exists, and left
424
+ // the author only `./AGENTS.md`: a concession to this function rather than a
425
+ // path anyone meant (issue #107).
428
426
  //
429
- // The backtick form wins when present. It is the only way to write a path
430
- // containing whitespace, and `touchEntries` already strips backticks from a
431
- // Touch entry, so one authorship stops being spelled two ways depending on
432
- // which reader will read it.
427
+ // The shape is a trailing extension beginning with a letter. That is what
428
+ // keeps a bare word unrecognized — `Durable owner: pending` reported as a
429
+ // missing file would send the author to create one and what keeps a version
430
+ // string out, since `5.44.0` would otherwise read as `44` with extension `0`,
431
+ // and authors write versions in prose beside an owner.
432
+ const ROOT_FILE_NAME = /^[^\s`]+\.[A-Za-z][A-Za-z0-9]{0,7}$/;
433
+
434
+ // What ends a path is whitespace — in a script that separates words with it.
435
+ // Chinese prose does not: a path is followed immediately by its terminator, so
436
+ // a run bounded only by whitespace swallowed the rest of the sentence and
437
+ // `openspec/FOLLOWUP.md。②本波两次重录重验` came back as one path (issue #113,
438
+ // 14 instances across five consuming repositories). These characters are
439
+ // already in `DECLARED_PATH_TRAILING`, which strips what *trails* the run and
440
+ // therefore never reached a terminator sitting inside it.
441
+ //
442
+ // ASCII punctuation keeps the other treatment — permitted inside the run and
443
+ // trimmed from its end — because `a.b/c-d.e` and `f(1)/g` are paths and ASCII
444
+ // prose supplies the whitespace that ends them. The asymmetry follows from the
445
+ // writing system rather than from a preference.
446
+ const PATH_TERMINATORS = "。,、;:!?()【】《》「」〈〉“”‘’";
447
+ const BARE_DECLARED_PATH = new RegExp(
448
+ `[^\\s\`${PATH_TERMINATORS}]+/[^\\s\`${PATH_TERMINATORS}]+`
449
+ );
450
+
451
+ // A declared path is a run of non-whitespace. What a path is *made of* is the
452
+ // filesystem's business, and answering that question with the boundary is what
453
+ // refused `notes/note-006-转岗最难的不是流程/note.md` by reporting that
454
+ // `notes/note-006-` does not exist — a path nobody wrote (issue #60). Adding
455
+ // terminators does not narrow the alphabet: a path whose segments are CJK
456
+ // words still extracts in full, and only its punctuation ends it.
457
+ //
458
+ // The declaration is what the value opens with. A leading backticked path wins
459
+ // because that is the only way to write a path containing whitespace, and
460
+ // `touchEntries` already accepts that form. A backticked span *elsewhere* is a
461
+ // citation: `Findings` is free prose where naming the owner and then quoting a
462
+ // file is ordinary, and taking the quotation first answered with a file the
463
+ // author never declared — one that usually exists, so the gate accepted rather
464
+ // than refused (issue #113, 20 instances). It is still read when the value
465
+ // declares nothing else, which keeps `Durable owner: 见 \`docs/a b.md\`` working.
466
+ // A separator with nothing on either side of it is not a path. Two adjacent
467
+ // inline code spans put one there — `networkx`/`PyYAML` closes one span and
468
+ // opens the next, so a backtick-delimited capture spanning the gap is the
469
+ // single character `/`. Measured twice in the consumer corpus, each time
470
+ // reported as a file that does not exist.
471
+ function backtickedPath(match) {
472
+ if (!match) return null;
473
+ const candidate = match[1].trim();
474
+ if (!candidate.split("/").some(Boolean)) return null;
475
+ return candidate || null;
476
+ }
477
+
433
478
  function declaredPath(value) {
434
479
  const text = String(value || "");
435
- const quoted = text.match(/`([^`\n]*\/[^`\n]*)`/);
436
- if (quoted) return quoted[1].trim() || null;
437
- const bare = text.match(/[^\s`]+\/[^\s`]+/);
438
- if (!bare) return null;
439
- return bare[0].replace(DECLARED_PATH_TRAILING, "") || null;
480
+ const leading = backtickedPath(text.match(/^\s*`([^`\n]*\/[^`\n]*)`/));
481
+ if (leading) return leading;
482
+ const bare = text.match(BARE_DECLARED_PATH);
483
+ if (bare) return bare[0].replace(DECLARED_PATH_TRAILING, "") || null;
484
+ const quoted = backtickedPath(text.match(/`([^`\n]*\/[^`\n]*)`/));
485
+ if (quoted) return quoted;
486
+ // The separator form is tried first and is unchanged, so nothing that
487
+ // resolves today resolves differently. The trim runs before the shape is
488
+ // judged, so a root file ending a sentence is still a root file.
489
+ for (const token of text.split(/\s+/)) {
490
+ const trimmed = token.replace(DECLARED_PATH_TRAILING, "");
491
+ if (trimmed && ROOT_FILE_NAME.test(trimmed)) return trimmed;
492
+ }
493
+ return null;
440
494
  }
441
495
 
442
496
  // A path inside the selected change's own directory exists now and cannot
@@ -1225,7 +1279,15 @@ function invalidationProblems(repo, content, tasks, change) {
1225
1279
  const problems = [];
1226
1280
  for (const entry of entries) {
1227
1281
  const [, id, body] = entry;
1228
- if (!/"[^"\n]{3,}"/.test(body)) {
1282
+ // The quotation is read across the entry, not one line of it. An entry
1283
+ // carries a quotation, a location, and a closure, and wraps as often as it
1284
+ // needs to — 42 of this repository's 194 archived entries span more than
1285
+ // one line. Requiring the quotation to fit on one refused entries that had
1286
+ // named exactly what was asked for, and offered no repair but reflowing
1287
+ // the text (issue #108). The bound is the entry: `entries` above splits on
1288
+ // the next `I<n>`, so a quotation cannot reach past its own. `Findings` in
1289
+ // this same file is already read as wrapping.
1290
+ if (!/"[^"]{3,}"/.test(body)) {
1229
1291
  problems.push(
1230
1292
  problem(
1231
1293
  "invalidation-phrase",