@christang/keel 5.53.0 → 5.54.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
@@ -333,6 +333,29 @@ keel lenses add web # copy the web template into keel/lenses/web.md, the
333
333
  keel lenses add web --force # overwrite an existing lens
334
334
  ```
335
335
 
336
+ ## Pausing a change
337
+
338
+ A change you have deliberately stopped — waiting on something outside the repository, or simply
339
+ not the priority — can say so where it lives, in its own `openspec/changes/<name>/.openspec.yaml`:
340
+
341
+ ```yaml
342
+ schema: keel-spec-driven
343
+ created: 2026-09-05
344
+ keel:
345
+ status: paused
346
+ reason: waiting on the competition brief; the priority is knowledge that needs no tooling
347
+ since: 2026-09-05
348
+ ```
349
+
350
+ `keel context` then passes over it when inferring what to do next, and says which change it passed
351
+ over and why — a skip you cannot see would be worse than the wrong recommendation it replaces. If
352
+ every active change is paused, `context` reports that, with each reason, rather than reporting that
353
+ nothing exists.
354
+
355
+ This changes only what Keel *recommends*. No gate, the write guard, and completion all behave
356
+ exactly as they would without it, and `keel context --change <paused>` still selects it — you asked
357
+ for it by name. Keel never pauses a change on its own.
358
+
336
359
  ## Commands
337
360
 
338
361
  ```bash
@@ -1,4 +1,4 @@
1
- <!-- keel:start version=5.53.0 -->
1
+ <!-- keel:start version=5.54.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.53.0",
5
+ "version": "5.54.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.53.0",
3
+ "version": "5.54.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.53.0",
3
+ "version": "5.54.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.53.0"
41
- PROTOCOL_VERSION = "5.53.0"
40
+ PACKAGE_VERSION = "5.54.0"
41
+ PROTOCOL_VERSION = "5.54.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
@@ -25812,6 +25812,162 @@ def validate_explanation_is_printed_once_scenario() -> int:
25812
25812
  return 0
25813
25813
 
25814
25814
 
