@christang/keel 5.3.8 → 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.8 -->
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.8",
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.8",
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.8",
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",
@@ -5,8 +5,10 @@
5
5
  // file edits while an explicit keel/guard.json manifest is active. Absence of
6
6
  // the manifest allows everything silently; a present-but-untrusted manifest
7
7
  // fails closed. The hook never writes state, never spawns the keel CLI, and
8
- // always exits 0 — denial is expressed only through hook output. Paths that
9
- // resolve outside the repository root are not product writes and pass through.
8
+ // always exits 0 — denial is expressed only through hook output. The
9
+ // repository is the guard's scope: a path resolving outside it is not a product
10
+ // write and passes through, decided before the manifest is read so that no
11
+ // manifest state can reach it.
10
12
 
11
13
  const crypto = require("crypto");
12
14
  const fs = require("fs");
@@ -116,12 +118,27 @@ function main() {
116
118
  }
117
119
  const repo =
118
120
  typeof event.cwd === "string" && event.cwd ? event.cwd : process.cwd();
119
- const manifestPath = path.join(repo, "keel", "guard.json");
120
- if (!fs.existsSync(manifestPath)) return 0;
121
121
 
122
122
  const pathField = FILE_EDIT_TOOLS.get(event.tool_name);
123
123
  if (!pathField) return 0;
124
124
 
125
+ // The repository is the guard's scope, so this boundary is settled before the
126
+ // manifest is read at all. It needs only the event's cwd and target, so no
127
+ // manifest state — absent, invalid, drifted, or completed — has anything to
128
+ // say about a path outside it. Deciding it here rather than further down is
129
+ // what stops a branch added to the manifest section from denying a file the
130
+ // guard never protected; that ordering has already failed twice.
131
+ const target = event.tool_input ? event.tool_input[pathField] : null;
132
+ if (typeof target !== "string" || !target) return 0;
133
+ const relative = path.relative(repo, path.resolve(repo, target));
134
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
135
+ return 0;
136
+ }
137
+ const candidate = relative.replace(/\\/g, "/");
138
+
139
+ const manifestPath = path.join(repo, "keel", "guard.json");
140
+ if (!fs.existsSync(manifestPath)) return 0;
141
+
125
142
  let manifest = null;
