@christang/keel 5.2.3 → 5.2.4

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.2.3 -->
1
+ <!-- keel:start version=5.2.4 -->
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/bin/keel.js CHANGED
@@ -1344,10 +1344,28 @@ function runDoctor(options) {
1344
1344
  printTargetSurface(repo, options.target);
1345
1345
  printLensSurface(repo, options.target);
1346
1346
  printFastPrePushSurface(repo);
1347
+ printSourceRepoCliResolution(repo);
1347
1348
 
1348
1349
  return checkStatus;
1349
1350
  }
1350
1351
 
1352
+ // Only meaningful in Keel's own repository: a bare `keel` resolves to the
1353
+ // installed package, so gate commands verify the released CLI rather than the
1354
+ // working tree. The failure mode is a silently stale result, not an error, so
1355
+ // it is worth stating rather than leaving to be discovered.
1356
+ function printSourceRepoCliResolution(repo) {
1357
+ if (!isKeelSourceRepo(repo)) return;
1358
+ process.stdout.write("\nKeel source repository:\n");
1359
+ printDoctorLine(
1360
+ "gate CLI resolution",
1361
+ "advisory",
1362
+ "a bare `keel` runs the installed package, so gate results will not "
1363
+ + "reflect uncommitted changes to gate, contract, or capability code; "
1364
+ + "run `node bin/keel.js gate <stage> . --change <c> --task <t>` "
1365
+ + "instead — the repository argument is required"
1366
+ );
1367
+ }
1368
+
1351
1369
  function readFastCheck(repo) {
1352
1370
  const configPath = path.join(repo, "keel", "config.yaml");
1353
1371
  if (!fs.existsSync(configPath)) return null;
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.3",
5
+ "version": "5.2.4",
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.3",
3
+ "version": "5.2.4",
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.3",
3
+ "version": "5.2.4",
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",
@@ -33,6 +33,14 @@ KEEL_CONFIG_TEMPLATE = (
33
33
  "# Example:\n"
34
34
  "# fast_check: npm test -- --fast\n"
35
35
  )
36
+ KEEL_GITIGNORE_PATH = KEEL_ROOT / ".gitignore"
37
+ KEEL_GITIGNORE_TEMPLATE = (
38
+ "# Keel local state. keel/ otherwise holds project content that belongs in\n"
39
+ "# version control (config.yaml, CHANGELOG.md, archive/); the write-guard\n"
40
+ "# manifest is per-clone session state that a gate run writes and\n"
41
+ "# `keel guard clear` removes.\n"
42
+ "guard.json\n"
43
+ )
36
44
  GITHOOKS_DIR = ".githooks"
37
45
  PRE_PUSH_PATH = Path(GITHOOKS_DIR) / "pre-push"
38
46
  OPENSPEC_ROOT = Path("openspec")
@@ -222,6 +230,14 @@ def keel_config_action() -> InstallAction:
222
230
  )
223
231
 
224
232
 
233
+ def keel_gitignore_action() -> InstallAction:
234
+ return InstallAction(
235
+ relative_path=KEEL_GITIGNORE_PATH,
236
+ content=KEEL_GITIGNORE_TEMPLATE,
237
+ strategy="keel-config-scaffold",
238
+ )
239
+
240
+
225
241
  def read_fast_check(repo: Path) -> str | None:
