@christang/keel 5.44.0 → 5.46.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.46.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.46.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.46.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.46.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.46.0"
41
+ PROTOCOL_VERSION = "5.46.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,466 @@ 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_root_file_is_a_path_scenario() -> int:
5584
+ """Issue #107: a file at the repository root had no separator to find.
5585
+
5586
+ `declaredPath()` located a path by finding a run of non-whitespace holding
5587
+ a path separator. Nine files sit at this repository's root and every one of
5588
+ them is a legitimate owner, so `Durable owner: AGENTS.md` was refused with
5589
+ `it names neither a check nor a path` — for a path whose file exists. The
5590
+ way past it was `./AGENTS.md`, which is a concession to the extractor
5591
+ rather than a path anyone meant.
5592
+
5593
+ The boundary is what keeps this from becoming worse than the defect: a bare
5594
+ word must stay unrecognized, or `Durable owner: pending` would be reported
5595
+ as a file that does not exist and send the author to create one.
5596
+ """
5597
+ label = "a-root-file-is-a-path"
5598
+
5599
+ with tempfile.TemporaryDirectory(prefix="keel-root-file-") as raw:
5600
+ repo = Path(raw) / "repo"
5601
+ repo.mkdir()
5602
+ tasks_path = repo / "openspec/changes/demo/tasks.md"
5603
+ write_text(repo / "openspec/changes/demo/proposal.md", "# Proposal\n")
5604
+ write_text(repo / "openspec/changes/demo/design.md", "## Context\n\nfixture\n")
5605
+ write_text(
5606
+ repo / "openspec/changes/demo/specs/demo/spec.md",
5607
+ "## ADDED Requirements\n",
5608
+ )
5609
+ write_text(repo / "AGENTS.md", "# Agents\n")
5610
+
5611
+ def completion(findings: str) -> dict:
5612
+ write_text(
5613
+ tasks_path,
5614
+ task_contract_fixture(evidence=("M1: check exercised.",))
5615
+ .replace("- [ ] 1.1", "- [x] 1.1")
5616
+ .replace(" - Status: pending\n", " - Status: pass\n")
5617
+ .replace(
5618
+ " - Acceptance check: pending\n",
5619
+ " - Acceptance check: behavior proven through the public CLI.\n",
5620
+ )
5621
+ .replace(
5622
+ " - Scope check: pending\n",
5623
+ " - Scope check: writes stayed inside Touch.\n",
5624
+ )
5625
+ .replace(" - Findings: pending\n", f" - Findings: {findings}\n")
5626
+ )
5627
+ record_contract_anchor(repo, "demo")
5628
+ result = run_keel(
5629
+ repo, "gate", "task-complete", "--change", "demo", "--task", "1.1",
5630
+ "--json",
5631
+ )
5632
+ return json.loads(result.stdout)
5633
+
5634
+ def messages(payload: dict) -> str:
5635
+ return " ".join(
5636
+ item.get("message", "") for item in payload.get("problems", [])
5637
+ )
5638
+
5639
+ accepted = (
5640
+ ("a root file as a durable owner", "still open. Durable owner: AGENTS.md"),
5641
+ ("a root file as resolution evidence", "repaired. Resolved here: AGENTS.md"),
5642
+ ("the same file spelled with ./", "still open. Durable owner: ./AGENTS.md"),
5643
+ (
5644
+ "a root file ending a sentence",
5645
+ "still open. Durable owner: AGENTS.md.",
5646
+ ),
5647
+ )
5648
+ for description, findings in accepted:
5649
+ payload = completion(findings)
5650
+ if payload.get("status") != "pass":
5651
+ report(
5652
+ f"{label}: {description} was refused. The file exists at the "
5653
+ "repository root; requiring a separator refuses a path the "
5654
+ "author may name and leaves them only a notation to change."
5655
+ )
5656
+ report(json.dumps(payload.get("problems", []), indent=2))
5657
+ return 1
5658
+
5659
+ # Existence still decides.
5660
+ payload = completion("still open. Durable owner: NOT-THERE.md")
5661
+ if payload.get("status") == "pass":
5662
+ report(f"{label}: a root file that does not exist was accepted.")
5663
+ return 1
5664
+
5665
+ # And where the gate names a missing path, it names a root file the
5666
+ # same way. `Findings` reports the generic owner refusal for every
5667
+ # unusable owner and always has; `## Invalidates` is the reader that
5668
+ # names the file, so the naming is asserted there rather than claimed
5669
+ # of a reader that never did it.
5670
+ def invalidates(closure: str) -> dict:
5671
+ write_text(
5672
+ tasks_path,
5673
+ task_contract_fixture().replace(
5674
+ "## Invalidates\n\n- None.\n\n",
5675
+ '## Invalidates\n\n- I1: "the wording that is now wrong" '
5676
+ f"— somewhere in the repo. {closure}\n\n",
5677
+ ),
5678
+ )
5679
+ result = run_keel(
5680
+ repo, "gate", "task-start", "--change", "demo", "--task", "1.1",
5681
+ "--json",
5682
+ )
5683
+ return json.loads(result.stdout)
5684
+
5685
+ payload = invalidates("Durable owner: AGENTS.md")
5686
+ if payload.get("status") != "pass":
5687
+ report(
5688
+ f"{label}: a root file was refused as an invalidation owner. "
5689
+ "One extractor serves every reader, so a form accepted in one "
5690
+ "must be accepted in all."
5691
+ )
5692
+ report(json.dumps(payload.get("problems", []), indent=2))
5693
+ return 1
5694
+
5695
+ payload = invalidates("Durable owner: NOT-THERE.md")
5696
+ text = messages(payload)
5697
+ if payload.get("status") == "pass":
5698
+ report(f"{label}: a missing root file closed an invalidation entry.")
5699
+ return 1
5700
+ if "NOT-THERE.md" not in text:
5701
+ report(
5702
+ f"{label}: the refusal for a missing root file did not name it. "
5703
+ "A path is the one form a gate can check, and naming what it "
5704
+ f"looked for is what makes the refusal repairable. Got: {text}"
5705
+ )
5706
+ return 1
5707
+
5708
+ # The boundary: a value with no path shape stays unrecognized, so the
5709
+ # author is not sent to create a file named `pending`.
5710
+ for description, findings in (
5711
+ ("a bare word", "still open. Durable owner: pending"),
5712
+ ("a version string", "still open. Durable owner: 5.44.0"),
5713
+ ):
5714
+ payload = completion(findings)
5715
+ text = messages(payload)
5716
+ if payload.get("status") == "pass":
5717
+ report(f"{label}: {description} was accepted as a durable owner.")
5718
+ return 1
5719
+ if "does not exist" in text:
5720
+ report(
5721
+ f"{label}: {description} was reported as a file that does "
5722
+ "not exist. It is not a path, and saying it is missing "
5723
+ "sends the author to create it. Got: " + text
5724
+ )
5725
+ return 1
5726
+
5727
+ if label not in {name for name, _ in SCENARIOS}:
5728
+ report(f"{label}: the scenario registry does not include it.")
5729
+ return 1
5730
+ report(f"{label} scenario passed.")
5731
+ return 0
5732
+
5733
+
5271
5734
  def validate_an_owner_outlives_the_change_scenario() -> int:
