@christang/keel 5.5.0 → 5.6.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.
package/README.md CHANGED
@@ -155,6 +155,43 @@ Three things the declaration is not:
155
155
  The block is absent by default, and a repository that declares nothing behaves exactly as it did
156
156
  before this feature existed. `keel --doctor` reports what is declared.
157
157
 
158
+ ### Decision precedents
159
+
160
+ A decision you make in conversation is spent at the next context reset, and the reasoning that
161
+ settled it — the part that would generalise to a decision you have not met yet — goes with it.
162
+ Point Keel at a directory of precedents and it consults them instead of asking you again:
163
+
164
+ ```yaml
165
+ precedents: ../my-decisions # any path; it may live outside this repository
166
+ ```
167
+
168
+ Keel ships no precedent and creates no store. It reads that directory and nothing else — it never
169
+ clones, pulls, or reaches the network — so one directory outside your repositories can serve all of
170
+ them, and a path that is not there behaves exactly as no declaration at all.
171
+
172
+ Each precedent is a markdown file carrying an `Applies when:` header, the materiality category it
173
+ belongs to, a status of `recorded` or `authorized`, the decision, and **the rationale**. The last is
174
+ load-bearing: *"chose A"* applies only to the situation literally recorded, while *"chose A because
175
+ B fails offline"* can be applied to a case nobody has seen — and recognised as not applying when the
176
+ new case is online. A precedent with no rationale is reported incomplete and is never applied.
177
+
178
+ Three rules govern how they are used:
179
+
180
+ - **A precedent is cited exactly where it replaced a question.** If applying it meant you were not
181
+ asked something you would have been asked, the reply names it. Routine decisions are not cited,
182
+ so a citation always marks a decision made in your place.
183
+ - **Only you promote one.** A precedent enters as `recorded` and is offered as a recommendation
184
+ while the question is still asked. It becomes `authorized` when you accept a promotion that was
185
+ proposed to you — never by a usage count, which would cross with nobody watching.
186
+ - **A precedent answers a recurrence; it never reclassifies.** It can shorten a decision inside its
187
+ category. It cannot move a decision out of the categories that require asking you, and no
188
+ accumulation of precedents makes a category stop mattering.
189
+
190
+ As with standing authorization, a precedent informs a decision and never substitutes for a proof:
191
+ gates, evidence, review, and the write guard are untouched by anything in the store. The session
192
+ start line reports the store's size and freshness only — precedent bodies load when a decision is
193
+ actually being made.
194
+
158
195
  ### Full vs Lite
159
196
 
160
197
  Use **Full mode** (the OpenSpec flow above) for new features, interface or protocol changes,
@@ -210,7 +247,9 @@ is repo-local and reversible.
210
247
 
211
248
  ## Domain lenses
212
249
 
213
- Keel's core is pure process; it ships no domain knowledge of its own. Domain guidance lives in
250
+ Keel's core is pure process; it ships no domain knowledge and no decisions of its own. Alongside
251
+ the precedent store above, the other user-authored surface Keel loads on demand is domain guidance,
252
+ which lives in
214
253
  **lenses** you author under `keel/lenses/*.md` in your repo. Each lens is self-describing: it
215
254
  opens with an `Applies when:` line stating the signals that trigger it (file extensions, artifact
216
255
  shapes) and carries an `Execution and review checks` section. When a change's artifacts or Touch
@@ -243,6 +282,7 @@ keel guard status --json
243
282
  keel guard clear --json
244
283
 
245
284
  # Domain lenses — user-authored guidance in keel/lenses/
285
+ # (the other user-authored surface is the precedent store; see above)
246
286
  keel lenses list
247
287
  keel lenses add <name> [--force]
248
288
 
@@ -1,4 +1,4 @@
1
- <!-- keel:start version=5.5.0 -->
1
+ <!-- keel:start version=5.6.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/bin/keel.js CHANGED
@@ -46,6 +46,7 @@ const {
46
46
  } = require("../src/core/guard");
47
47
  const {
48
48
  STANDING_AUTHORIZATION_ACTIONS,
49
+ readPrecedentStore,
49
50
  readStandingAuthorization,
50
51
  } = require("../src/core/config");
51
52
 