126
143
  try {
127
144
  manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
@@ -144,14 +161,6 @@ function main() {
144
161
  // stop the one thing the completion gate is waiting for. Derived from the
145
162
  // manifest's existing `change` field, so no manifest shape changes.
146
163
  const recordPrefix = `openspec/changes/${manifest.change}/`;
147
-
148
- const target = event.tool_input ? event.tool_input[pathField] : null;
149
- if (typeof target !== "string" || !target) return 0;
150
- const relative = path.relative(repo, path.resolve(repo, target));
151
- if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
152
- return 0;
153
- }
154
- const candidate = relative.replace(/\\/g, "/");
155
164
  if (candidate.startsWith(recordPrefix)) return 0;
156
165
 
157
166
  for (const entry of manifest.authority) {
@@ -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.8"
41
- PROTOCOL_VERSION = "5.3.8"
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
@@ -5132,6 +5132,164 @@ def fill_template_slots(text: str, comments: str = "strip") -> str:
5132
5132
  text = collapsed
5133
5133
 
5134
5134
 
5135
+ def validate_guard_scope_is_the_repository_scenario() -> int:
5136
+ """Issue #31: a decision needing no manifest sat downstream of reading one.
5137
+
5138
+ Whether a target lies outside the repository is computable from the event's
5139
+ cwd and target path alone, but the invalid-manifest denial ran first, so a
5140
+ corrupt `keel/guard.json` denied writes to files the guard never protected.
5141
+ The precedence is what is asserted here, not the passthrough: a scenario
5142
+ checking only that an out-of-repo path passes under a valid manifest would
5143
+ have passed before this change too.
5144
+ """
5145
+ hook = ROOT / "plugins/keel/scripts/pretooluse-guard.js"
5146
+ if not hook.is_file():
5147
+ report(f"guard-scope-is-the-repository: missing hook {hook}.")
5148
+ return 1
5149
+
5150
+ with tempfile.TemporaryDirectory(prefix="keel-guard-scope-") as raw:
5151
+ root = Path(raw)
5152
+ repo = root / "repo"
5153
+ outside = root / "scratch"
5154
+ outside.mkdir(parents=True)
5155
+ write_text(repo / "src/feature.js", "// product\n")
5156
+ # A live spec, so the Covers source lands outside the record layer and
5157
+ # authority drift can actually fire. Drifting the change's own tasks.md
5158
+ # produces no drift at all, because the record layer exempts it.
5159
+ live = repo / "openspec/specs/demo-cap/spec.md"
5160
+ write_text(
5161
+ live,
5162
+ "# demo-cap\n\n## Purpose\n\nFixture.\n\n"
5163
+ "### Requirement: The system emits a feed status\n"
5164
+ "The system SHALL emit the recorded feed status.\n\n"
5165
+ "#### Scenario: A status is emitted\n"
5166
+ "- **WHEN** the feed runs\n- **THEN** the status is recorded\n",
5167
+ )
5168
+ write_text(
5169
+ repo / "openspec/changes/demo/tasks.md",
5170
+ "# Tasks\n\n## Invalidates\n\n- None.\n\n"
5171
+ "- [ ] 1.1 Exercise the guard\n"
5172
+ " - Covers:\n - demo-cap / The system emits a feed status\n"
5173
+ " - Touch:\n - src/feature.js\n"
5174
+ " - Verify:\n - Strategy: evidence-first\n - M1: node test.js\n"
5175
+ " - Evidence:\n - Contract: pending\n - M1: pending\n"
5176
+ " - Review:\n - Status: pending\n"
5177
+ " - Acceptance check: pending\n - Scope check: pending\n"
5178
+ " - Findings: pending\n - Blocker: none\n",
5179
+ )
5180
+ started = run_keel(
5181
+ repo, "gate", "task-start", "--change", "demo", "--task", "1.1",
5182
+ "--record", "--json",
5183
+ )
5184
+ if started.returncode != 0:
5185
+ report("guard-scope-is-the-repository: the fixture did not start.")
5186
+ report((started.stdout or started.stderr).strip())
5187
+ return 1
5188
+ manifest_path = repo / "keel/guard.json"
5189
+ manifest_text = manifest_path.read_text(encoding="utf-8")
5190
+ hashed = [
5191
+ item["path"] for item in json.loads(manifest_text).get("authority", [])
5192
+ ]
5193
+ if "openspec/specs/demo-cap/spec.md" not in hashed:
5194
+ report(
5195
+ "guard-scope-is-the-repository: the fixture hashed no authority "
5196
+ f"outside the change directory, so drift cannot fire: {hashed}."
5197
+ )
5198
+ return 1
5199
+
5200
+ def decide(target: Path) -> str:
5201
+ event = json.dumps({
5202
+ "cwd": str(repo),
5203
+ "tool_name": "Edit",
5204
+ "tool_input": {"file_path": str(target)},
5205
+ })
5206
+ result = subprocess.run(
5207
+ ["node", str(hook)],
5208
+ input=event,
5209
+ capture_output=True,
5210
+ text=True,
5211
+ encoding="utf-8",
5212
+ errors="replace",
5213
+ check=False,
5214
+ )
5215
+ out = (result.stdout or "").strip()
5216
+ if not out:
5217
+ return "allow"
5218
+ payload = json.loads(out)["hookSpecificOutput"]
5219
+ return payload.get("permissionDecisionReason", "deny")
5220
+
5221
+ def expect(label: str, target: Path, allow: bool, needle: str = "") -> bool:
5222
+ verdict = decide(target)
5223
+ if allow:
5224
+ if verdict != "allow":
5225
+ report(f"guard-scope-is-the-repository: {label} was denied.")
5226
+ report(f" {verdict}")
5227
+ return False
5228
+ return True
5229
+ if verdict == "allow":
5230
+ report(f"guard-scope-is-the-repository: {label} was allowed.")
5231
+ return False
5232
+ if needle and needle not in verdict:
5233
+ report(
5234
+ f"guard-scope-is-the-repository: {label} was denied for the "
5235
+ f"wrong reason; expected {needle!r}."
5236
+ )
5237
+ report(f" {verdict}")
5238
+ return False
5239
+ return True
5240
+
5241
+ scratch = outside / "notes.md"
5242
+ # M2 — the in-repository denials must survive the reordering.
5243
+ checks = [
5244
+ expect("an in-Touch write", repo / "src/feature.js", True),
5245
+ expect(
5246
+ "an in-repository path outside Touch",
5247
+ repo / "other.js",
5248
+ False,
5249
+ "outside Touch",
5250
+ ),
5251
+ expect(
5252
+ "the guarded change's own records",
5253
+ repo / "openspec/changes/demo/notes.md",
5254
+ True,
5255
+ ),
5256
+ expect("an out-of-repository write", scratch, True),
5257
+ ]
5258
+
5259
+ # M1, first half — genuine authority drift.
5260
+ write_text(live, live.read_text(encoding="utf-8") + "\nDRIFTED\n")
5261
+ checks.append(
5262
+ expect("an in-Touch write under drift", repo / "src/feature.js", False, "drift")
5263
+ )
5264
+ checks.append(
5265
+ expect("an out-of-repository write under drift", scratch, True)
5266
+ )
5267
+
5268
+ # M1, second half — the corrupt manifest, which is the reported defect.
5269
+ manifest_path.write_text("{ not json", encoding="utf-8")
5270
+ checks.append(
5271
+ expect("an out-of-repository write under a corrupt manifest", scratch, True)
5272
+ )
5273
+ checks.append(
5274
+ expect(
5275
+ "an in-repository write under a corrupt manifest",
5276
+ repo / "src/feature.js",
5277
+ False,
5278
+ "invalid",
5279
+ )
5280
+ )
5281
+ if not all(checks):
5282
+ return 1
5283
+ if "guard-scope-is-the-repository" not in {name for name, _ in SCENARIOS}:
5284
+ report(
5285
+ "guard-scope-is-the-repository: the scenario registry does not "
5286
+ "include it."
5287
+ )
5288
+ return 1
5289
+ report("guard-scope-is-the-repository scenario passed.")
5290
+ return 0
5291
+
5292
+
5135
5293
  def validate_completion_requires_a_recorded_anchor_scenario() -> int:
5136
5294
  """Issue #30: an unrecorded anchor made the drift guarantee conditional.
5137
5295
 
@@ -8782,10 +8940,17 @@ def run_session_start_hook(
8782
8940
  *,
8783
8941
  keel_cli: str,
8784
8942
  timeout_ms: int | None = None,
8943
+ panel: str | None = None,
8785
8944
  ) -> subprocess.CompletedProcess[str]:
8786
8945
  env = dict(os.environ)
8787
8946
  env["KEEL_CLI"] = keel_cli
8788
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
8789
8954
  if timeout_ms is not None:
8790
8955
  env["KEEL_HOOK_TIMEOUT_MS"] = str(timeout_ms)
8791
8956
  return subprocess.run(
@@ -8811,10 +8976,98 @@ def session_start_context(result: subprocess.CompletedProcess[str]) -> str | Non
8811
8976
  return output.get("additionalContext")
8812
8977
 
8813
8978
 
8814
- # The projection is delivered through additionalContext, which the host injects
8815
- # into the agent and never renders for the human. Every branch must therefore
8816
- # carry the instruction to relay it, including — especially — the degraded ones,
8817
- # 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.
8818
9071
  SESSION_START_DISCLOSURE = "to the user in your first reply"
8819
9072
 
8820
9073
  # A host loads its plugins once per session, so the projection can be absent for
@@ -9014,6 +9267,134 @@ def validate_native_plugin_session_start_scenario() -> int:
9014
9267
  report(repr(hang_context))
9015
9268
  return 1
9016
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
+
9017
9398
  hooks_config = json.loads(
9018
9399
  (ROOT / PLUGIN_ROOT / "hooks/hooks.json").read_text(encoding="utf-8")
9019
9400
  )
@@ -14145,6 +14526,10 @@ SCENARIOS: tuple = (
14145
14526
  "completion-requires-a-recorded-anchor",
14146
14527
  validate_completion_requires_a_recorded_anchor_scenario,
14147
14528
  ),
14529
+ (
14530
+ "guard-scope-is-the-repository",
14531
+ validate_guard_scope_is_the_repository_scenario,
14532
+ ),
14148
14533
  ("spec-template-validates", validate_spec_template_validates_scenario),
14149
14534
  (
14150
14535
  "tasks-template-red-green-example",