@christang/keel 5.46.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.46.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.46.0",
5
+ "version": "5.47.0",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keel",
3
- "version": "5.46.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.46.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.46.0"
41
- PROTOCOL_VERSION = "5.46.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
@@ -5580,6 +5580,205 @@ def validate_a_declared_dependency_is_resolved_scenario() -> int:
5580
5580
  return 0
5581
5581
 
5582
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
+
5583
5782
  def validate_a_root_file_is_a_path_scenario() -> int:
5584
5783
  """Issue #107: a file at the repository root had no separator to find.
5585
5784
 
@@ -24870,6 +25069,10 @@ SCENARIOS: tuple = (
24870
25069
  validate_an_owner_outlives_the_change_scenario,
24871
25070
  ),
24872
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
+ ),
24873
25076
  (
24874
25077
  "a-declared-dependency-is-resolved",
24875
25078
  validate_a_declared_dependency_is_resolved_scenario,
package/src/core/gates.js CHANGED
@@ -431,25 +431,58 @@ const DECLARED_PATH_TRAILING = /[.,;:!?)\]}"'\u2019\u201d\u3002\uff0c\u3001\uff1
431
431
  // and authors write versions in prose beside an owner.
432
432
  const ROOT_FILE_NAME = /^[^\s`]+\.[A-Za-z][A-Za-z0-9]{0,7}$/;
433
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
437
- // `notes/note-006-转岗最难的不是流程/note.md` by reporting that
438
- // `notes/note-006-` does not exist a path nobody wrote (issue #60). It is the
439
- // same class as #40 on the worktree-reading side, which survived because that
440
- // fix repaired one reader rather than how paths are extracted; this is the one
441
- // extractor every gate reader of a declared path now uses.
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.
442
441
  //
443
- // The backtick form wins when present. It is the only way to write a path
444
- // containing whitespace, and `touchEntries` already strips backticks from a
445
- // Touch entry, so one authorship stops being spelled two ways depending on
446
- // which reader will read it.
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
+
447
478
  function declaredPath(value) {
448
479
  const text = String(value || "");
449
- const quoted = text.match(/`([^`\n]*\/[^`\n]*)`/);
450
- if (quoted) return quoted[1].trim() || null;
451
- const bare = text.match(/[^\s`]+\/[^\s`]+/);
480
+ const leading = backtickedPath(text.match(/^\s*`([^`\n]*\/[^`\n]*)`/));
481
+ if (leading) return leading;
482
+ const bare = text.match(BARE_DECLARED_PATH);
452
483
  if (bare) return bare[0].replace(DECLARED_PATH_TRAILING, "") || null;
484
+ const quoted = backtickedPath(text.match(/`([^`\n]*\/[^`\n]*)`/));
485
+ if (quoted) return quoted;
453
486
  // The separator form is tried first and is unchanged, so nothing that
454
487
  // resolves today resolves differently. The trim runs before the shape is
455
488
  // judged, so a root file ending a sentence is still a root file.