@christang/keel 5.3.9 → 5.4.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
@@ -114,6 +114,17 @@ keel --init → keel context → /opsx:apply (pick one task)
114
114
  → task-complete → /opsx:sync · /opsx:archive
115
115
  ```
116
116
 
117
+ On the Claude target, the session-start hook also shows that state to **you**, not only to the
118
+ agent — one line, before you type anything:
119
+
120
+ ```
121
+ Keel: add-user-auth#2.1 — next: task-start. Disposable projection; OpenSpec and Git are the authority.
122
+ ```
123
+
124
+ Set `KEEL_SESSION_PANEL=1` to draw it as a framed panel with the Keel mark instead. It is off by
125
+ default, and turning it on changes nothing but the presentation — the same status and the same
126
+ next command are in both forms.
127
+
117
128
  ### Full vs Lite
118
129
 
119
130
  Use **Full mode** (the OpenSpec flow above) for new features, interface or protocol changes,
@@ -1,4 +1,4 @@
1
- <!-- keel:start version=5.3.9 -->
1
+ <!-- keel:start version=5.4.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.3.9",
5
+ "version": "5.4.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.3.9",
3
+ "version": "5.4.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.3.9",
3
+ "version": "5.4.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",
@@ -15,9 +15,11 @@ const fs = require("fs");
15
15
  const path = require("path");
16
16
  const { spawnSync } = require("child_process");
17
17
 
18
- // This text is injected into the agent and never rendered for the human, so
19
- // without an explicit instruction the projection reaches nobody who can catch
20
- // it being wrong. Every branch carries the same phrase, degraded ones included.
18
+ // This text is injected into the agent; the human reads the `systemMessage`
19
+ // line instead. Both channels ship on every branch, degraded ones included,
20
+ // and neither makes the other redundant: the host's line says what the state
21
+ // is, and this instruction is what surfaces the state the agent actually
22
+ // worked from, which is the one a user can catch being wrong.
21
23
  const DISCLOSURE = "to the user in your first reply";
22
24
 
23
25
  const TIMEOUT_MS = Number(process.env.KEEL_HOOK_TIMEOUT_MS || 8000) || 8000;
@@ -32,15 +34,77 @@ function readStdin() {
32
34
  }
33
35
  }
34
36
 
35
- function emit(context) {
36
- process.stdout.write(
37
- `${JSON.stringify({
38
- hookSpecificOutput: {
39
- hookEventName: "SessionStart",
40
- additionalContext: context,
41
- },
42
- })}\n`
37
+ // The human line rides the host's `systemMessage` field, which is rendered to
38
+ // the person at session start without waiting for them to type. It is a second
39
+ // channel, not a replacement: `additionalContext` still carries the full
40
+ // projection to the agent, and a host that does not recognize the field simply
41
+ // ignores it and leaves today's behavior intact.
42
+ function emit(context, humanMessage) {
43
+ const payload = {};
44
+ if (humanMessage) payload.systemMessage = humanMessage;
45
+ payload.hookSpecificOutput = {
46
+ hookEventName: "SessionStart",
47
+ additionalContext: context,
48
+ };
49
+ process.stdout.write(`${JSON.stringify(payload)}\n`);
50
+ }
51
+
52
+ // Stated on the human line as well as the model payload: the person reading it
53
+ // at session start is the one who must not mistake a projection for authority.
54
+ const DISPOSABLE = "Disposable projection; OpenSpec and Git are the authority.";
55
+
56
+ // The Keel mark. A keel is the carina, the ridge on a bird's sternum, so the
57
+ // animal that literally has one is a bird. Every cell is drawn from
58
+ // U+2580–U+259F — the same block-element family as the host's own startup
59
+ // banner — because those code points are East-Asian-Ambiguous width: pinning
60
+ // the charset is what keeps the rows aligned under a CJK locale, and matters
61
+ // more than the shape. The rows are padded to equal width so that a future
62
+ // edit which breaks the rectangle is caught rather than silently skewed.
63
+ const MARK = [
64
+ "▙▖▛▀▜ ▛▀▜▗▟",
65
+ " ▌█▐ ▌█▐ ",
66
+ " ▙▄▟▚▞▙▄▟ ",
67
+ ].join("\n");
68
+
69
+ // The frame is modelled on the host's own welcome panel and draws from
70
+ // U+2500–U+257F, a different range than the mark. Its width is the longest
71
+ // content row, so a long change name widens the panel instead of being cut:
72
+ // the identifier is the most useful thing in the projection, and truncating
73
+ // the payload to preserve the frame would invert what the frame is for.
74
+ // Leads with a newline because the host prefixes the message with
75
+ // `<hookEvent>:<source> says: `, which would otherwise push the top rule out
76
+ // of line with the rows beneath it.
77
+ // Opt-in. The single line is what answers the reported problem — nobody is
78
+ // told anything at session start — and it ships on. The panel is presentation,
79
+ // and presentation that appears unbidden in every session of every install
80
+ // should be chosen rather than inherited. The allowlist is explicit so a typo
81
+ // leaves the default in place instead of quietly switching it on.
82
+ const PANEL_TITLE = "Keel";
83
+ const PANEL_ENABLED = /^(1|true|on|yes)$/i.test(
84
+ String(process.env.KEEL_SESSION_PANEL || "").trim()
85
+ );
86
+
87
+ function panel(lines) {
88
+ if (!PANEL_ENABLED) return lines.join(" ");
89
+ const rows = [...MARK.split("\n"), "", ...lines];
90
+ const width = Math.max(
91
+ ...rows.map((row) => row.length),
92
+ PANEL_TITLE.length + 8
43
93
  );
94
+ const centred = rows.map((row) => {
95
+ if (!row) return "";
96
+ const isMark = /^[▀-▟ ]+$/.test(row);
97
+ if (!isMark) return row;
98
+ const pad = Math.floor((width - row.length) / 2);
99
+ return " ".repeat(pad) + row;
100
+ });
101
+ const head = `─── ${PANEL_TITLE} `;
102
+ return [
103
+ "",
104
+ `╭${head}${"─".repeat(width + 2 - head.length)}╮`,
105
+ ...centred.map((row) => `│ ${row.padEnd(width)} │`),
106
+ `╰${"─".repeat(width + 2)}╯`,
107
+ ].join("\n");
44
108
  }
45
109
 
46
110
  function runKeel(cwd, args) {
@@ -53,11 +117,18 @@ function runKeel(cwd, args) {
53
117
  });
54
118
  }
55
119
 
120
+ // A degraded projection needs the human line most: a hook that fails silently
121
+ // is indistinguishable from a hook that never ran, which is how this whole
122
+ // failure mode was reported in the first place.
56
123
  function fallback(reason) {
57
124
  emit(
58
125
  `Keel hook fallback: ${reason} Run \`keel context\` manually; `