5272
5735
  """Issue #100: the existence check expires at archive.
5273
5736
 
@@ -9355,7 +9818,10 @@ def validate_spec_template_validates_scenario() -> int:
9355
9818
  )
9356
9819
  return 1
9357
9820
  if run_openspec(ROOT, "--version") is None:
9358
- report("spec-template-validates skipped: the openspec CLI is not on PATH.")
9821
+ report(
9822
+ "spec-template-validates skipped: the openspec CLI could not be "
9823
+ "found. Searched " + ", then ".join(OPENSPEC_SEARCH_ORDER) + "."
9824
+ )
9359
9825
  return 0
9360
9826
 
9361
9827
  filled = fill_template_slots(shipped.read_text(encoding="utf-8"))
@@ -13309,13 +13775,42 @@ SUPPORTED_VERIFICATION_STRATEGIES = (
13309
13775
  )
13310
13776
 
13311
13777
 
13312
- def run_openspec(cwd: Path, *args: str) -> subprocess.CompletedProcess[str] | None:
13313
- executable = shutil.which("openspec")
13778
+ # `@fission-ai/openspec` is this package's own declared dependency, and
13779
+ # `npm install` puts an executable at `node_modules/.bin/openspec`. npm scripts
13780
+ # see that directory on PATH; a Python subprocess started by
13781
+ # `node scripts/run_python.js` does not — so a checkout that had installed
13782
+ # everything it declares reported `validation --all failed for:
13783
+ # compact-task-authoring` while the schema it could supposedly not resolve
13784
+ # resolved perfectly (issue #105).
13785
+ #
13786
+ # The declared dependency wins over PATH. It is the version this repository is
13787
+ # tested against; a global install that happens to be on PATH is a different
13788
+ # version answering for it.
13789
+ OPENSPEC_SEARCH_ORDER = (
13790
+ "the package's own node_modules/.bin",
13791
+ "PATH",
13792
+ )
13793
+
13794
+
13795
+ def resolve_openspec(env: dict[str, str] | None = None) -> str | None:
13796
+ local = ROOT / "node_modules" / ".bin" / "openspec"
13797
+ if local.is_file():
13798
+ return str(local)
13799
+ return shutil.which("openspec", path=(env or os.environ).get("PATH"))
13800
+
13801
+
13802
+ def run_openspec(
13803
+ cwd: Path,
13804
+ *args: str,
13805
+ env: dict[str, str] | None = None,
13806
+ ) -> subprocess.CompletedProcess[str] | None:
13807
+ executable = resolve_openspec(env)
13314
13808
  if executable is None:
13315
13809
  return None
13316
13810
  return subprocess.run(
13317
13811
  [executable, *args],
13318
13812
  cwd=cwd,
13813
+ env=env,
13319
13814
  text=True,
13320
13815
  encoding="utf-8",
13321
13816
  errors="replace",
@@ -14812,10 +15307,22 @@ def validate_compact_task_authoring_scenario() -> int:
14812
15307
  local_root = ROOT / "openspec" / "schemas" / OPENSPEC_SCHEMA_NAME
14813
15308
 
14814
15309
  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())
15310
+ # Two distinct facts, kept apart. A CLI that was never found says nothing
15311
+ # about the schema, and reporting the schema as unresolvable sends the
15312
+ # reader to a subject with nothing wrong with it (issue #105).
15313
+ if which is None:
15314
+ report(
15315
+ "compact-task-authoring skipped: the openspec CLI could not be "
15316
+ "found. Searched " + ", then ".join(OPENSPEC_SEARCH_ORDER)
15317
+ + ". Run `npm install` to provide the declared dependency."
15318
+ )
15319
+ return 3
15320
+ if which.returncode != 0:
15321
+ report(
15322
+ "compact-task-authoring could not resolve the schema through "
15323
+ "OpenSpec. The CLI ran and refused:"
15324
+ )
15325
+ report((which.stderr or which.stdout).strip())
14819
15326
  return 1
14820
15327
  which_payload = json.loads(which.stdout[which.stdout.index("{"):])
14821
15328
  resolved = Path(which_payload.get("path", ""))
@@ -24362,6 +24869,19 @@ SCENARIOS: tuple = (
24362
24869
  "an-owner-outlives-the-change",
24363
24870
  validate_an_owner_outlives_the_change_scenario,
24364
24871
  ),
24872
+ ("a-root-file-is-a-path", validate_a_root_file_is_a_path_scenario),
24873
+ (
24874
+ "a-declared-dependency-is-resolved",
24875
+ validate_a_declared_dependency_is_resolved_scenario,
24876
+ ),
24877
+ (
24878
+ "the-tarball-is-the-repository",
24879
+ validate_the_tarball_is_the_repository_scenario,
24880
+ ),
24881
+ (
24882
+ "an-invalidates-phrase-may-wrap",
24883
+ validate_an_invalidates_phrase_may_wrap_scenario,
24884
+ ),
24365
24885
  (
24366
24886
  "the-spec-names-the-managed-set",
24367
24887
  validate_the_spec_names_the_managed_set_scenario,
package/src/core/gates.js CHANGED
@@ -417,9 +417,23 @@ 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
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).
426
+ //
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
+ // A declared path is a run of non-whitespace. What ends a path is whitespace;
435
+ // what a path is *made of* is the filesystem's business, and answering the
436
+ // first question with the second is what refused
423
437
  // `notes/note-006-转岗最难的不是流程/note.md` by reporting that
424
438
  // `notes/note-006-` does not exist — a path nobody wrote (issue #60). It is the
425
439
  // same class as #40 on the worktree-reading side, which survived because that
@@ -435,8 +449,15 @@ function declaredPath(value) {
435
449
  const quoted = text.match(/`([^`\n]*\/[^`\n]*)`/);
436
450
  if (quoted) return quoted[1].trim() || null;
437
451
  const bare = text.match(/[^\s`]+\/[^\s`]+/);
438
- if (!bare) return null;
439
- return bare[0].replace(DECLARED_PATH_TRAILING, "") || null;
452
+ if (bare) return bare[0].replace(DECLARED_PATH_TRAILING, "") || null;
453
+ // The separator form is tried first and is unchanged, so nothing that
454
+ // resolves today resolves differently. The trim runs before the shape is
455
+ // judged, so a root file ending a sentence is still a root file.
456
+ for (const token of text.split(/\s+/)) {
457
+ const trimmed = token.replace(DECLARED_PATH_TRAILING, "");
458
+ if (trimmed && ROOT_FILE_NAME.test(trimmed)) return trimmed;
459
+ }
460
+ return null;
440
461
  }
441
462
 
442
463
  // A path inside the selected change's own directory exists now and cannot
@@ -1225,7 +1246,15 @@ function invalidationProblems(repo, content, tasks, change) {
1225
1246
  const problems = [];
1226
1247
  for (const entry of entries) {
1227
1248
  const [, id, body] = entry;
1228
- if (!/"[^"\n]{3,}"/.test(body)) {
1249
+ // The quotation is read across the entry, not one line of it. An entry
1250
+ // carries a quotation, a location, and a closure, and wraps as often as it
1251
+ // needs to — 42 of this repository's 194 archived entries span more than
1252
+ // one line. Requiring the quotation to fit on one refused entries that had
1253
+ // named exactly what was asked for, and offered no repair but reflowing
1254
+ // the text (issue #108). The bound is the entry: `entries` above splits on
1255
+ // the next `I<n>`, so a quotation cannot reach past its own. `Findings` in
1256
+ // this same file is already read as wrapping.
1257
+ if (!/"[^"]{3,}"/.test(body)) {
1229
1258
  problems.push(
1230
1259
  problem(
1231
1260
  "invalidation-phrase",