@@ -1353,6 +1354,7 @@ function runDoctor(options) {
1353
1354
  printTargetSurface(repo, options.target);
1354
1355
  printLensSurface(repo, options.target);
1355
1356
  const authorizationOk = printStandingAuthorizationSurface(repo);
1357
+ printPrecedentSurface(repo);
1356
1358
  printFastPrePushSurface(repo);
1357
1359
  printSourceRepoCliResolution(repo);
1358
1360
 
@@ -1430,6 +1432,42 @@ function printStandingAuthorizationSurface(repo) {
1430
1432
  return true;
1431
1433
  }
1432
1434
 
1435
+ function printPrecedentSurface(repo) {
1436
+ process.stdout.write("\nPrecedent store:\n");
1437
+ const store = readPrecedentStore(repo);
1438
+ if (store.precedents.length === 0) {
1439
+ printDoctorLine(
1440
+ "precedents",
1441
+ "none",
1442
+ store.declared
1443
+ ? `declared at ${store.declared}, which holds no precedents here; `
1444
+ + "an absent store behaves exactly as an undeclared one"
1445
+ : "undeclared; no precedent informs any decision"
1446
+ );
1447
+ return;
1448
+ }
1449
+ const authorized = store.precedents.filter(
1450
+ (item) => item.status === "authorized"
1451
+ ).length;
1452
+ printDoctorLine("precedents", String(store.precedents.length), store.declared);
1453
+ printDoctorLine(
1454
+ "authorized",
1455
+ String(authorized),
1456
+ `${store.precedents.length - authorized} recorded, offered as a `
1457
+ + "recommendation rather than applied"
1458
+ );
1459
+ const incomplete = store.precedents.filter((item) => !item.complete);
1460
+ printDoctorLine(
1461
+ "incomplete",
1462
+ String(incomplete.length),
1463
+ incomplete.length > 0
1464
+ ? `missing a Rationale, so not applicable to any decision: ${incomplete
1465
+ .map((item) => item.name)
1466
+ .join(", ")}`
1467
+ : "every precedent states why, which is the part that transfers"
1468
+ );
1469
+ }
1470
+
1433
1471
  function printFastPrePushSurface(repo) {
1434
1472
  process.stdout.write("\nFast pre-push surface:\n");
1435
1473
  const fastCheck = readFastCheck(repo);
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.5.0",
5
+ "version": "5.6.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.5.0",
3
+ "version": "5.6.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.5.0",
3
+ "version": "5.6.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",
@@ -53,6 +53,61 @@ function emit(context, humanMessage) {
53
53
  // at session start is the one who must not mistake a projection for authority.
54
54
  const DISPOSABLE = "Disposable projection; OpenSpec and Git are the authority.";
55
55
 
56
+ // A pointer, never a body. The store grows without bound while the precedents
57
+ // relevant to any one session are a small subset, and this hook pays its cost
58
+ // on every session including post-compaction reinjection. Counts and freshness
59
+ // tell the agent the store exists and how stale it is; the precedents
60
+ // themselves load when a decision is actually being made.
61
+ //
62
+ // The reader is inlined rather than required from src/core because this script
63
+ // ships inside the plugin and must run without the CLI package resolvable.
64
+ function precedentPointer(cwd) {
65
+ let declared = null;
66
+ try {
67
+ const configPath = path.join(cwd, "keel", "config.yaml");
68
+ if (!fs.existsSync(configPath)) return null;
69
+ for (const line of fs.readFileSync(configPath, "utf8").split(/\r?\n/)) {
70
+ const stripped = line.trim();
71
+ if (stripped.startsWith("#")) continue;
72
+ const match = stripped.match(/^precedents\s*:\s*(.+?)\s*$/);
73
+ if (match) {
74
+ declared = match[1];
75
+ break;
76
+ }
77
+ }
78
+ if (!declared) return null;
79
+ const resolved = path.isAbsolute(declared)
80
+ ? declared
81
+ : path.resolve(cwd, declared);
82
+ if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {
83
+ return `precedent store declared at ${declared} but absent here; `
84
+ + "no precedent informs any decision.";
85
+ }
86
+ const files = fs
87
+ .readdirSync(resolved)
88
+ .filter((name) => name.endsWith(".md") && name !== "README.md");
89
+ let authorized = 0;
90
+ let newest = 0;
91
+ for (const name of files) {
92
+ const full = path.join(resolved, name);
93
+ if (/^-\s*Status:\s*authorized/mi.test(fs.readFileSync(full, "utf8"))) {
94
+ authorized += 1;
95
+ }
96
+ const mtime = fs.statSync(full).mtimeMs;
97
+ if (mtime > newest) newest = mtime;
98
+ }
99
+ const synced = newest
100
+ ? new Date(newest).toISOString().slice(0, 10)
101
+ : "never";
102
+ return `precedents: ${files.length} (${authorized} authorized, `
103
+ + `last synced ${synced}); bodies load at the decision, not here.`;
104
+ } catch {
105
+ // The projection never blocks a session. An unreadable store is the same
106
+ // as no store for this hook's purposes.
107
+ return null;
108
+ }
109
+ }
110
+
56
111
  // The Keel mark. A keel is the carina, the ridge on a bird's sternum, so the
57
112
  // animal that literally has one is a bird. Every cell is drawn from
58
113
  // U+2580–U+259F — the same block-element family as the host's own startup
@@ -238,6 +293,8 @@ function main() {
238
293
  + "does not guess among candidates."
239
294
  );
240
295
  }
296
+ const pointer = precedentPointer(cwd);
297
+ if (pointer) lines.push(`- ${pointer}`);
241
298
  lines.push(`- report this state ${DISCLOSURE}; it authorizes nothing.`);
242
299
  emit(lines.join("\n"), panel(human));
243
300
  return 0;
@@ -40,6 +40,37 @@ Accepted alignment routes to existing OpenSpec owners; create no separate alignm
40
40
  - specs own observable requirements and positive/negative/edge/failure scenarios.
41
41
  - tasks.md owns Covers, verification strategy and checks, scope, and stop boundaries that reference the accepted authority instead of duplicating chat prose.
42
42
 
43
+ ## Decision precedents
44
+
45
+ When the repository declares a precedent store, consult the precedent matching a decision before
46
+ escalating it, and record a new precedent when the user decides something the store does not cover.
47
+ A precedent store is user-authored and never bundled; a repository that declares none behaves
48
+ exactly as one without this section.
49
+
50
+ Record the reasoning, not only the conclusion. "Chose A" applies only to the situation literally
51
+ recorded; "chose A because B fails offline" can be applied to a case nobody has seen yet, and — just
52
+ as important — recognised as *not* applying when the new case is online. Only the reasoning transfers.
53
+ A precedent with no rationale is incomplete and is not applied.
54
+
55
+ Three rules govern use:
56
+
57
+ - **Cite only where you replaced a question.** Name the precedent you applied exactly when, without
58
+ it, you would otherwise have interrupted the user. Decisions that would not have interrupted them
59
+ are not cited, so that a citation always marks a decision made in the user's place rather than
60
+ running commentary.
61
+ - **Promotion is the user's act.** A precedent enters as `recorded` and is offered as a
62
+ recommendation while the question is still asked. To make one applicable without asking, propose
63
+ the promotion and name the precedent; it changes only when the user accepts. There is no usage
64
+ count, age, or other threshold that promotes anything, because a threshold crosses with nobody
65
+ watching.
66
+ - **A precedent answers a recurrence; it never reclassifies.** It may shorten a decision inside its
67
+ materiality category by supplying the recorded answer and its reasoning. It never moves a decision
68
+ out of the categories that require asking, and no accumulation of precedents makes a category
69
+ immaterial. A decision that resembles a precedent but sits in a different category is not a match.
70
+
71
+ A precedent informs a decision and never substitutes for a proof: gates, evidence, Review, and the
72
+ write guard are untouched by anything in the store.
73
+
43
74
  ## Domain lenses
44
75
 
45
76
  When the change signals a specific domain, look in `keel/lenses/` for a lens whose `Applies when:` header matches, and read only that lens before asking domain questions; do not load unrelated lenses. When no lens matches, or the repo defines none, proceed on the domain-agnostic path. Lenses are user-authored; scaffold the bundled starting points with `keel lenses add` (web, hardware, hardware-dsl).
@@ -37,8 +37,8 @@ REQUIRED_SCRIPTS = [
37
37
  "scripts/validate_plugin.py",
38
38
  ]
39
39
 
40
- PACKAGE_VERSION = "5.5.0"
41
- PROTOCOL_VERSION = "5.5.0"
40
+ PACKAGE_VERSION = "5.6.0"
41
+ PROTOCOL_VERSION = "5.6.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
@@ -11363,6 +11363,430 @@ def validate_standing_authorization_inheritance_scenario() -> int:
11363
11363
  return 0
11364
11364
 
11365
11365
 
11366
+ def write_precedent(
11367
+ store: Path,
11368
+ name: str,
11369
+ *,
11370
+ category: str = "external interface",
11371
+ status: str = "recorded",
11372
+ decision: str = "Return 404 rather than 200 with an empty body.",
11373
+ rationale: str | None = "A 200 teaches every caller to parse the body to learn it failed.",
11374
+ ) -> None:
11375
+ store.mkdir(parents=True, exist_ok=True)
11376
+ body = (
11377
+ f"# {name}\n\n"
11378
+ f"Applies when: a handler must report that a resource is absent.\n\n"
11379
+ f"- Category: {category}\n"
11380
+ f"- Status: {status}\n\n"
11381
+ "## Decision\n\n"
11382
+ f"{decision}\n"
11383
+ )
11384
+ if rationale is not None:
11385
+ body += f"\n## Rationale\n\n{rationale}\n"
11386
+ (store / f"{name}.md").write_text(body, encoding="utf-8")
11387
+
11388
+
11389
+ def validate_precedent_store_declaration_scenario() -> int:
11390
+ with tempfile.TemporaryDirectory(prefix="keel-precedent-") as raw_tmp:
11391
+ root = Path(raw_tmp)
11392
+
11393
+ # A store deliberately placed OUTSIDE every repository that reads it.
11394
+ shared = root / "shared-store"
11395
+ write_precedent(shared, "absent-resource-status")
11396
+ write_precedent(shared, "irreversible-cost", status="authorized")
11397
+
11398
+ def declare(repo: Path, store: str | None) -> None:
11399
+ (repo / "keel").mkdir(parents=True, exist_ok=True)
11400
+ body = "fast_check: echo check\n"
11401
+ if store is not None:
11402
+ body += f"precedents: {store}\n"
11403
+ (repo / "keel" / "config.yaml").write_text(body, encoding="utf-8")
11404
+
11405
+ # M1 — a declared, existing store is reported with its counts.
11406
+ declared = root / "declared"
11407
+ declared.mkdir()
11408
+ declare(declared, str(shared).replace("\\", "/"))
11409
+ out = run_keel(declared, "--doctor").stdout
11410
+ if "Precedent store:" not in out:
11411
+ report("precedent-store: doctor has no precedent surface.")
11412
+ report(out)
11413
+ return 1
11414
+ for needle in ("precedents: 2", "authorized: 1"):
11415
+ if needle not in out:
11416
+ report(f"precedent-store: doctor does not report {needle}.")
11417
+ report(out)
11418
+ return 1
11419
+
11420
+ # M1 (continued) — an undeclared store leaves every surface alone.
11421
+ silent = root / "silent"
11422
+ silent.mkdir()
11423
+ declare(silent, None)
11424
+ silent_out = run_keel(silent, "--doctor").stdout
11425
+ if "precedents: none" not in silent_out:
11426
+ report("precedent-store: an undeclared store is not reported as none.")
11427
+ report(silent_out)
11428
+ return 1
11429
+ if "fast_check: ok - declared in keel/config.yaml: echo check" not in silent_out:
11430
+ report("precedent-store: the fast_check surface changed.")
11431
+ report(silent_out)
11432
+ return 1
11433
+
11434
+ # M2 — two repositories declaring the same out-of-tree path read the
11435
+ # same precedents, which is the whole point of a declarable path.
11436
+ second = root / "second"
11437
+ second.mkdir()
11438
+ declare(second, str(shared).replace("\\", "/"))
11439
+ second_out = run_keel(second, "--doctor").stdout
11440
+ if "precedents: 2" not in second_out or "authorized: 1" not in second_out:
11441
+ report("precedent-store: a second repo did not read the shared store.")
11442
+ report(second_out)
11443
+ return 1
11444
+
11445
+ # M2 (continued) — a declared path that does not exist degrades to the
11446
+ # no-store behavior. This is the state CI and every clone land in.
11447
+ missing = root / "missing"
11448
+ missing.mkdir()
11449
+ declare(missing, str(root / "not-here").replace("\\", "/"))
11450
+ missing_result = run_keel(missing, "--doctor")
11451
+ if "precedents: none" not in missing_result.stdout:
11452
+ report("precedent-store: a missing store path did not degrade to none.")
11453
+ report(missing_result.stdout)
11454
+ return 1
11455
+ if missing_result.returncode != run_keel(silent, "--doctor").returncode:
11456
+ report("precedent-store: a missing store path changed the doctor exit code.")
11457
+ return 1
11458
+
11459
+ # M3 — completeness is a presence check, not a judgement.
11460
+ incomplete_store = root / "incomplete-store"
11461
+ write_precedent(incomplete_store, "no-reason", rationale=None)
11462
+ incomplete = root / "incomplete"
11463
+ incomplete.mkdir()
11464
+ declare(incomplete, str(incomplete_store).replace("\\", "/"))
11465
+ out = run_keel(incomplete, "--doctor").stdout
11466
+ if "incomplete: 1" not in out or "no-reason" not in out:
11467
+ report("precedent-store: a precedent with no rationale was not named incomplete.")
11468
+ report(out)
11469
+ return 1
11470
+
11471
+ opaque_store = root / "opaque-store"
11472
+ write_precedent(opaque_store, "unevaluable", rationale="qqq")
11473
+ opaque = root / "opaque"
11474
+ opaque.mkdir()
11475
+ declare(opaque, str(opaque_store).replace("\\", "/"))
11476
+ out = run_keel(opaque, "--doctor").stdout
11477
+ if "incomplete: 0" not in out:
11478
+ report(
11479
+ "precedent-store: a rationale Keel cannot evaluate was reported "
11480
+ "incomplete; the check must be presence, not judgement."
11481
+ )
11482
+ report(out)
11483
+ return 1
11484
+
11485
+ # M4 — reading a store performs no network access.
11486
+ #
11487
+ # Proxy environment variables do NOT prove this: Node's fetch ignores
11488
+ # HTTP_PROXY entirely, so a run under them passes whether or not the
11489
+ # code reaches the network. Instead, preload a module that makes every
11490
+ # network primitive throw. Then a passing run is evidence that none was
11491
+ # called, and any added network call fails loudly.
11492
+ guard = root / "no-network.cjs"
11493
+ guard.write_text(
11494
+ "const fail = (what) => {\n"
11495
+ " throw new Error('network attempted: ' + what);\n"
11496
+ "};\n"
11497
+ "require('net').Socket.prototype.connect = () => fail('net.connect');\n"
11498
+ "const http = require('http');\n"
11499
+ "http.request = () => fail('http.request');\n"
11500
+ "http.get = () => fail('http.get');\n"
11501
+ "const https = require('https');\n"
11502
+ "https.request = () => fail('https.request');\n"
11503
+ "https.get = () => fail('https.get');\n"
11504
+ "const dns = require('dns');\n"
11505
+ "dns.lookup = () => fail('dns.lookup');\n"
11506
+ "dns.resolve = () => fail('dns.resolve');\n"
11507
+ "globalThis.fetch = () => fail('fetch');\n",
11508
+ encoding="utf-8",
11509
+ )
11510
+ env = dict(os.environ)
11511
+ env["NODE_OPTIONS"] = f"--require {str(guard).replace(chr(92), '/')}"
11512
+ offline = run_keel(declared, "--doctor", env=env)
11513
+ if "precedents: 2" not in offline.stdout:
11514
+ report(
11515
+ "precedent-store: reading the store attempted network access, "
11516
+ "or failed under the no-network guard."
11517
+ )
11518
+ report((offline.stderr or offline.stdout).strip())
11519
+ return 1
11520
+
11521
+ report("precedent-store-declaration scenario passed.")
11522
+ return 0
11523
+
11524
+
11525
+ def validate_precedent_rules_scenario() -> int:
11526
+ """The three rules the owner accepted must be in the skill, not in a chat.
11527
+
11528
+ Each is asserted by the phrase that carries its distinguishing content, not
11529
+ by a keyword: "precedent" appearing somewhere would satisfy a keyword check
11530
+ while saying none of what was decided.
11531
+ """
11532
+
11533
+ required = [
11534
+ # Citation: the trigger, and its negative half.
11535
+ "would otherwise have interrupted",
11536
+ "not cited",
11537
+ # Promotion: who does it, and what does not.
11538
+ "propose the promotion",
11539
+ "no usage count",
11540
+ # No reclassification, and the reason it is a fixed point.
11541
+ "never moves a decision out of",
11542
+ "recurrence",
11543
+ # Recording: the rationale is the load-bearing field.
11544
+ "reasoning transfers",
11545
+ ]
11546
+ canonical = ROOT / "src/skills/keel-align-expectations/SKILL.md"
11547
+ distributed = ROOT / PLUGIN_ROOT / "skills/keel-align-expectations/SKILL.md"
11548
+
11549
+ for label, path in (("canonical", canonical), ("distributed", distributed)):
11550
+ if not path.is_file():
11551
+ report(f"precedent-rules: missing {label} skill: {path}")
11552
+ return 1
11553
+ # Collapse whitespace before matching. These are multi-word phrases and
11554
+ # the file is hard-wrapped, so matching raw text would assert the line
11555
+ # layout rather than the wording — and would fail on any later reflow
11556
+ # that changed nothing.
11557
+ content = re.sub(r"\s+", " ", path.read_text(encoding="utf-8"))
11558
+ for phrase in required:
11559
+ if phrase not in content:
11560
+ report(f"precedent-rules: {label} skill omits: {phrase}")
11561
+ return 1
11562
+
11563
+ if canonical.read_bytes() != distributed.read_bytes():
11564
+ report("precedent-rules: the canonical and distributed skills diverged.")
11565
+ return 1
11566
+
11567
+ report("precedent-rules scenario passed.")
11568
+ return 0
11569
+
11570
+
11571
+ def validate_precedent_projection_pointer_scenario() -> int:
11572
+ """SessionStart may say how big the store is. It may not say what is in it.
11573
+
11574
+ The store grows monotonically while the precedents relevant to any one
11575
+ session are a small subset, and the hook pays its cost on every session
11576
+ including post-compaction reinjection. So the projection carries counts and
11577
+ freshness; bodies load when a decision is actually being made.
11578
+ """
11579
+
11580
+ def projection(repo: Path) -> tuple[str, str]:
11581
+ result = run_session_start_hook(
11582
+ repo,
11583
+ {"hook_event_name": "SessionStart", "source": "startup"},
11584
+ keel_cli=f'node "{ROOT / "bin/keel.js"}"',
11585
+ )
11586
+ payload = json.loads(result.stdout.strip().splitlines()[-1])
11587
+ return (
11588
+ payload["hookSpecificOutput"]["additionalContext"],
11589
+ payload.get("systemMessage", ""),
11590
+ )
11591
+
11592
+ with tempfile.TemporaryDirectory(prefix="keel-precproj-") as raw_tmp:
11593
+ root = Path(raw_tmp)
11594
+ store = root / "store"
11595
+ # Text that must never reach the projection. If any of it appears, a
11596
+ # body leaked where only a pointer belongs.
11597
+ write_precedent(
11598
+ store,
11599
+ "leak-canary",
11600
+ status="authorized",
11601
+ decision="NEVERAPPEARSINPROJECTION-decision",
11602
+ rationale="NEVERAPPEARSINPROJECTION-rationale",
11603
+ )
11604
+ write_precedent(store, "second")
11605
+
11606
+ # The hook is silent outside a Keel repository, so both fixtures need
11607
+ # an openspec tree before the projection exists at all.
11608
+ declaring = root / "declaring"
11609
+ declaring.mkdir()
11610
+ write_text(declaring / "openspec/changes/demo/tasks.md", task_contract_fixture())
11611
+ (declaring / "keel").mkdir(parents=True)
11612
+ (declaring / "keel" / "config.yaml").write_text(
11613
+ f"precedents: {str(store).replace(chr(92), '/')}\n", encoding="utf-8"
11614
+ )
11615
+ # Two ways to declare nothing, and they reach different branches: no
11616
+ # config file at all, and a config file that declares other things.
11617
+ silent = root / "silent"
11618
+ silent.mkdir()
11619
+ write_text(silent / "openspec/changes/demo/tasks.md", task_contract_fixture())
11620
+ other_keys = root / "other-keys"
11621
+ other_keys.mkdir()
11622
+ write_text(
11623
+ other_keys / "openspec/changes/demo/tasks.md", task_contract_fixture()
11624
+ )
11625
+ (other_keys / "keel").mkdir(parents=True)
11626
+ (other_keys / "keel" / "config.yaml").write_text(
11627
+ "fast_check: echo check\nauthorize:\n - commit\n", encoding="utf-8"
11628
+ )
11629
+
11630
+ # M1 — counts and freshness, never a body.
11631
+ context, message = projection(declaring)
11632
+ combined = f"{context}\n{message}"
11633
+ if "precedents: 2" not in combined or "1 authorized" not in combined:
11634
+ report(
11635
+ "precedent-projection: the projection does not state the "
11636
+ f"precedent counts: {combined!r}"
11637
+ )
11638
+ return 1
11639
+ if "last synced" not in combined:
11640
+ report("precedent-projection: the projection does not state store freshness.")
11641
+ report(combined)
11642
+ return 1
11643
+ if "NEVERAPPEARSINPROJECTION" in combined:
11644
+ report(
11645
+ "precedent-projection: a precedent body reached the projection; "
11646
+ "only a pointer belongs there."
11647
+ )
11648
+ report(combined)
11649
+ return 1
11650
+
11651
+ # M2 — an undeclared store adds nothing at all, by either route.
11652
+ for repo, label in ((silent, "no config file"), (other_keys, "other keys only")):
11653
+ quiet_context, quiet_message = projection(repo)
11654
+ if "precedent" in f"{quiet_context}\n{quiet_message}".lower():
11655
+ report(
11656
+ f"precedent-projection: with {label}, an undeclared store "
11657
+ "still added text to the projection."
11658
+ )
11659
+ report(quiet_context)
11660
+ return 1
11661
+
11662
+ report("precedent-projection-pointer scenario passed.")
11663
+ return 0
11664
+
11665
+
11666
+ def validate_precedent_never_weakens_scenario() -> int:
11667
+ """A precedent informs a decision. It must not stand in for a proof.
11668
+
11669
+ Same shape as the standing-authorization inertness scenario, and for the
11670
+ same reason: every check passes when two repositories agree, so a store
11671
+ that silently failed to load would make each comparison trivially true.
11672
+ The positive control asserts the difference exists before asserting it is
11673
+ inert.
11674
+ """
11675
+
11676
+ complete_task = (
11677
+ "- [ ] 1.1 Behavior\n"
11678
+ " - Covers:\n"
11679
+ " - E1: public behavior\n"
11680
+ " - Touch:\n"
11681
+ " - src/feature.js\n"
11682
+ " - Verify:\n"
11683
+ " - Strategy: evidence-first\n"
11684
+ " - M1: node test.js proves the public behavior\n"
11685
+ " - Evidence:\n"
11686
+ " - Contract: pending\n"
11687
+ " - M1: node test.js printed ok\n"
11688
+ " - Review:\n"
11689
+ " - Status: pass\n"
11690
+ " - Acceptance check: reviewed\n"
11691
+ " - Scope check: reviewed\n"
11692
+ " - Findings: none\n"
11693
+ " - Blocker: none\n"
11694
+ )
11695
+ missing_evidence_task = complete_task.replace(
11696
+ " - M1: node test.js printed ok\n", " - M1: pending\n"
11697
+ )
11698
+
11699
+ def gate_result(repo: Path, stage: str) -> dict | None:
11700
+ result = run_keel(
11701
+ repo, "gate", stage, "--change", "demo", "--task", "1.1", "--json"
11702
+ )
11703
+ try:
11704
+ payload = json.loads(result.stdout)
11705
+ except json.JSONDecodeError:
11706
+ return None
11707
+ return {
11708
+ "status": payload.get("status"),
11709
+ "problems": sorted(
11710
+ (problem.get("code", ""), problem.get("message", ""))
11711
+ for problem in payload.get("problems") or []
11712
+ ),
11713
+ }
11714
+
11715
+ with tempfile.TemporaryDirectory(prefix="keel-precinert-") as raw_tmp:
11716
+ root = Path(raw_tmp)
11717
+ store = root / "store"
11718
+ for name in ("first", "second", "third"):
11719
+ write_precedent(store, name, status="authorized")
11720
+
11721
+ def pair(name: str, tasks: str) -> tuple[Path, Path]:
11722
+ declaring = root / f"{name}-declaring"
11723
+ declaring.mkdir()
11724
+ write_gate_fixture(declaring, tasks)
11725
+ (declaring / "keel").mkdir(parents=True, exist_ok=True)
11726
+ (declaring / "keel" / "config.yaml").write_text(
11727
+ f"precedents: {str(store).replace(chr(92), '/')}\n", encoding="utf-8"
11728
+ )
11729
+ silent = root / f"{name}-silent"
11730
+ silent.mkdir()
11731
+ write_gate_fixture(silent, tasks)
11732
+ # Positive control: prove the two repositories actually differ
11733
+ # before proving the difference changes nothing.
11734
+ live = run_keel(declaring, "--doctor").stdout
11735
+ inert = run_keel(silent, "--doctor").stdout
11736
+ if "precedents: 3" not in live or "authorized: 3" not in live:
11737
+ report(
11738
+ f"precedent-inert: the {name} declaring fixture never loaded "
11739
+ "its store; every comparison below would be vacuous."
11740
+ )
11741
+ raise AssertionError("declaring fixture is not declaring")
11742
+ if "precedents: none" not in inert:
11743
+ report(f"precedent-inert: the {name} silent fixture declared a store.")
11744
+ raise AssertionError("silent fixture is not silent")
11745
+ return declaring, silent
11746
+
11747
+ # M1 — every gate stage agrees across the pair.
11748
+ declaring, silent = pair("complete", complete_task)
11749
+ for stage in ("task-start", "task-complete"):
11750
+ live = gate_result(declaring, stage)
11751
+ inert = gate_result(silent, stage)
11752
+ if live is None or inert is None:
11753
+ report(f"precedent-inert: {stage} produced no JSON.")
11754
+ return 1
11755
+ if live != inert:
11756
+ report(
11757
+ f"precedent-inert: a declared store changed the {stage} "
11758
+ f"result: {live} != {inert}"
11759
+ )
11760
+ return 1
11761
+
11762
+ # M2 — missing evidence still fails, with unchanged failure text.
11763
+ declaring, silent = pair("missing", missing_evidence_task)
11764
+ for repo in (declaring, silent):
11765
+ if gate_result(repo, "task-start") is None:
11766
+ report("precedent-inert: task-start produced no JSON.")
11767
+ return 1
11768
+ live = gate_result(declaring, "task-complete")
11769
+ inert = gate_result(silent, "task-complete")
11770
+ if live is None or inert is None:
11771
+ report("precedent-inert: task-complete produced no JSON.")
11772
+ return 1
11773
+ if live.get("status") == "pass":
11774
+ report(
11775
+ "precedent-inert: a store of authorized precedents let a task "
11776
+ "with missing evidence pass completion."
11777
+ )
11778
+ return 1
11779
+ if live != inert:
11780
+ report(
11781
+ "precedent-inert: a declared store changed the failure text: "
11782
+ f"{live} != {inert}"
11783
+ )
11784
+ return 1
11785
+
11786
+ report("precedent-never-weakens scenario passed.")
11787
+ return 0
11788
+
11789
+
11366
11790
  def validate_standing_authorization_never_weakens_scenario() -> int:
11367
11791
  """A declaration removes a confirmation. It must not remove a proof.
11368
11792
 
@@ -14910,6 +15334,16 @@ SCENARIOS: tuple = (
14910
15334
  "standing-authorization-never-weakens",
14911
15335
  validate_standing_authorization_never_weakens_scenario,
14912
15336
  ),
15337
+ (
15338
+ "precedent-store-declaration",
15339
+ validate_precedent_store_declaration_scenario,
15340
+ ),
15341
+ ("precedent-never-weakens", validate_precedent_never_weakens_scenario),
15342
+ ("precedent-rules", validate_precedent_rules_scenario),
15343
+ (
15344
+ "precedent-projection-pointer",
15345
+ validate_precedent_projection_pointer_scenario,
15346
+ ),
14913
15347
  ("fast-check-config-scaffold", validate_fast_check_config_scaffold_scenario),
14914
15348
  ("fast-pre-push-hooks", validate_fast_pre_push_hooks_scenario),
14915
15349
  ("fast-pre-push-doctor", validate_fast_pre_push_doctor_scenario),
@@ -42,8 +42,64 @@ function readStandingAuthorization(repo) {
42
42
  return { declared, unknown };
43
43
  }
44
44
 
45
+ function configScalar(repo, key) {
46
+ const configPath = path.join(repo, "keel", "config.yaml");
47
+ if (!fs.existsSync(configPath)) return null;
48
+ const pattern = new RegExp(`^${key}\\s*:\\s*(.+?)\\s*$`);
49
+ for (const line of fs.readFileSync(configPath, "utf8").split(/\r?\n/)) {
50
+ const stripped = line.trim();
51
+ if (stripped.startsWith("#")) continue;
52
+ const match = stripped.match(pattern);
53
+ if (match) return match[1];
54
+ }
55
+ return null;
56
+ }
57
+
58
+ // Keel reads a local directory and nothing else. How that directory came to
59
+ // exist — a clone, an installed plugin, hand-authored files — is outside Keel,
60
+ // because a surface that reaches the network trades the local, offline,
61
+ // deterministic properties that make it trustworthy.
62
+ function readPrecedentStore(repo) {
63
+ const declared = configScalar(repo, "precedents");
64
+ if (!declared) return { declared: null, path: null, precedents: [] };
65
+ const resolved = path.isAbsolute(declared)
66
+ ? declared
67
+ : path.resolve(repo, declared);
68
+ // A declared path that is not there degrades to the no-store behavior rather
69
+ // than to an error: a private store is exactly what a fresh clone and CI will
70
+ // not have, and a repository declaring one must still be usable by them.
71
+ if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {
72
+ return { declared, path: resolved, precedents: [] };
73
+ }
74
+ const precedents = fs
75
+ .readdirSync(resolved)
76
+ .filter((name) => name.endsWith(".md") && name !== "README.md")
77
+ .sort()
78
+ .map((name) => {
79
+ const content = fs.readFileSync(path.join(resolved, name), "utf8");
80
+ const status = (content.match(/^-\s*Status:\s*(\S+)/mi) || [])[1] || "";
81
+ // Presence, never judgement. Keel cannot tell a good reason from a bad
82
+ // one and must not imply it can; it can tell a reason from no reason,
83
+ // and a conclusion with no reason cannot be carried to a situation that
84
+ // is not literally the recorded one.
85
+ const rationale = content
86
+ .split(/^##\s+/m)
87
+ .find((section) => /^Rationale\s*$/i.test(section.split(/\r?\n/)[0]));
88
+ return {
89
+ name: name.replace(/\.md$/, ""),
90
+ status: status.toLowerCase(),
91
+ complete: Boolean(
92
+ rationale
93
+ && rationale.split(/\r?\n/).slice(1).join("\n").trim()
94
+ ),
95
+ };
96
+ });
97
+ return { declared, path: resolved, precedents };
98
+ }
99
+
45
100
  module.exports = {
46
101
  CONFIG_RELATIVE_PATH,
47
102
  STANDING_AUTHORIZATION_ACTIONS,
103
+ readPrecedentStore,
48
104
  readStandingAuthorization,
49
105
  };