25815
+ # Issue #112's most valuable finding: a change its owner had stopped kept being
25816
+ # recommended, and the workaround was a paragraph in the project's CLAUDE.md
25817
+ # telling future sessions to ignore keel's own primary output.
25818
+ def validate_paused_change_is_not_the_next_action_scenario() -> int:
25819
+ label = "a-paused-change-is-not-the-next-action"
25820
+
25821
+ def write_change(repo: Path, name: str, keel_block: str | None) -> None:
25822
+ task = strategy_probe_task(strategy="vertical-tdd")
25823
+ change = repo / "openspec" / "changes" / name
25824
+ write_text(
25825
+ change / "tasks.md",
25826
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n## Expectation Coverage\n\n"
25827
+ "- E1:\n - Covered by: 1.1\n\n## Tasks\n\n" + task,
25828
+ )
25829
+ write_text(change / "proposal.md", "# Proposal\n")
25830
+ write_text(change / "design.md", "## Context\n\nfixture\n")
25831
+ write_text(change / "specs/demo/spec.md", "## ADDED Requirements\n")
25832
+ write_text(
25833
+ change / ".openspec.yaml",
25834
+ "schema: keel-spec-driven\ncreated: 2026-09-08\n"
25835
+ + (keel_block or ""),
25836
+ )
25837
+
25838
+ paused_block = (
25839
+ "keel:\n"
25840
+ " status: paused\n"
25841
+ " reason: 打分寻优要等赛题,当前优先级是不依赖工具的知识沉淀\n"
25842
+ " since: 2026-09-05\n"
25843
+ )
25844
+
25845
+ def context(repo: Path, *args):
25846
+ result = run_keel(repo, "context", "--json", *args)
25847
+ try:
25848
+ return json.loads(result.stdout)
25849
+ except json.JSONDecodeError:
25850
+ return {"status": "unparsed", "reasons": [result.stdout[:300]]}
25851
+
25852
+ def said(payload: dict) -> str:
25853
+ return " ".join(
25854
+ str(x) for x in
25855
+ (payload.get("reasons") or []) + (payload.get("warnings") or [])
25856
+ )
25857
+
25858
+ with tempfile.TemporaryDirectory(prefix="keel-paused-") as raw:
25859
+ root = Path(raw)
25860
+
25861
+ # Without the declaration the pair is ambiguous, so the skip is what
25862
+ # changes the answer rather than the fixture shape.
25863
+ both = root / "both"
25864
+ write_change(both, "alpha", None)
25865
+ write_change(both, "beta", None)
25866
+ if context(both).get("status") != "ambiguous":
25867
+ report(
25868
+ f"{label}: two unpaused changes were not ambiguous, so the "
25869
+ "fixture does not isolate what pausing changes."
25870
+ )
25871
+ return 1
25872
+
25873
+ mixed = root / "mixed"
25874
+ write_change(mixed, "alpha", paused_block)
25875
+ write_change(mixed, "beta", None)
25876
+ picked = context(mixed)
25877
+ if picked.get("status") != "ready":
25878
+ report(
25879
+ f"{label}: inference did not pass over the paused change; "
25880
+ f"{picked.get('status')!r} {said(picked)!r}."
25881
+ )
25882
+ return 1
25883
+ if (picked.get("selection") or {}).get("change") != "beta":
25884
+ report(
25885
+ f"{label}: inference selected {picked.get('selection')!r} "
25886
+ "rather than the change that is not paused."
25887
+ )
25888
+ return 1
25889
+ spoken = said(picked)
25890
+ if "alpha" not in spoken or "打分寻优" not in spoken:
25891
+ report(
25892
+ f"{label}: the paused change was skipped silently or without "
25893
+ f"its reason; {spoken!r}."
25894
+ )
25895
+ return 1
25896
+
25897
+ allpaused = root / "allpaused"
25898
+ write_change(allpaused, "alpha", paused_block)
25899
+ write_change(allpaused, "beta", paused_block)
25900
+ idle = context(allpaused)
25901
+ if idle.get("status") != "idle":
25902
+ report(
25903
+ f"{label}: a repository whose every change is paused reported "
25904
+ f"{idle.get('status')!r}."
25905
+ )
25906
+ return 1
25907
+ spoken = said(idle)
25908
+ for needed in ("alpha", "beta", "打分寻优"):
25909
+ if needed not in spoken:
25910
+ report(
25911
+ f"{label}: the all-paused report omits {needed!r}; "
25912
+ f"{spoken!r}."
25913
+ )
25914
+ return 1
25915
+
25916
+ explicit = context(mixed, "--change", "alpha")
25917
+ if (explicit.get("selection") or {}).get("change") != "alpha":
25918
+ report(
25919
+ f"{label}: explicit selection no longer reaches a paused "
25920
+ f"change; {explicit.get('status')!r} {said(explicit)!r}."
25921
+ )
25922
+ return 1
25923
+ if "paused" not in said(explicit).lower():
25924
+ report(
25925
+ f"{label}: explicit selection did not report that the change "
25926
+ f"is paused; {said(explicit)!r}."
25927
+ )
25928
+ return 1
25929
+
25930
+ # D6: a declaration Keel cannot read pauses nothing.
25931
+ broken = root / "broken"
25932
+ write_change(broken, "alpha", "keel:\n status: perhaps-later\n")
25933
+ write_change(broken, "beta", None)
25934
+ unreadable = context(broken)
25935
+ if unreadable.get("status") != "ambiguous":
25936
+ report(
25937
+ f"{label}: an unreadable declaration removed a change from "
25938
+ f"inference; {unreadable.get('status')!r} {said(unreadable)!r}."
25939
+ )
25940
+ return 1
25941
+ if "perhaps-later" not in said(unreadable):
25942
+ report(
25943
+ f"{label}: an unreadable declaration was not reported; "
25944
+ f"{said(unreadable)!r}."
25945
+ )
25946
+ return 1
25947
+
25948
+ # D4: pausing is not a gate.
25949
+ gated = run_keel(
25950
+ mixed, "gate", "task-start", "--change", "alpha", "--task", "1.1",
25951
+ "--json", "--no-guard",
25952
+ )
25953
+ ungated = run_keel(
25954
+ both, "gate", "task-start", "--change", "alpha", "--task", "1.1",
25955
+ "--json", "--no-guard",
25956
+ )
25957
+ if json.loads(gated.stdout).get("status") != json.loads(
25958
+ ungated.stdout
25959
+ ).get("status"):
25960
+ report(
25961
+ f"{label}: pausing changed a gate verdict — paused "
25962
+ f"{json.loads(gated.stdout).get('status')!r} against unpaused "
25963
+ f"{json.loads(ungated.stdout).get('status')!r}."
25964
+ )
25965
+ return 1
25966
+
25967
+ report(f"{label} scenario passed.")
25968
+ return 0
25969
+
25970
+
25815
25971
  # A scenario name, as the registry spells one. Two registered names carry no