226
242
  """Return the project's declared fast inner-loop command, or None.
227
243
 
@@ -472,6 +488,7 @@ def collect_actions(repo: Path, target: str) -> list[InstallAction]:
472
488
 
473
489
  actions.append(openspec_config_action())
474
490
  actions.append(keel_config_action())
491
+ actions.append(keel_gitignore_action())
475
492
  actions.extend(openspec_schema_actions())
476
493
  return actions
477
494
 
@@ -37,8 +37,8 @@ REQUIRED_SCRIPTS = [
37
37
  "scripts/validate_plugin.py",
38
38
  ]
39
39
 
40
- PACKAGE_VERSION = "5.2.3"
41
- PROTOCOL_VERSION = "5.2.3"
40
+ PACKAGE_VERSION = "5.2.4"
41
+ PROTOCOL_VERSION = "5.2.4"
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")
@@ -4227,6 +4227,410 @@ def validate_source_repo_bootstrap_skip_scenario() -> int:
4227
4227
  return 0
4228
4228
 
4229
4229
 
4230
+ TRACKER_OWNER = "https://github.com/TanglmChris/keel/issues/12"
4231
+
4232
+
4233
+ def tracker_owner_tasks(findings: str, closure: str) -> str:
4234
+ """One complete, checked task plus one Expectation Coverage closure line."""
4235
+ return (
4236
+ "# Tasks\n\n"
4237
+ "## Expectation Coverage\n\n"
4238
+ "- E1:\n"
4239
+ f" - {closure}\n\n"
4240
+ "## 1. Work\n\n"
4241
+ "- [x] 1.1 Complete behavior\n"
4242
+ " - Owner: keel-agent\n"
4243
+ " - Mode: implementation\n"
4244
+ " - Covers:\n"
4245
+ " - E1: public behavior\n"
4246
+ " - Read:\n"
4247
+ " - README.md\n"
4248
+ " - Touch:\n"
4249
+ " - src/feature.js\n"
4250
+ " - Commands:\n"
4251
+ " - M1: node test.js\n"
4252
+ " - Acceptance:\n"
4253
+ " - Public behavior passes.\n"
4254
+ " - Autonomy boundary:\n"
4255
+ " - Default: hard-stop\n"
4256
+ " - Pre-authorized fallback: none\n"
4257
+ " - Coupling: none\n"
4258
+ " - Candidate Boundary:\n"
4259
+ " - One candidate.\n"
4260
+ " - Stop Rules:\n"
4261
+ " - Stop on failure.\n"
4262
+ " - Evidence:\n"
4263
+ " - M1: passed\n"
4264
+ " - Review:\n"
4265
+ " - Status: pass\n"
4266
+ " - Acceptance check: reviewed\n"
4267
+ " - Scope check: reviewed\n"
4268
+ f" - Findings: {findings}\n"
4269
+ " - Blocker: none\n"
4270
+ " - Stop if:\n"
4271
+ " - Scope expands.\n"
4272
+ " - Report:\n"
4273
+ " - Summary\n"
4274
+ )
4275
+
4276
+
4277
+ def validate_tracker_durable_owner_scenario() -> int:
4278
+ """Issue #12: an issue tracker is a durable follow-up owner.
4279
+
4280
+ The gate enumerated three repository-local forms, so a project whose
4281
+ declared follow-up owner is an issue tracker had to write a local note per
4282
+ finding whose only job was to be a shape the gate recognized. The tracker
4283
+ form is now accepted in both places a durable owner is required, and every
4284
+ previously accepted form still passes.
4285
+ """
4286
+ with tempfile.TemporaryDirectory(prefix="keel-tracker-owner-") as raw:
4287
+ repo = Path(raw)
4288
+ tasks = repo / "openspec/changes/demo/tasks.md"
4289
+ write_text(repo / "openspec/changes/demo/proposal.md", "# Proposal\n")
4290
+ write_text(
4291
+ repo / "openspec/changes/demo/specs/demo/spec.md",
4292
+ "## ADDED Requirements\n",
4293
+ )
4294
+
4295
+ def complete(findings: str, closure: str = "Covered by: 1.1"):
4296
+ write_text(tasks, tracker_owner_tasks(findings, closure))
4297
+ return run_keel(
4298
+ repo, "gate", "task-complete",
4299
+ "--change", "demo", "--task", "1.1", "--json",
4300
+ )
4301
+
4302
+ def close(closure: str):
4303
+ write_text(tasks, tracker_owner_tasks("none", closure))
4304
+ return run_keel(
4305
+ repo, "gate", "change-close",
4306
+ "--change", "demo", "--action", "sync", "--json",
4307
+ )
4308
+
4309
+ tracker = complete(f"stale local note; owner {TRACKER_OWNER}")
4310
+ if tracker.returncode != 0:
4311
+ report(
4312
+ "tracker-durable-owner: a finding owned by an absolute tracker "
4313
+ "reference was still refused."
4314
+ )
4315
+ report((tracker.stderr or tracker.stdout).strip())
4316
+ return 1
4317
+
4318
+ unowned = complete("stale local note that names no owner at all")
4319
+ unowned_payload = json.loads(unowned.stdout) if unowned.stdout else {}
4320
+ message = " ".join(
4321
+ problem.get("message", "")
4322
+ for problem in unowned_payload.get("problems", [])
4323
+ if problem.get("code") == "finding-owner"
4324
+ )
4325
+ if unowned.returncode != 3 or "http" not in message:
4326
+ report(
4327
+ "tracker-durable-owner: an unowned finding must still fail, and "
4328
+ "the error must list the tracker form among the accepted ones."
4329
+ )
4330
+ report((unowned.stderr or unowned.stdout).strip())
4331
+ return 1
4332
+
4333
+ handoff = complete("stale local note; owner keel/HANDOFF.md")
4334
+ if handoff.returncode != 3:
4335
+ report(
4336
+ "tracker-durable-owner: keel/HANDOFF.md must still be refused "
4337
+ "as a finding owner."
4338
+ )
4339
+ report((handoff.stderr or handoff.stdout).strip())
4340
+ return 1
4341
+
4342
+ archived = complete("stale local note; owner keel/archive/follow-ups/x.md")
4343
+ if archived.returncode != 0:
4344
+ report(
4345
+ "tracker-durable-owner: the pre-existing keel/archive owner form "
4346
+ "regressed."
4347
+ )
4348
+ report((archived.stderr or archived.stdout).strip())
4349
+ return 1
4350
+
4351
+ closed_by_tracker = close(f"Durable owner: {TRACKER_OWNER}")
4352
+ if closed_by_tracker.returncode != 0:
4353
+ report(
4354
+ "tracker-durable-owner: change-close refused an Expectation "
4355
+ "Coverage durable owner naming a tracker reference."
4356
+ )
4357
+ report((closed_by_tracker.stderr or closed_by_tracker.stdout).strip())
4358
+ return 1
4359
+
4360
+ closed_by_task = close("Covered by: 1.1")
4361
+ if closed_by_task.returncode != 0:
4362
+ report(
4363
+ "tracker-durable-owner: the pre-existing Covered by closure "
4364
+ "regressed at change-close."
4365
+ )
4366
+ report((closed_by_task.stderr or closed_by_task.stdout).strip())
4367
+ return 1
4368
+
4369
+ closed_unowned = close("pending")
4370
+ if closed_unowned.returncode != 3:
4371
+ report(
4372
+ "tracker-durable-owner: change-close must still reject an E1 "
4373
+ "with no coverage, owner, or discard reason."
4374
+ )
4375
+ report((closed_unowned.stderr or closed_unowned.stdout).strip())
4376
+ return 1
4377
+
4378
+ if "tracker-durable-owner" not in {name for name, _ in SCENARIOS}:
4379
+ report("tracker-durable-owner: the scenario registry does not include it.")
4380
+ return 1
4381
+ report("tracker-durable-owner scenario passed.")
4382
+ return 0
4383
+
4384
+
4385
+ def validate_guard_manifest_ignored_scenario() -> int:
4386
+ """Issue #11: the guard manifest was written but declared ignorable nowhere.
4387
+
4388
+ Every gate run left an untracked `keel/guard.json` in the project, and
4389
+ because completion attributes working-tree paths against Touch, that is a
4390
+ permanent exception the author re-adjudicates at every completion.
4391
+ """
4392
+ with tempfile.TemporaryDirectory(prefix="keel-guard-ignore-") as raw:
4393
+ project = Path(raw)
4394
+ init = run_keel(project, "--install", "--target", "claude")
4395
+ if init.returncode != 0:
4396
+ report("guard-manifest-ignored: keel --install failed.")
4397
+ report((init.stderr or init.stdout).strip())
4398
+ return 1
4399
+ ignore_path = project / "keel/.gitignore"
4400
+ if not ignore_path.is_file():
4401
+ report(
4402
+ "guard-manifest-ignored: keel --install did not scaffold "
4403
+ "keel/.gitignore."
4404
+ )
4405
+ return 1
4406
+ declared = ignore_path.read_text(encoding="utf-8")
4407
+ if "guard.json" not in declared:
4408
+ report(
4409
+ "guard-manifest-ignored: the scaffolded keel/.gitignore does "
4410
+ "not declare the guard manifest."
4411
+ )
4412
+ report(declared)
4413
+ return 1
4414
+
4415
+ # git must actually honour the declaration: initialize a repository,
4416
+ # write a manifest through a passing task-start, and confirm the path
4417
+ # never appears in porcelain status.
4418
+ if subprocess.run(
4419
+ ["git", "init", "--quiet"], cwd=project, capture_output=True
4420
+ ).returncode != 0:
4421
+ report("guard-manifest-ignored: git init failed in the fixture.")
4422
+ return 1
4423
+ write_text(
4424
+ project / "openspec/changes/demo/tasks.md",
4425
+ tracker_owner_tasks("none", "Covered by: 1.1").replace(
4426
+ "- [x] 1.1", "- [ ] 1.1"
4427
+ ),
4428
+ )
4429
+ started = run_keel(
4430
+ project, "gate", "task-start",
4431
+ "--change", "demo", "--task", "1.1", "--json",
4432
+ )
4433
+ if started.returncode != 0 or not (project / "keel/guard.json").is_file():
4434
+ report(
4435
+ "guard-manifest-ignored: the fixture did not produce a guard "
4436
+ "manifest to test the declaration against."
4437
+ )
4438
+ report((started.stderr or started.stdout).strip())
4439
+ return 1
4440
+ status = subprocess.run(
4441
+ ["git", "status", "--short", "--untracked-files=all"],
4442
+ cwd=project, capture_output=True, encoding="utf-8",
4443
+ )
4444
+ if "guard.json" in (status.stdout or ""):
4445
+ report(
4446
+ "guard-manifest-ignored: git still reports the guard manifest "
4447
+ "after a gate run, so the declaration does not take effect."
4448
+ )
4449
+ report(status.stdout or "")
4450
+ return 1
4451
+
4452
+ # Scaffold once: a project's own file is never rewritten.
4453
+ own = "# mine\nguard.json\nscratch/\n"
4454
+ write_text(ignore_path, own)
4455
+ again = run_keel(project, "--install", "--target", "claude")
4456
+ if again.returncode != 0 or ignore_path.read_text(encoding="utf-8") != own:
4457
+ report(
4458
+ "guard-manifest-ignored: a second install overwrote the "
4459
+ "project's own keel/.gitignore."
4460
+ )
4461
+ report((again.stderr or again.stdout).strip())
4462
+ return 1
4463
+
4464
+ if not (ROOT / "keel/.gitignore").is_file():
4465
+ report(
4466
+ "guard-manifest-ignored: Keel's own repository does not declare "
4467
+ "the guard manifest ignorable."
4468
+ )
4469
+ return 1
4470
+ if "guard-manifest-ignored" not in {name for name, _ in SCENARIOS}:
4471
+ report("guard-manifest-ignored: the scenario registry does not include it.")
4472
+ return 1
4473
+ report("guard-manifest-ignored scenario passed.")
4474
+ return 0
4475
+
4476
+
4477
+ def validate_guard_status_is_not_enforcement_scenario() -> int:
4478
+ """Issue #14: `Guard: started` means the manifest was written.
4479
+
4480
+ Authors read it as "writes are being checked now". The two came apart when a
4481
+ session kept plugin state that predated a marketplace switch: the manifest
4482
+ was valid and keel's PreToolUse hook never ran. Keel cannot observe the hook
4483
+ from the repository, so the result must say so rather than imply otherwise.
4484
+ """
4485
+ needles = ("enforcement", "runtime hook", "cannot observe")
4486
+ # Assertive claims only: the honest sentence legitimately denies that a
4487
+ # written manifest proves anything, so a bare "writes are checked" substring
4488
+ # would match the denial itself.
4489
+ forbidden = (
4490
+ "enforcement is active",
4491
+ "enforcement is live",
4492
+ "writes are guarded",
4493
+ )
4494
+ with tempfile.TemporaryDirectory(prefix="keel-guard-honesty-") as raw:
4495
+ project = Path(raw)
4496
+ write_text(
4497
+ project / "openspec/changes/demo/tasks.md",
4498
+ tracker_owner_tasks("none", "Covered by: 1.1").replace(
4499
+ "- [x] 1.1", "- [ ] 1.1"
4500
+ ),
4501
+ )
4502
+ started = run_keel(
4503
+ project, "guard", "start",
4504
+ "--change", "demo", "--task", "1.1", "--json",
4505
+ )
4506
+ if started.returncode != 0:
4507
+ report("guard-status-is-not-enforcement: keel guard start failed.")
4508
+ report((started.stderr or started.stdout).strip())
4509
+ return 1
4510
+ for label, result in (
4511
+ ("start", started),
4512
+ (
4513
+ "status",
4514
+ run_keel(project, "guard", "status", "--json"),
4515
+ ),
4516
+ ):
4517
+ payload = json.loads(result.stdout) if result.stdout else {}
4518
+ warnings = " ".join(payload.get("warnings", []))
4519
+ missing = [needle for needle in needles if needle not in warnings]
4520
+ if missing:
4521
+ report(
4522
+ f"guard-status-is-not-enforcement: keel guard {label} does "
4523
+ "not state that the status describes the manifest and that "
4524
+ "enforcement depends on a runtime hook Keel cannot observe; "
4525
+ f"missing {missing}."
4526
+ )
4527
+ report(result.stdout or result.stderr or "")
4528
+ return 1
4529
+ if not payload.get("status"):
4530
+ report(
4531
+ f"guard-status-is-not-enforcement: keel guard {label} lost "
4532
+ "its status value."
4533
+ )
4534
+ return 1
4535
+ human = run_keel(
4536
+ project,
4537
+ "guard",
4538
+ *(("start", "--change", "demo", "--task", "1.1", "--force")
4539
+ if label == "start" else ("status",)),
4540
+ )
4541
+ if "runtime hook" not in (human.stdout or ""):
4542
+ report(
4543
+ f"guard-status-is-not-enforcement: the human-readable keel "
4544
+ f"guard {label} output omits the enforcement boundary."
4545
+ )
4546
+ report(human.stdout or human.stderr or "")
4547
+ return 1
4548
+ for phrase in forbidden:
4549
+ if phrase in warnings or phrase in (human.stdout or ""):
4550
+ report(
4551
+ f"guard-status-is-not-enforcement: keel guard {label} "
4552
+ f"asserts observed enforcement: {phrase!r}."
4553
+ )
4554
+ return 1
4555
+ # The pre-existing durability statement must survive alongside it.
4556
+ status_payload = json.loads(
4557
+ run_keel(project, "guard", "status", "--json").stdout
4558
+ )
4559
+ if not any(
4560
+ "durable authority" in warning
4561
+ for warning in status_payload.get("warnings", [])
4562
+ ):
4563
+ report(
4564
+ "guard-status-is-not-enforcement: the existing durable-authority "
4565
+ "statement was dropped."
4566
+ )
4567
+ return 1
4568
+ if "guard-status-is-not-enforcement" not in {name for name, _ in SCENARIOS}:
4569
+ report(
4570
+ "guard-status-is-not-enforcement: the scenario registry does not "
4571
+ "include it."
4572
+ )
4573
+ return 1
4574
+ report("guard-status-is-not-enforcement scenario passed.")
4575
+ return 0
4576
+
4577
+
4578
+ def validate_source_repo_cli_resolution_scenario() -> int:
4579
+ """Issue #13 item 3: a bare `keel` runs the installed package.
4580
+
4581
+ When the repository under change *is* Keel, gate commands verify the
4582
+ installed CLI rather than the working tree, and the failure mode is a
4583
+ silently stale result rather than an error. One dogfood round was lost to
4584
+ two spurious problems reported by a stale global CLI.
4585
+ """
4586
+ own = run_keel(ROOT, "--doctor")
4587
+ out = own.stdout or ""
4588
+ if "node bin/keel.js" not in out:
4589
+ report(
4590
+ "source-repo-cli-resolution: Keel's own repository is not told to "
4591
+ "run gate commands through its own entry point."
4592
+ )
4593
+ report(out)
4594
+ return 1
4595
+ # The local entry point needs an explicit repository argument; an author
4596
+ # who copies the hint without it gets an unrelated failure.
4597
+ hint = next(
4598
+ (line for line in out.splitlines() if "node bin/keel.js" in line), ""
4599
+ )
4600
+ # The line must be advisory: it describes a hazard, not a repository
4601
+ # failure, so it must never push doctor toward a non-ok verdict.
4602
+ for needle in ("gate", ".", "advisory"):
4603
+ if needle not in hint:
4604
+ report(
4605
+ "source-repo-cli-resolution: the hint does not name a usable "
4606
+ f"invocation, or is not advisory; missing {needle!r} in: {hint}"
4607
+ )
4608
+ return 1
4609
+ with tempfile.TemporaryDirectory(prefix="keel-cli-resolution-") as raw:
4610
+ consumer = Path(raw)
4611
+ init = run_keel(consumer, "--init", "--target", "claude")
4612
+ if init.returncode != 0:
4613
+ report("source-repo-cli-resolution: keel --init failed.")
4614
+ report((init.stderr or init.stdout).strip())
4615
+ return 1
4616
+ consumer_doctor = run_keel(consumer, "--doctor")
4617
+ if "node bin/keel.js" in (consumer_doctor.stdout or ""):
4618
+ report(
4619
+ "source-repo-cli-resolution: a consuming project was shown a "
4620
+ "hazard that exists only in Keel's own repository."
4621
+ )
4622
+ report(consumer_doctor.stdout or "")
4623
+ return 1
4624
+ if "source-repo-cli-resolution" not in {name for name, _ in SCENARIOS}:
4625
+ report(
4626
+ "source-repo-cli-resolution: the scenario registry does not "
4627
+ "include it."
4628
+ )
4629
+ return 1
4630
+ report("source-repo-cli-resolution scenario passed.")
4631
+ return 0
4632
+
4633
+
4230
4634
  def validate_task_capsule_scenario() -> int:
4231
4635
  with tempfile.TemporaryDirectory(prefix="keel-task-capsule-") as raw_tmp:
4232
4636
  repo = Path(raw_tmp)
@@ -10999,6 +11403,13 @@ SCENARIOS: tuple = (
10999
11403
  "source-repo-bootstrap-skip",
11000
11404
  validate_source_repo_bootstrap_skip_scenario,
11001
11405
  ),
11406
+ ("tracker-durable-owner", validate_tracker_durable_owner_scenario),
11407
+ ("guard-manifest-ignored", validate_guard_manifest_ignored_scenario),
11408
+ (
11409
+ "guard-status-is-not-enforcement",
11410
+ validate_guard_status_is_not_enforcement_scenario,
11411
+ ),
11412
+ ("source-repo-cli-resolution", validate_source_repo_cli_resolution_scenario),
11002
11413
  ("task-contract-core", validate_task_contract_core_scenario),
11003
11414
  ("task-capsule", validate_task_capsule_scenario),
11004
11415
  ("task-verification-strategies", validate_task_verification_strategies_scenario),
package/src/core/gates.js CHANGED
@@ -246,12 +246,21 @@ function reviewValue(task, label) {
246
246
  return match ? match[1].trim() : "";
247
247
  }
248
248
 
249
+ // The durable-owner forms that are pure shape checks, shared by the Review
250
+ // Findings check and the Expectation Coverage check so a form added to one is
251
+ // never missing from the other. Gates run without network and have never
252
+ // confirmed that an archive path resolves either, so an external tracker
253
+ // reference is no less checkable than what was already accepted; whether the
254
+ // owner is real stays a Review judgment.
255
+ const SHARED_DURABLE_OWNER_FORMS =
256
+ /(?:\bkeel\/archive\/[A-Za-z0-9._/-]+|\bhttps?:\/\/\S)/i;
257
+
249
258
  function findingOwnerIsDurable(repo, findings) {
250
259
  if (/keel\/HANDOFF\.md/i.test(findings)) return false;
251
260
  if (/\b(?:explicit\s+)?discard (?:reason|rationale)\s*:/i.test(findings)) {
252
261
  return true;
253
262
  }
254
- if (/\bkeel\/archive\/[A-Za-z0-9._/-]+/i.test(findings)) return true;
263
+ if (SHARED_DURABLE_OWNER_FORMS.test(findings)) return true;
255
264
  const owner = findings.match(
256
265
  /\b(openspec\/changes\/[A-Za-z0-9][A-Za-z0-9._-]*\/(?:proposal|design|tasks)\.md)(?:#\d+(?:\.\d+)*)?/i
257
266
  );
@@ -452,8 +461,8 @@ function completionChecks(repo, task, contract = null) {
452
461
  "finding-owner",
453
462
  "Review Findings must be `none` or carry a durable owner — a "
454
463
  + "`Discard reason:`/`Discard rationale:` prefix, a `keel/archive/…` "
455
- + "path, or an existing `openspec/changes/…` artifact; "
456
- + "`keel/HANDOFF.md` is not an owner."
464
+ + "path, an existing `openspec/changes/…` artifact, or an absolute "
465
+ + "`https://…` tracker reference; `keel/HANDOFF.md` is not an owner."
457
466
  )
458
467
  );
459
468
  }
@@ -520,7 +529,8 @@ function expectationProblems(content, tasks) {
520
529
  "expectation-coverage",
521
530
  "Expectation Coverage must declare each `E<n>` closure — "
522
531
  + "`- E<n>: <expectation> Covered by: <task ids>`, a `Durable owner:` "
523
- + "path, or a `Discard reason:` — or `- None.`."
532
+ + "path or `https://…` tracker reference, or a `Discard reason:` — "
533
+ + "or `- None.`."
524
534
  ),
525
535
  ];
526
536
  }
@@ -528,8 +538,12 @@ function expectationProblems(content, tasks) {
528
538
  for (const entry of entries) {
529
539
  const [, id, body] = entry;
530
540
  const covered = body.match(/Covered by:\s*([0-9.,\s-]+)/i);
531
- const hasDurableOwner =
532
- /Durable owner:\s*(?:openspec\/changes\/|keel\/archive\/)/i.test(body);
541
+ const declaredOwner = body.match(/Durable owner:\s*(\S[^\n]*)/i);
542
+ const hasDurableOwner = Boolean(
543
+ declaredOwner
544
+ && (/^openspec\/changes\//i.test(declaredOwner[1].trim())
545
+ || SHARED_DURABLE_OWNER_FORMS.test(declaredOwner[1]))
546
+ );
533
547
  const discarded = /Discard(?:ed)? (?:reason|rationale):\s*\S/i.test(body);
534
548
  if (!covered && !hasDurableOwner && !discarded) {
535
549
  problems.push(
package/src/core/guard.js CHANGED
@@ -35,6 +35,14 @@ function guardResult(subcommand, status, extra = {}) {
35
35
  "The guard manifest is a disposable enforcement pointer; OpenSpec and "
36
36
  + "Git remain the only durable authority and selection never derives "
37
37
  + "from it.",
38
+ // The status describes a file Keel wrote. Whether anything reads that
39
+ // file is a target-side fact: enforcement runs as a runtime hook the
40
+ // host loads, and a host that loaded different plugins keeps them for
41
+ // the life of its session. Reporting `started` as though it were a probe
42
+ // result is the same inference `--doctor` already refuses to make.
43
+ "This status describes the manifest only. Enforcement runs as a runtime "
44
+ + "hook in the host, which Keel cannot observe from the repository, so "
45
+ + "a written manifest is not evidence that any write was checked.",
38
46
  ],
39
47
  ...extra,
40
48
  };