@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,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.
|
|
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
|
+
"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
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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.
|
|
41
|
-
PROTOCOL_VERSION = "5.
|
|
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
|
-
|
|
8973
|
-
|
|
8974
|
-
|
|
8975
|
-
|
|
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
|
)
|