@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 +11 -0
- package/assets/bootstrap/AGENTS.md +1 -1
- package/package.json +1 -1
- package/plugins/keel/.claude-plugin/plugin.json +1 -1
- package/plugins/keel/.codex-plugin/plugin.json +1 -1
- package/plugins/keel/scripts/pretooluse-guard.js +21 -12
- package/plugins/keel/scripts/session-start.js +102 -15
- package/scripts/validate_plugin.py +391 -6
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",
|
|
@@ -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.
|
|
9
|
-
//
|
|
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
|
|
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
|
|
@@ -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
|
-
|
|
8815
|
-
|
|
8816
|
-
|
|
8817
|
-
|
|
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",
|