25816
25972
  # hyphen — `cli` and `uninstall` — so requiring one would leave exactly those
25817
25973
  # two unchecked, and allowing single words was measured to add no false
@@ -26050,6 +26206,7 @@ SCENARIOS: tuple = (
26050
26206
  ("a-reference-outlives-its-declaration", validate_reference_outlives_its_declaration_scenario),
26051
26207
  ("the-obligation-is-stated-early", validate_obligation_is_stated_early_scenario),
26052
26208
  ("an-explanation-is-printed-once", validate_explanation_is_printed_once_scenario),
26209
+ ("a-paused-change-is-not-the-next-action", validate_paused_change_is_not_the_next_action_scenario),
26053
26210
  (
26054
26211
  "authored-scenario-names-are-registered",
26055
26212
  validate_authored_scenario_names_scenario,
@@ -230,8 +230,21 @@ function resolveExplicit(repo, change, task) {
230
230
  if (task && !/^\d+(?:\.\d+)+$/.test(task)) {
231
231
  return blocked(`Invalid explicit task: ${task}`);
232
232
  }
233
+ // A pause is skipped by inference and never by explicit selection: the owner
234
+ // has said which change they mean. It is still reported, because a session
235
+ // resuming into a paused change should know that is what it is.
236
+ const declaration = pauseDeclaration(repo, change);
237
+ const paused = declaration && declaration.paused
238
+ ? [
239
+ `Explicitly selected change is paused: ${change} — ${declaration.reason}`
240
+ + (declaration.since ? ` (since ${declaration.since})` : "")
241
+ + ". Inference passes over it; you asked for it by name.",
242
+ ]
243
+ : [];
233
244
  if (!task) {
234
- return selectionForChange(repo, change, "explicit");
245
+ const context = selectionForChange(repo, change, "explicit");
246
+ context.warnings.push(...paused);
247
+ return context;
235
248
  }
236
249
 
237
250
  const tasksPath = path.join(repo, "openspec", "changes", change, "tasks.md");
@@ -259,6 +272,58 @@ function activeChanges(repo) {
259
272
  .sort();
260
273
  }
261
274
 
275
+ // A change its owner deliberately stopped. Declared where the change lives,
276
+ // under a `keel:` key of the OpenSpec change config — namespaced because that
277
+ // file is OpenSpec's, and a bare `status:` would be a claim on a key OpenSpec
278
+ // may define differently. Read by inference and by nothing else: pausing says
279
+ // what to recommend, never what is allowed, so every gate behaves identically
280
+ // on a paused change and explicit selection still reaches it.
281
+ //
282
+ // A declaration that cannot be read leaves the change available and is
283
+ // reported. The alternative failure — a change silently dropped from inference
284
+ // because its config had a typo — is this defect pointed the other way.
285
+ function pauseDeclaration(repo, change) {
286
+ const configPath = path.join(
287
+ repo, "openspec", "changes", change, ".openspec.yaml"
288
+ );
289
+ if (!fs.existsSync(configPath)) return null;
290
+ let content;
291
+ try {
292
+ content = fs.readFileSync(configPath, "utf8");
293
+ } catch {
294
+ return { unreadable: "the file could not be read" };
295
+ }
296
+ // The indented body of a top-level `keel:` key: every following line that
297
+ // starts with whitespace. Simpler and safer than a lookahead for the next
298
+ // top-level key, which has to spell "end of input" as well.
299
+ const block = content.match(/^keel:[ \t]*\r?\n((?:[ \t]+\S[^\n]*\r?\n?)*)/m);
300
+ if (!block) return null;
301
+ const entries = new Map();
302
+ for (const line of block[1].split(/\r?\n/)) {
303
+ const match = line.match(/^\s+([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/);
304
+ if (match) entries.set(match[1].toLowerCase(), parseScalar(match[2]));
305
+ }
306
+ if (!entries.has("status")) return null;
307
+ const status = String(entries.get("status") || "").toLowerCase();
308
+ if (status !== "paused") {
309
+ return { unreadable: `status: ${entries.get("status")}` };
310
+ }
311
+ return {
312
+ paused: true,
313
+ reason: entries.get("reason") || "no reason recorded",
314
+ since: entries.get("since") || null,
315
+ };
316
+ }
317
+
318
+ function pauseNote(change, declaration) {
319
+ return (
320
+ `Paused change not inferred: ${change} — ${declaration.reason}`
321
+ + (declaration.since ? ` (since ${declaration.since})` : "")
322
+ + ". Select it explicitly with `keel context --change "
323
+ + `${change}\` if it is what you mean.`
324
+ );
325
+ }
326
+
262
327
  function inferContext(repo) {
263
328
  const changes = activeChanges(repo);
264
329
  if (changes.length === 0) {
@@ -270,20 +335,57 @@ function inferContext(repo) {
270
335
  ["No active OpenSpec change was found."]
271
336
  );
272
337
  }
273
- const contexts = changes.map((change) => selectionForChange(repo, change, "inferred"));
338
+ const declarations = new Map(
339
+ changes.map((change) => [change, pauseDeclaration(repo, change)])
340
+ );
341
+ const unreadable = [...declarations.entries()]
342
+ .filter(([, declaration]) => declaration && declaration.unreadable)
343
+ .map(([change, declaration]) =>
344
+ `Keel configuration for ${change} is not a pause declaration `
345
+ + `(${declaration.unreadable}); the change stays available to `
346
+ + "inference. A pause is `keel:` with `status: paused` and a `reason:`."
347
+ );
348
+ const paused = changes.filter(
349
+ (change) => declarations.get(change) && declarations.get(change).paused
350
+ );
351
+ const pauseNotes = paused.map(
352
+ (change) => pauseNote(change, declarations.get(change))
353
+ );
354
+ const active = changes.filter((change) => !paused.includes(change));
355
+ if (active.length === 0) {
356
+ // "Nothing to do" and "everything here is deliberately on hold" are
357
+ // different states, and the second is the one that tells a returning
358
+ // session whether to un-pause something or start something new.
359
+ return result(
360
+ "idle",
361
+ null,
362
+ "none",
363
+ [],
364
+ [
365
+ "Every active OpenSpec change is paused.",
366
+ ...pauseNotes,
367
+ ...unreadable,
368
+ ]
369
+ );
370
+ }
371
+ const contexts = active.map((change) => selectionForChange(repo, change, "inferred"));
274
372
  const storage = contexts.filter((context) => context.storageOnly);
275
373
  const candidates = contexts.filter((context) => !context.storageOnly);
276
- const warnings = storage.map(
277
- (context) =>
278
- `Storage-only backlog ignored during inference: ${context.selection.change}.`
279
- );
374
+ const warnings = [
375
+ ...storage.map(
376
+ (context) =>
377
+ `Storage-only backlog ignored during inference: ${context.selection.change}.`
378
+ ),
379
+ ...pauseNotes,
380
+ ...unreadable,
381
+ ];
280
382
  if (candidates.length === 0) {
281
383
  return result(
282
384
  "idle",
283
385
  null,
284
386
  "none",
285
387
  storage.flatMap((context) => context.read),
286
- ["No actionable OpenSpec change was found."]
388
+ ["No actionable OpenSpec change was found.", ...pauseNotes, ...unreadable]
287
389
  );
288
390
  }
289
391
  if (candidates.length > 1) {
@@ -297,6 +399,8 @@ function inferContext(repo) {
297
399
  + candidates
298
400
  .map((context) => context.selection?.change || context.read[0])
299
401
  .join(", "),
402
+ ...pauseNotes,
403
+ ...unreadable,
300
404
  ]
301
405
  );
302
406
  }