59
126
  + "OpenSpec and Git remain the durable authority. Report this failure "
60
- + `and that command ${DISCLOSURE}.`
127
+ + `and that command ${DISCLOSURE}.`,
128
+ panel([
129
+ `Keel: projection unavailable — ${reason} Next: keel context.`,
130
+ DISPOSABLE,
131
+ ])
61
132
  );
62
133
  }
63
134
 
@@ -112,8 +183,14 @@ function main() {
112
183
  : "Keel session projection (disposable; OpenSpec and Git are the durable authority):";
113
184
 
114
185
  const lines = [header];
186
+ let human = [];
115
187
  if (context.status === "ready" && context.selection) {
116
188
  const task = context.selection.task ? `#${context.selection.task}` : "";
189
+ human = [
190
+ `Keel: ${context.selection.change}${task} — next: `
191
+ + `${context.nextAction ? context.nextAction.kind : "unknown"}.`,
192
+ DISPOSABLE,
193
+ ];
117
194
  lines.push(
118
195
  `- context ready: ${context.selection.change}${task} `
119
196
  + `(${context.selection.source}); next action: `
@@ -142,8 +219,18 @@ function main() {
142
219
  );
143
220
  }
144
221
  } else {
145
- lines.push(`- context status: ${context.status || "unknown"}.`);
146
- for (const reason of (context.reasons || []).slice(0, MAX_REASONS)) {
222
+ const status = context.status || "unknown";
223
+ const reasons = (context.reasons || []).slice(0, MAX_REASONS);
224
+ human = [
225
+ `Keel: ${status}`
226
+ + (reasons.length > 0
227
+ ? ` — ${String(reasons[0]).slice(0, MAX_REASON_LENGTH)}`
228
+ : "")
229
+ + " Next: keel context.",
230
+ DISPOSABLE,
231
+ ];
232
+ lines.push(`- context status: ${status}.`);
233
+ for (const reason of reasons) {
147
234
  lines.push(`- reason: ${String(reason).slice(0, MAX_REASON_LENGTH)}`);
148
235
  }
149
236
  lines.push(
@@ -152,7 +239,7 @@ function main() {
152
239
  );
153
240
  }
154
241
  lines.push(`- report this state ${DISCLOSURE}; it authorizes nothing.`);
155
- emit(lines.join("\n"));
242
+ emit(lines.join("\n"), panel(human));
156
243
  return 0;
157
244
  }
158
245
 
@@ -37,8 +37,8 @@ REQUIRED_SCRIPTS = [
37
37
  "scripts/validate_plugin.py",
38
38
  ]
39
39
 
40
- PACKAGE_VERSION = "5.3.9"
41
- PROTOCOL_VERSION = "5.3.9"
40
+ PACKAGE_VERSION = "5.4.0"
41
+ PROTOCOL_VERSION = "5.4.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
@@ -8940,10 +8940,17 @@ def run_session_start_hook(
8940
8940
  *,
8941
8941
  keel_cli: str,
8942
8942
  timeout_ms: int | None = None,
8943
+ panel: str | None = None,
8943
8944
  ) -> subprocess.CompletedProcess[str]:
8944
8945
  env = dict(os.environ)
8945
8946
  env["KEEL_CLI"] = keel_cli
8946
8947
  env["CLAUDE_PLUGIN_ROOT"] = str(ROOT / PLUGIN_ROOT)
8948
+ # The suite must decide the panel's state rather than inherit whatever the
8949
+ # developer running it has exported, or the default-off assertion would
8950
+ # pass or fail by accident of the shell.
8951
+ env.pop("KEEL_SESSION_PANEL", None)
8952
+ if panel is not None:
8953
+ env["KEEL_SESSION_PANEL"] = panel
8947
8954
  if timeout_ms is not None:
8948
8955
  env["KEEL_HOOK_TIMEOUT_MS"] = str(timeout_ms)
8949
8956
  return subprocess.run(
@@ -8969,10 +8976,98 @@ def session_start_context(result: subprocess.CompletedProcess[str]) -> str | Non
8969
8976
  return output.get("additionalContext")
8970
8977
 
8971
8978
 
8972
- # The projection is delivered through additionalContext, which the host injects
8973
- # into the agent and never renders for the human. Every branch must therefore
8974
- # carry the instruction to relay it, including — especially — the degraded ones,
8975
- # because a projection nobody sees is a projection nobody checks.
8979
+ def session_start_message(result: subprocess.CompletedProcess[str]) -> str | None:
8980
+ """The human-visible half of the projection, carried on `systemMessage`."""
8981
+ if not result.stdout.strip():
8982
+ return None
8983
+ return json.loads(result.stdout).get("systemMessage")
8984
+
8985
+
8986
+ # Each branch pairs its human message with the tokens a person needs in order to
8987
+ # act: what the state is, and which command moves it. The degraded branches are
8988
+ # the load-bearing rows — a fallback nobody sees is the bug this pair of channels
8989
+ # exists to close.
8990
+ HUMAN_BRANCH_TOKENS = {
8991
+ "ready": ("demo#1.1",),
8992
+ "idle": ("idle", "keel context"),
8993
+ "ambiguous": ("ambiguous", "keel context"),
8994
+ "missing-CLI": ("missing or incompatible", "keel context"),
8995
+ "malformed": ("malformed", "keel context"),
8996
+ "timeout": ("failed or timed out", "keel context"),
8997
+ }
8998
+ HUMAN_AUTHORITY_TOKEN = "OpenSpec and Git"
8999
+
9000
+ # The mark is drawn only from the block-element range the host's own banner uses.
9001
+ # That range is East-Asian-Ambiguous width, so a terminal under a CJK locale
9002
+ # renders every one of these cells the same way it already renders the banner —
9003
+ # which is the whole reason the charset is pinned rather than the shape.
9004
+ MARK_RANGE = (0x2580, 0x259F)
9005
+ MARK_ROWS = 3
9006
+ BORDER_RANGE = (0x2500, 0x257F)
9007
+
9008
+
9009
+ def panel_rows(message: str) -> list[str]:
9010
+ """The rendered panel: everything after the leading newline."""
9011
+ return message[1:].split("\n") if message.startswith("\n") else []
9012
+
9013
+
9014
+ def is_mark_row(content: str) -> bool:
9015
+ """A mark row is non-empty and drawn only from blocks and inner spaces.
9016
+
9017
+ The inner spaces are load-bearing shape - they are the owl's eye gaps - so
9018
+ the charset test admits them rather than stripping the row down to its
9019
+ glyphs and demanding every remaining cell be a block.
9020
+ """
9021
+ return bool(content.strip()) and all(
9022
+ c == " " or MARK_RANGE[0] <= ord(c) <= MARK_RANGE[1] for c in content
9023
+ )
9024
+
9025
+
9026
+ def panel_problem(message: str) -> str | None:
9027
+ """The panel must close.
9028
+
9029
+ A frame turns a one-cell width error from a cosmetic skew into visibly
9030
+ broken output, so every row is checked for equal width rather than trusted.
9031
+ The mark keeps its own charset check: the border draws from U+2500-U+257F
9032
+ and the mark from U+2580-U+259F, and mixing them is what would misalign
9033
+ under a locale that renders one range wide.
9034
+ """
9035
+ if not message.startswith("\n"):
9036
+ return "human message does not open with a newline before the panel"
9037
+ rows = panel_rows(message)
9038
+ if len(rows) < MARK_ROWS + 3:
9039
+ return f"panel has {len(rows)} rows, too few to frame the mark"
9040
+ if not (rows[0].startswith("╭") and rows[0].endswith("╮")):
9041
+ return f"panel top rule is not a rule: {rows[0]!r}"
9042
+ if "Keel" not in rows[0]:
9043
+ return "panel top rule carries no title"
9044
+ if not (rows[-1].startswith("╰") and rows[-1].endswith("╯")):
9045
+ return f"panel bottom rule is not a rule: {rows[-1]!r}"
9046
+ for row in rows[1:-1]:
9047
+ if not (row.startswith("│") and row.endswith("│")):
9048
+ return f"panel body row is not enclosed: {row!r}"
9049
+ widths = {len(row) for row in rows}
9050
+ if len(widths) != 1:
9051
+ return f"panel rows are ragged: widths {sorted(widths)}"
9052
+ marks = [row[2:-2] for row in rows[1:-1] if is_mark_row(row[2:-2])]
9053
+ if len(marks) != MARK_ROWS:
9054
+ return f"panel carries {len(marks)} mark rows, expected {MARK_ROWS}"
9055
+ return None
9056
+
9057
+
9058
+ def panel_content(message: str) -> str:
9059
+ """The panel with its frame and mark taken away."""
9060
+ rows = panel_rows(message)
9061
+ kept = [row[2:-2] for row in rows[1:-1] if not is_mark_row(row[2:-2])]
9062
+ return " ".join(part.strip() for part in kept if part.strip())
9063
+
9064
+
9065
+ # additionalContext is the agent's half of the projection; the human reads the
9066
+ # systemMessage line asserted above. Every branch must still carry the
9067
+ # instruction to relay it, including — especially — the degraded ones. The two
9068
+ # checks are not redundant: one proves the state was shown, this one proves the
9069
+ # agent was told to say which state it is working from, and only the second can
9070
+ # expose the two disagreeing.
8976
9071
  SESSION_START_DISCLOSURE = "to the user in your first reply"
8977
9072
 
8978
9073
  # A host loads its plugins once per session, so the projection can be absent for
@@ -9172,6 +9267,134 @@ def validate_native_plugin_session_start_scenario() -> int:
9172
9267
  report(repr(hang_context))
9173
9268
  return 1
9174
9269
 
9270
+ # Every branch is exercised in both forms. The panel is opt-in, so the
9271
+ # default run is the one that ships; the enabled run only proves the
9272
+ # decoration still assembles when asked for. Both must carry the same
9273
+ # information, which is what keeps the switch from costing anything.
9274
+ branches = (
9275
+ ("ready", ready_repo, real_cli, None),
9276
+ ("idle", idle_repo, real_cli, None),
9277
+ ("ambiguous", ambiguous_repo, real_cli, None),
9278
+ ("missing-CLI", ready_repo, "keel-definitely-missing-cli-xyz", None),
9279
+ ("malformed", ready_repo, f'node "{malformed_cli}"', None),
9280
+ ("timeout", ready_repo, f'node "{hang_cli}"', 700),
9281
+ )
9282
+ for label, repo, cli, timeout_ms in branches:
9283
+ for panel_env in (None, "1"):
9284
+ result = run_session_start_hook(
9285
+ repo, codex_event, keel_cli=cli,
9286
+ timeout_ms=timeout_ms, panel=panel_env,
9287
+ )
9288
+ mode = "default" if panel_env is None else "panel"
9289
+ message = session_start_message(result)
9290
+ if not message:
9291
+ report(
9292
+ f"native-plugin-session-start {label}/{mode} branch "
9293
+ "emitted no human-visible message, so that state "
9294
+ "reaches only the agent and nobody can catch it being "
9295
+ "wrong."
9296
+ )
9297
+ return 1
9298
+ if label == "ambiguous" and "alpha#1.1" in message:
9299
+ report(
9300
+ f"native-plugin-session-start {label}/{mode} human "
9301
+ "message named a guessed owner."
9302
+ )
9303
+ return 1
9304
+ if panel_env is None:
9305
+ decoration = [
9306
+ c for c in message
9307
+ if MARK_RANGE[0] <= ord(c) <= MARK_RANGE[1]
9308
+ or BORDER_RANGE[0] <= ord(c) <= BORDER_RANGE[1]
9309
+ ]
9310
+ if decoration:
9311
+ report(
9312
+ f"native-plugin-session-start {label} draws the "
9313
+ f"panel without being asked: {decoration[:6]!r}"
9314
+ )
9315
+ return 1
9316
+ if "\n" in message:
9317
+ report(
9318
+ f"native-plugin-session-start {label} default "
9319
+ f"message is not a single line: {message!r}"
9320
+ )
9321
+ return 1
9322
+ carried = message
9323
+ else:
9324
+ problem = panel_problem(message)
9325
+ if problem:
9326
+ report(
9327
+ f"native-plugin-session-start {label} {problem}"
9328
+ )
9329
+ return 1
9330
+ # Neither frame nor mark may be load-bearing: take both
9331
+ # away and the message still has to say what the state is
9332
+ # and which command moves it.
9333
+ carried = panel_content(message)
9334
+ absent = [
9335
+ token
9336
+ for token in (
9337
+ *HUMAN_BRANCH_TOKENS[label], HUMAN_AUTHORITY_TOKEN
9338
+ )
9339
+ if token not in carried
9340
+ ]
9341
+ if absent:
9342
+ report(
9343
+ f"native-plugin-session-start {label}/{mode} message "
9344
+ f"omits {absent}: {carried!r}"
9345
+ )
9346
+ return 1
9347
+
9348
+ # A value outside the allowlist must leave the default in place, so a
9349
+ # typo cannot silently switch the decoration on.
9350
+ typo = session_start_message(
9351
+ run_session_start_hook(
9352
+ idle_repo, codex_event, keel_cli=real_cli, panel="yeah"
9353
+ )
9354
+ ) or ""
9355
+ if "\n" in typo or "╭" in typo:
9356
+ report(
9357
+ "native-plugin-session-start enabled the panel for a value "
9358
+ f"outside the allowlist: {typo!r}"
9359
+ )
9360
+ return 1
9361
+
9362
+ # The panel sizes to its content. A change name longer than every other
9363
+ # row must widen the frame rather than be cut, because the identifier
9364
+ # is the thing the reader came for.
9365
+ long_name = "a-deliberately-long-change-name-that-exceeds-the-panel-default"
9366
+ wide_repo = tmp / "wide"
9367
+ write_text(
9368
+ wide_repo / f"openspec/changes/{long_name}/tasks.md",
9369
+ task_contract_fixture(),
9370
+ )
9371
+ wide_message = session_start_message(
9372
+ run_session_start_hook(wide_repo, codex_event, keel_cli=real_cli, panel="1")
9373
+ ) or ""
9374
+ problem = panel_problem(wide_message)
9375
+ if problem:
9376
+ report(f"native-plugin-session-start wide panel {problem}")
9377
+ return 1
9378
+ if long_name not in panel_content(wide_message):
9379
+ report(
9380
+ "native-plugin-session-start truncated the change name to fit "
9381
+ f"the panel: {panel_content(wide_message)!r}"
9382
+ )
9383
+ return 1
9384
+ narrow_message = session_start_message(
9385
+ run_session_start_hook(
9386
+ idle_repo, codex_event, keel_cli=real_cli, panel="1"
9387
+ )
9388
+ ) or ""
9389
+ wide = len(panel_rows(wide_message)[0])
9390
+ narrow = len(panel_rows(narrow_message)[0])
9391
+ if wide <= narrow:
9392
+ report(
9393
+ "native-plugin-session-start panel width is fixed, not derived "
9394
+ f"from content: wide={wide} narrow={narrow}"
9395
+ )
9396
+ return 1
9397
+
9175
9398
  hooks_config = json.loads(
9176
9399
  (ROOT / PLUGIN_ROOT / "hooks/hooks.json").read_text(encoding="utf-8")
9177
9400
  )