@christang/keel 5.3.9 → 5.5.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 +43 -1
- package/assets/bootstrap/AGENTS.md +1 -1
- package/assets/openspec/schemas/keel-spec-driven/schema.yaml +6 -2
- package/assets/openspec/schemas/keel-spec-driven/templates/tasks.md +8 -4
- package/bin/keel.js +41 -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/session-start.js +102 -15
- package/scripts/validate_plugin.py +648 -6
- package/src/core/config.js +49 -0
- package/src/core/task-contract.js +19 -0
package/README.md
CHANGED
|
@@ -114,6 +114,47 @@ 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
|
+
|
|
128
|
+
### Standing authorization
|
|
129
|
+
|
|
130
|
+
Keel asks before a repository action it has no authority for, and it asks again next session,
|
|
131
|
+
because a permission granted in conversation does not survive a context reset. Declare it once in
|
|
132
|
+
`keel/config.yaml` instead:
|
|
133
|
+
|
|
134
|
+
```yaml
|
|
135
|
+
authorize: # accepted names: commit, push, release, archive
|
|
136
|
+
- commit
|
|
137
|
+
- push
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
A task that authors no `Autonomy boundary:` inherits the declaration, and the compiled capsule
|
|
141
|
+
names `keel/config.yaml` as that entry's source so an inherited authorization is never mistaken
|
|
142
|
+
for one the task decided. A task that authors its own boundary keeps it.
|
|
143
|
+
|
|
144
|
+
Three things the declaration is not:
|
|
145
|
+
|
|
146
|
+
- **Not a way past a gate.** It authorizes the action, never the proof. `keel gate task-complete`
|
|
147
|
+
returns exactly the same verdict, and the same failure text, whether or not you declared
|
|
148
|
+
anything.
|
|
149
|
+
- **Not a trigger.** It removes a confirmation, not the step that reaches the action. Nothing
|
|
150
|
+
schedules itself, and no next task is selected for you.
|
|
151
|
+
- **Not open-ended.** The four names above are the whole vocabulary. An unrecognized entry is
|
|
152
|
+
reported with the accepted names and the declaration authorizes nothing until you fix it — a
|
|
153
|
+
typo never becomes a silent grant.
|
|
154
|
+
|
|
155
|
+
The block is absent by default, and a repository that declares nothing behaves exactly as it did
|
|
156
|
+
before this feature existed. `keel --doctor` reports what is declared.
|
|
157
|
+
|
|
117
158
|
### Full vs Lite
|
|
118
159
|
|
|
119
160
|
Use **Full mode** (the OpenSpec flow above) for new features, interface or protocol changes,
|
|
@@ -149,7 +190,8 @@ Keel splits verification into two layers so a slow suite never blocks your push:
|
|
|
149
190
|
run at CI or at `keel gate change-close`.
|
|
150
191
|
|
|
151
192
|
A task's `Verify` checks stay fast; the slow or exhaustive layer belongs to the full gate, not the
|
|
152
|
-
local pre-push. Declare your fast check
|
|
193
|
+
local pre-push. Declare your fast check in `keel/config.yaml`, the same file that holds your
|
|
194
|
+
standing authorization:
|
|
153
195
|
|
|
154
196
|
```yaml
|
|
155
197
|
fast_check: npm test -- --fast # your project's seconds-scale check
|
|
@@ -126,8 +126,12 @@ artifacts:
|
|
|
126
126
|
Keel agent. Keel-managed work executes in the current agent conversation;
|
|
127
127
|
do not hand execution to another agent, subagent, or operator unless the
|
|
128
128
|
selected task or user explicitly authorizes it.
|
|
129
|
-
Autonomy boundary defaults to hard-stop
|
|
130
|
-
|
|
129
|
+
Autonomy boundary defaults to hard-stop for every action the repository
|
|
130
|
+
has not standing-authorized in `keel/config.yaml`; a declared action is
|
|
131
|
+
inherited only by a task that authored no boundary of its own, and the
|
|
132
|
+
capsule names the declaration as that entry's source. A pre-authorized
|
|
133
|
+
fallback must state an exact reversible bound and the evidence required
|
|
134
|
+
after use.
|
|
131
135
|
|
|
132
136
|
Use `Coupling: none` by default. When `Coupling: required`, define one
|
|
133
137
|
complete candidate, its allowed provisional failures, completion gate,
|
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
Record only task-specific authority. Omitted fields inherit versioned
|
|
3
3
|
defaults: Owner is the current Keel agent, Mode is implementation, Read
|
|
4
4
|
is the change proposal/design/specs/tasks plus discovered repository
|
|
5
|
-
context, Acceptance derives from Covers,
|
|
6
|
-
|
|
7
|
-
commit, push, sync, archive, and cross-task continuation stay
|
|
8
|
-
|
|
5
|
+
context, Acceptance derives from Covers, Coupling defaults to none, and
|
|
6
|
+
helpers stay read-only/evidence-only. Autonomy defaults to hard-stop, and
|
|
7
|
+
commit, push, sync, archive, and cross-task continuation stay unauthorized,
|
|
8
|
+
EXCEPT where `keel/config.yaml` standing-authorizes an action: a task that
|
|
9
|
+
authors no `Autonomy boundary:` inherits that declaration, and the capsule
|
|
10
|
+
names the declaration as the entry's source. A standing authorization
|
|
11
|
+
removes the confirmation, never the gate, evidence, or Review.
|
|
12
|
+
Declare a field only when it differs from these defaults. -->
|
|
9
13
|
|
|
10
14
|
## 1. <!-- Task Group Name -->
|
|
11
15
|
|
package/bin/keel.js
CHANGED
|
@@ -44,6 +44,10 @@ const {
|
|
|
44
44
|
renderGuard,
|
|
45
45
|
startGuard,
|
|
46
46
|
} = require("../src/core/guard");
|
|
47
|
+
const {
|
|
48
|
+
STANDING_AUTHORIZATION_ACTIONS,
|
|
49
|
+
readStandingAuthorization,
|
|
50
|
+
} = require("../src/core/config");
|
|
47
51
|
|
|
48
52
|
const PACKAGE_ROOT = path.resolve(__dirname, "..");
|
|
49
53
|
const PACKAGE_JSON = require(path.join(PACKAGE_ROOT, "package.json"));
|
|
@@ -1033,6 +1037,8 @@ function keelOpenSpecOverlay(action) {
|
|
|
1033
1037
|
"- When implementation exposes a material expectation, acceptance boundary, or user-owned decision absent from durable authority, stop before implementing that choice, rerun `keel-align-expectations`, and reauthor the affected proposal/design/spec/task authority first.",
|
|
1034
1038
|
"- A discovered repository fact that does not change accepted behavior or scope may be recorded and execution continues inside the existing task boundary without a product interview.",
|
|
1035
1039
|
"- Invoke OpenSpec through `keel openspec` (for example `keel openspec validate`); a bare `openspec` command may not be on PATH.",
|
|
1040
|
+
"- Consult the repository's standing authorization in `keel/config.yaml` before asking the user to confirm a repository action: a standing-authorized action proceeds without a per-occurrence confirmation, and an undeclared action still requires the confirmation it requires today.",
|
|
1041
|
+
"- A standing authorization covers the action and never substitutes for a gate, evidence, or Review; it removes the confirmation, not the record, and it is not a trigger to perform the action.",
|
|
1036
1042
|
]
|
|
1037
1043
|
: [
|
|
1038
1044
|
"- The current agent owns final sync/archive decisions and must verify task evidence, follow-up ownership, and completion gates before proceeding.",
|
|
@@ -1043,6 +1049,8 @@ function keelOpenSpecOverlay(action) {
|
|
|
1043
1049
|
"- Invoke OpenSpec through `keel openspec` (for example `keel openspec validate`); a bare `openspec` command may not be on PATH.",
|
|
1044
1050
|
"- When `/opsx:sync` has already promoted the change's spec delta, run the archive with `--skip-specs` so the promoted delta is not re-applied; archive is not idempotent over an already-synced delta.",
|
|
1045
1051
|
"- After archiving, run `keel guard clear` to drop the change's guard manifest; the read-only gate never clears it for you.",
|
|
1052
|
+
"- A repository that standing-authorizes `archive` in `keel/config.yaml` does not need the per-occurrence archive confirmation; a repository that declares nothing still needs it.",
|
|
1053
|
+
"- The completion gate and follow-up ownership checks still run unchanged under a standing authorization; it removes the confirmation, not the proof.",
|
|
1046
1054
|
];
|
|
1047
1055
|
|
|
1048
1056
|
const lines = [
|
|
@@ -1344,10 +1352,11 @@ function runDoctor(options) {
|
|
|
1344
1352
|
|
|
1345
1353
|
printTargetSurface(repo, options.target);
|
|
1346
1354
|
printLensSurface(repo, options.target);
|
|
1355
|
+
const authorizationOk = printStandingAuthorizationSurface(repo);
|
|
1347
1356
|
printFastPrePushSurface(repo);
|
|
1348
1357
|
printSourceRepoCliResolution(repo);
|
|
1349
1358
|
|
|
1350
|
-
return checkStatus;
|
|
1359
|
+
return authorizationOk ? checkStatus : 1;
|
|
1351
1360
|
}
|
|
1352
1361
|
|
|
1353
1362
|
// Only meaningful in Keel's own repository: a bare `keel` resolves to the
|
|
@@ -1390,6 +1399,37 @@ function gitConfigHooksPath(repo) {
|
|
|
1390
1399
|
return value || null;
|
|
1391
1400
|
}
|
|
1392
1401
|
|
|
1402
|
+
function printStandingAuthorizationSurface(repo) {
|
|
1403
|
+
process.stdout.write("\nStanding authorization:\n");
|
|
1404
|
+
const { declared, unknown } = readStandingAuthorization(repo);
|
|
1405
|
+
if (unknown.length > 0) {
|
|
1406
|
+
printDoctorLine(
|
|
1407
|
+
"authorize",
|
|
1408
|
+
"failed",
|
|
1409
|
+
`keel/config.yaml declares unrecognized ${
|
|
1410
|
+
unknown.length === 1 ? "action" : "actions"
|
|
1411
|
+
}: ${unknown.join(", ")}; accepted names are `
|
|
1412
|
+
+ `${STANDING_AUTHORIZATION_ACTIONS.join(", ")}. The whole declaration `
|
|
1413
|
+
+ "authorizes nothing until it is corrected"
|
|
1414
|
+
);
|
|
1415
|
+
return false;
|
|
1416
|
+
}
|
|
1417
|
+
printDoctorLine(
|
|
1418
|
+
"authorize",
|
|
1419
|
+
declared.length > 0 ? "ok" : "none",
|
|
1420
|
+
declared.length > 0
|
|
1421
|
+
? `declared in keel/config.yaml: ${declared.join(", ")}`
|
|
1422
|
+
: "undeclared; every action stays hard-stop"
|
|
1423
|
+
);
|
|
1424
|
+
for (const action of STANDING_AUTHORIZATION_ACTIONS) {
|
|
1425
|
+
printDoctorLine(
|
|
1426
|
+
action,
|
|
1427
|
+
declared.includes(action) ? "authorized" : "not authorized"
|
|
1428
|
+
);
|
|
1429
|
+
}
|
|
1430
|
+
return true;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1393
1433
|
function printFastPrePushSurface(repo) {
|
|
1394
1434
|
process.stdout.write("\nFast pre-push surface:\n");
|
|
1395
1435
|
const fastCheck = readFastCheck(repo);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.5.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.5.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.5.0"
|
|
41
|
+
PROTOCOL_VERSION = "5.5.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
|
|
@@ -2087,6 +2087,12 @@ def assert_openspec_overlay(path: Path, action: str) -> str | None:
|
|
|
2087
2087
|
"source expectations",
|
|
2088
2088
|
"Rough future slices",
|
|
2089
2089
|
"cannot mark tasks complete",
|
|
2090
|
+
# A confirmation the owner already declared is routed to the
|
|
2091
|
+
# declaration; one they did not declare is still asked for, and
|
|
2092
|
+
# neither case touches the proof.
|
|
2093
|
+
"standing-authorized action proceeds without",
|
|
2094
|
+
"undeclared action still requires",
|
|
2095
|
+
"never substitutes for a gate",
|
|
2090
2096
|
]
|
|
2091
2097
|
)
|
|
2092
2098
|
else:
|
|
@@ -2098,6 +2104,8 @@ def assert_openspec_overlay(path: Path, action: str) -> str | None:
|
|
|
2098
2104
|
"durable follow-up owner",
|
|
2099
2105
|
"explicit discard reason",
|
|
2100
2106
|
"cannot archive, sync, change acceptance, or bypass completion gates",
|
|
2107
|
+
"standing-authorizes `archive`",
|
|
2108
|
+
"completion gate and follow-up ownership checks still run",
|
|
2101
2109
|
]
|
|
2102
2110
|
)
|
|
2103
2111
|
for snippet in required:
|
|
@@ -8940,10 +8948,17 @@ def run_session_start_hook(
|
|
|
8940
8948
|
*,
|
|
8941
8949
|
keel_cli: str,
|
|
8942
8950
|
timeout_ms: int | None = None,
|
|
8951
|
+
panel: str | None = None,
|
|
8943
8952
|
) -> subprocess.CompletedProcess[str]:
|
|
8944
8953
|
env = dict(os.environ)
|
|
8945
8954
|
env["KEEL_CLI"] = keel_cli
|
|
8946
8955
|
env["CLAUDE_PLUGIN_ROOT"] = str(ROOT / PLUGIN_ROOT)
|
|
8956
|
+
# The suite must decide the panel's state rather than inherit whatever the
|
|
8957
|
+
# developer running it has exported, or the default-off assertion would
|
|
8958
|
+
# pass or fail by accident of the shell.
|
|
8959
|
+
env.pop("KEEL_SESSION_PANEL", None)
|
|
8960
|
+
if panel is not None:
|
|
8961
|
+
env["KEEL_SESSION_PANEL"] = panel
|
|
8947
8962
|
if timeout_ms is not None:
|
|
8948
8963
|
env["KEEL_HOOK_TIMEOUT_MS"] = str(timeout_ms)
|
|
8949
8964
|
return subprocess.run(
|
|
@@ -8969,10 +8984,98 @@ def session_start_context(result: subprocess.CompletedProcess[str]) -> str | Non
|
|
|
8969
8984
|
return output.get("additionalContext")
|
|
8970
8985
|
|
|
8971
8986
|
|
|
8972
|
-
|
|
8973
|
-
|
|
8974
|
-
|
|
8975
|
-
|
|
8987
|
+
def session_start_message(result: subprocess.CompletedProcess[str]) -> str | None:
|
|
8988
|
+
"""The human-visible half of the projection, carried on `systemMessage`."""
|
|
8989
|
+
if not result.stdout.strip():
|
|
8990
|
+
return None
|
|
8991
|
+
return json.loads(result.stdout).get("systemMessage")
|
|
8992
|
+
|
|
8993
|
+
|
|
8994
|
+
# Each branch pairs its human message with the tokens a person needs in order to
|
|
8995
|
+
# act: what the state is, and which command moves it. The degraded branches are
|
|
8996
|
+
# the load-bearing rows — a fallback nobody sees is the bug this pair of channels
|
|
8997
|
+
# exists to close.
|
|
8998
|
+
HUMAN_BRANCH_TOKENS = {
|
|
8999
|
+
"ready": ("demo#1.1",),
|
|
9000
|
+
"idle": ("idle", "keel context"),
|
|
9001
|
+
"ambiguous": ("ambiguous", "keel context"),
|
|
9002
|
+
"missing-CLI": ("missing or incompatible", "keel context"),
|
|
9003
|
+
"malformed": ("malformed", "keel context"),
|
|
9004
|
+
"timeout": ("failed or timed out", "keel context"),
|
|
9005
|
+
}
|
|
9006
|
+
HUMAN_AUTHORITY_TOKEN = "OpenSpec and Git"
|
|
9007
|
+
|
|
9008
|
+
# The mark is drawn only from the block-element range the host's own banner uses.
|
|
9009
|
+
# That range is East-Asian-Ambiguous width, so a terminal under a CJK locale
|
|
9010
|
+
# renders every one of these cells the same way it already renders the banner —
|
|
9011
|
+
# which is the whole reason the charset is pinned rather than the shape.
|
|
9012
|
+
MARK_RANGE = (0x2580, 0x259F)
|
|
9013
|
+
MARK_ROWS = 3
|
|
9014
|
+
BORDER_RANGE = (0x2500, 0x257F)
|
|
9015
|
+
|
|
9016
|
+
|
|
9017
|
+
def panel_rows(message: str) -> list[str]:
|
|
9018
|
+
"""The rendered panel: everything after the leading newline."""
|
|
9019
|
+
return message[1:].split("\n") if message.startswith("\n") else []
|
|
9020
|
+
|
|
9021
|
+
|
|
9022
|
+
def is_mark_row(content: str) -> bool:
|
|
9023
|
+
"""A mark row is non-empty and drawn only from blocks and inner spaces.
|
|
9024
|
+
|
|
9025
|
+
The inner spaces are load-bearing shape - they are the owl's eye gaps - so
|
|
9026
|
+
the charset test admits them rather than stripping the row down to its
|
|
9027
|
+
glyphs and demanding every remaining cell be a block.
|
|
9028
|
+
"""
|
|
9029
|
+
return bool(content.strip()) and all(
|
|
9030
|
+
c == " " or MARK_RANGE[0] <= ord(c) <= MARK_RANGE[1] for c in content
|
|
9031
|
+
)
|
|
9032
|
+
|
|
9033
|
+
|
|
9034
|
+
def panel_problem(message: str) -> str | None:
|
|
9035
|
+
"""The panel must close.
|
|
9036
|
+
|
|
9037
|
+
A frame turns a one-cell width error from a cosmetic skew into visibly
|
|
9038
|
+
broken output, so every row is checked for equal width rather than trusted.
|
|
9039
|
+
The mark keeps its own charset check: the border draws from U+2500-U+257F
|
|
9040
|
+
and the mark from U+2580-U+259F, and mixing them is what would misalign
|
|
9041
|
+
under a locale that renders one range wide.
|
|
9042
|
+
"""
|
|
9043
|
+
if not message.startswith("\n"):
|
|
9044
|
+
return "human message does not open with a newline before the panel"
|
|
9045
|
+
rows = panel_rows(message)
|
|
9046
|
+
if len(rows) < MARK_ROWS + 3:
|
|
9047
|
+
return f"panel has {len(rows)} rows, too few to frame the mark"
|
|
9048
|
+
if not (rows[0].startswith("╭") and rows[0].endswith("╮")):
|
|
9049
|
+
return f"panel top rule is not a rule: {rows[0]!r}"
|
|
9050
|
+
if "Keel" not in rows[0]:
|
|
9051
|
+
return "panel top rule carries no title"
|
|
9052
|
+
if not (rows[-1].startswith("╰") and rows[-1].endswith("╯")):
|
|
9053
|
+
return f"panel bottom rule is not a rule: {rows[-1]!r}"
|
|
9054
|
+
for row in rows[1:-1]:
|
|
9055
|
+
if not (row.startswith("│") and row.endswith("│")):
|
|
9056
|
+
return f"panel body row is not enclosed: {row!r}"
|
|
9057
|
+
widths = {len(row) for row in rows}
|
|
9058
|
+
if len(widths) != 1:
|
|
9059
|
+
return f"panel rows are ragged: widths {sorted(widths)}"
|
|
9060
|
+
marks = [row[2:-2] for row in rows[1:-1] if is_mark_row(row[2:-2])]
|
|
9061
|
+
if len(marks) != MARK_ROWS:
|
|
9062
|
+
return f"panel carries {len(marks)} mark rows, expected {MARK_ROWS}"
|
|
9063
|
+
return None
|
|
9064
|
+
|
|
9065
|
+
|
|
9066
|
+
def panel_content(message: str) -> str:
|
|
9067
|
+
"""The panel with its frame and mark taken away."""
|
|
9068
|
+
rows = panel_rows(message)
|
|
9069
|
+
kept = [row[2:-2] for row in rows[1:-1] if not is_mark_row(row[2:-2])]
|
|
9070
|
+
return " ".join(part.strip() for part in kept if part.strip())
|
|
9071
|
+
|
|
9072
|
+
|
|
9073
|
+
# additionalContext is the agent's half of the projection; the human reads the
|
|
9074
|
+
# systemMessage line asserted above. Every branch must still carry the
|
|
9075
|
+
# instruction to relay it, including — especially — the degraded ones. The two
|
|
9076
|
+
# checks are not redundant: one proves the state was shown, this one proves the
|
|
9077
|
+
# agent was told to say which state it is working from, and only the second can
|
|
9078
|
+
# expose the two disagreeing.
|
|
8976
9079
|
SESSION_START_DISCLOSURE = "to the user in your first reply"
|
|
8977
9080
|
|
|
8978
9081
|
# A host loads its plugins once per session, so the projection can be absent for
|
|
@@ -9172,6 +9275,134 @@ def validate_native_plugin_session_start_scenario() -> int:
|
|
|
9172
9275
|
report(repr(hang_context))
|
|
9173
9276
|
return 1
|
|
9174
9277
|
|
|
9278
|
+
# Every branch is exercised in both forms. The panel is opt-in, so the
|
|
9279
|
+
# default run is the one that ships; the enabled run only proves the
|
|
9280
|
+
# decoration still assembles when asked for. Both must carry the same
|
|
9281
|
+
# information, which is what keeps the switch from costing anything.
|
|
9282
|
+
branches = (
|
|
9283
|
+
("ready", ready_repo, real_cli, None),
|
|
9284
|
+
("idle", idle_repo, real_cli, None),
|
|
9285
|
+
("ambiguous", ambiguous_repo, real_cli, None),
|
|
9286
|
+
("missing-CLI", ready_repo, "keel-definitely-missing-cli-xyz", None),
|
|
9287
|
+
("malformed", ready_repo, f'node "{malformed_cli}"', None),
|
|
9288
|
+
("timeout", ready_repo, f'node "{hang_cli}"', 700),
|
|
9289
|
+
)
|
|
9290
|
+
for label, repo, cli, timeout_ms in branches:
|
|
9291
|
+
for panel_env in (None, "1"):
|
|
9292
|
+
result = run_session_start_hook(
|
|
9293
|
+
repo, codex_event, keel_cli=cli,
|
|
9294
|
+
timeout_ms=timeout_ms, panel=panel_env,
|
|
9295
|
+
)
|
|
9296
|
+
mode = "default" if panel_env is None else "panel"
|
|
9297
|
+
message = session_start_message(result)
|
|
9298
|
+
if not message:
|
|
9299
|
+
report(
|
|
9300
|
+
f"native-plugin-session-start {label}/{mode} branch "
|
|
9301
|
+
"emitted no human-visible message, so that state "
|
|
9302
|
+
"reaches only the agent and nobody can catch it being "
|
|
9303
|
+
"wrong."
|
|
9304
|
+
)
|
|
9305
|
+
return 1
|
|
9306
|
+
if label == "ambiguous" and "alpha#1.1" in message:
|
|
9307
|
+
report(
|
|
9308
|
+
f"native-plugin-session-start {label}/{mode} human "
|
|
9309
|
+
"message named a guessed owner."
|
|
9310
|
+
)
|
|
9311
|
+
return 1
|
|
9312
|
+
if panel_env is None:
|
|
9313
|
+
decoration = [
|
|
9314
|
+
c for c in message
|
|
9315
|
+
if MARK_RANGE[0] <= ord(c) <= MARK_RANGE[1]
|
|
9316
|
+
or BORDER_RANGE[0] <= ord(c) <= BORDER_RANGE[1]
|
|
9317
|
+
]
|
|
9318
|
+
if decoration:
|
|
9319
|
+
report(
|
|
9320
|
+
f"native-plugin-session-start {label} draws the "
|
|
9321
|
+
f"panel without being asked: {decoration[:6]!r}"
|
|
9322
|
+
)
|
|
9323
|
+
return 1
|
|
9324
|
+
if "\n" in message:
|
|
9325
|
+
report(
|
|
9326
|
+
f"native-plugin-session-start {label} default "
|
|
9327
|
+
f"message is not a single line: {message!r}"
|
|
9328
|
+
)
|
|
9329
|
+
return 1
|
|
9330
|
+
carried = message
|
|
9331
|
+
else:
|
|
9332
|
+
problem = panel_problem(message)
|
|
9333
|
+
if problem:
|
|
9334
|
+
report(
|
|
9335
|
+
f"native-plugin-session-start {label} {problem}"
|
|
9336
|
+
)
|
|
9337
|
+
return 1
|
|
9338
|
+
# Neither frame nor mark may be load-bearing: take both
|
|
9339
|
+
# away and the message still has to say what the state is
|
|
9340
|
+
# and which command moves it.
|
|
9341
|
+
carried = panel_content(message)
|
|
9342
|
+
absent = [
|
|
9343
|
+
token
|
|
9344
|
+
for token in (
|
|
9345
|
+
*HUMAN_BRANCH_TOKENS[label], HUMAN_AUTHORITY_TOKEN
|
|
9346
|
+
)
|
|
9347
|
+
if token not in carried
|
|
9348
|
+
]
|
|
9349
|
+
if absent:
|
|
9350
|
+
report(
|
|
9351
|
+
f"native-plugin-session-start {label}/{mode} message "
|
|
9352
|
+
f"omits {absent}: {carried!r}"
|
|
9353
|
+
)
|
|
9354
|
+
return 1
|
|
9355
|
+
|
|
9356
|
+
# A value outside the allowlist must leave the default in place, so a
|
|
9357
|
+
# typo cannot silently switch the decoration on.
|
|
9358
|
+
typo = session_start_message(
|
|
9359
|
+
run_session_start_hook(
|
|
9360
|
+
idle_repo, codex_event, keel_cli=real_cli, panel="yeah"
|
|
9361
|
+
)
|
|
9362
|
+
) or ""
|
|
9363
|
+
if "\n" in typo or "╭" in typo:
|
|
9364
|
+
report(
|
|
9365
|
+
"native-plugin-session-start enabled the panel for a value "
|
|
9366
|
+
f"outside the allowlist: {typo!r}"
|
|
9367
|
+
)
|
|
9368
|
+
return 1
|
|
9369
|
+
|
|
9370
|
+
# The panel sizes to its content. A change name longer than every other
|
|
9371
|
+
# row must widen the frame rather than be cut, because the identifier
|
|
9372
|
+
# is the thing the reader came for.
|
|
9373
|
+
long_name = "a-deliberately-long-change-name-that-exceeds-the-panel-default"
|
|
9374
|
+
wide_repo = tmp / "wide"
|
|
9375
|
+
write_text(
|
|
9376
|
+
wide_repo / f"openspec/changes/{long_name}/tasks.md",
|
|
9377
|
+
task_contract_fixture(),
|
|
9378
|
+
)
|
|
9379
|
+
wide_message = session_start_message(
|
|
9380
|
+
run_session_start_hook(wide_repo, codex_event, keel_cli=real_cli, panel="1")
|
|
9381
|
+
) or ""
|
|
9382
|
+
problem = panel_problem(wide_message)
|
|
9383
|
+
if problem:
|
|
9384
|
+
report(f"native-plugin-session-start wide panel {problem}")
|
|
9385
|
+
return 1
|
|
9386
|
+
if long_name not in panel_content(wide_message):
|
|
9387
|
+
report(
|
|
9388
|
+
"native-plugin-session-start truncated the change name to fit "
|
|
9389
|
+
f"the panel: {panel_content(wide_message)!r}"
|
|
9390
|
+
)
|
|
9391
|
+
return 1
|
|
9392
|
+
narrow_message = session_start_message(
|
|
9393
|
+
run_session_start_hook(
|
|
9394
|
+
idle_repo, codex_event, keel_cli=real_cli, panel="1"
|
|
9395
|
+
)
|
|
9396
|
+
) or ""
|
|
9397
|
+
wide = len(panel_rows(wide_message)[0])
|
|
9398
|
+
narrow = len(panel_rows(narrow_message)[0])
|
|
9399
|
+
if wide <= narrow:
|
|
9400
|
+
report(
|
|
9401
|
+
"native-plugin-session-start panel width is fixed, not derived "
|
|
9402
|
+
f"from content: wide={wide} narrow={narrow}"
|
|
9403
|
+
)
|
|
9404
|
+
return 1
|
|
9405
|
+
|
|
9175
9406
|
hooks_config = json.loads(
|
|
9176
9407
|
(ROOT / PLUGIN_ROOT / "hooks/hooks.json").read_text(encoding="utf-8")
|
|
9177
9408
|
)
|
|
@@ -10888,6 +11119,405 @@ def validate_verification_layering_docs_scenario() -> int:
|
|
|
10888
11119
|
return 0
|
|
10889
11120
|
|
|
10890
11121
|
|
|
11122
|
+
STANDING_AUTHORIZATION_ACTIONS = ("commit", "push", "release", "archive")
|
|
11123
|
+
|
|
11124
|
+
|
|
11125
|
+
def write_authorize_config(repo: Path, body: str) -> None:
|
|
11126
|
+
(repo / "keel").mkdir(parents=True, exist_ok=True)
|
|
11127
|
+
(repo / "keel" / "config.yaml").write_text(body, encoding="utf-8")
|
|
11128
|
+
|
|
11129
|
+
|
|
11130
|
+
def validate_standing_authorization_declaration_scenario() -> int:
|
|
11131
|
+
with tempfile.TemporaryDirectory(prefix="keel-authorize-") as raw_tmp:
|
|
11132
|
+
root = Path(raw_tmp)
|
|
11133
|
+
|
|
11134
|
+
# M1 — a declared action is authorized; an undeclared one is not.
|
|
11135
|
+
declared = root / "declared"
|
|
11136
|
+
declared.mkdir()
|
|
11137
|
+
write_authorize_config(
|
|
11138
|
+
declared,
|
|
11139
|
+
"fast_check: echo declared-check\n"
|
|
11140
|
+
"authorize:\n"
|
|
11141
|
+
" - commit\n"
|
|
11142
|
+
" - push\n",
|
|
11143
|
+
)
|
|
11144
|
+
out = run_keel(declared, "--doctor").stdout
|
|
11145
|
+
if "Standing authorization:" not in out:
|
|
11146
|
+
report("standing-authorization: doctor has no standing authorization surface.")
|
|
11147
|
+
report(out)
|
|
11148
|
+
return 1
|
|
11149
|
+
for needle in ("commit: authorized", "push: authorized"):
|
|
11150
|
+
if needle not in out:
|
|
11151
|
+
report(f"standing-authorization: declared action not reported: {needle}")
|
|
11152
|
+
report(out)
|
|
11153
|
+
return 1
|
|
11154
|
+
for needle in ("release: not authorized", "archive: not authorized"):
|
|
11155
|
+
if needle not in out:
|
|
11156
|
+
report(f"standing-authorization: undeclared action not reported: {needle}")
|
|
11157
|
+
report(out)
|
|
11158
|
+
return 1
|
|
11159
|
+
|
|
11160
|
+
# M2 — absent, blockless, and empty declarations all authorize nothing,
|
|
11161
|
+
# and none of them disturbs the fast_check surface that shares the file.
|
|
11162
|
+
absent = root / "absent"
|
|
11163
|
+
absent.mkdir()
|
|
11164
|
+
blockless = root / "blockless"
|
|
11165
|
+
blockless.mkdir()
|
|
11166
|
+
write_authorize_config(blockless, "fast_check: echo blockless-check\n")
|
|
11167
|
+
empty = root / "empty"
|
|
11168
|
+
empty.mkdir()
|
|
11169
|
+
write_authorize_config(
|
|
11170
|
+
empty, "fast_check: echo empty-check\nauthorize:\n"
|
|
11171
|
+
)
|
|
11172
|
+
for repo, label, fast in (
|
|
11173
|
+
(absent, "absent", None),
|
|
11174
|
+
(blockless, "blockless", "echo blockless-check"),
|
|
11175
|
+
(empty, "empty", "echo empty-check"),
|
|
11176
|
+
):
|
|
11177
|
+
out = run_keel(repo, "--doctor").stdout
|
|
11178
|
+
if "authorize: none" not in out:
|
|
11179
|
+
report(
|
|
11180
|
+
f"standing-authorization: {label} repo does not report an "
|
|
11181
|
+
"undeclared authorization surface."
|
|
11182
|
+
)
|
|
11183
|
+
report(out)
|
|
11184
|
+
return 1
|
|
11185
|
+
if "authorized" in out.replace("not authorized", ""):
|
|
11186
|
+
report(
|
|
11187
|
+
f"standing-authorization: {label} repo reports an authorized action."
|
|
11188
|
+
)
|
|
11189
|
+
report(out)
|
|
11190
|
+
return 1
|
|
11191
|
+
expected_fast = f"fast_check: ok - declared in keel/config.yaml: {fast}"
|
|
11192
|
+
if fast is not None and expected_fast not in out:
|
|
11193
|
+
report(
|
|
11194
|
+
f"standing-authorization: {label} repo lost its fast_check line."
|
|
11195
|
+
)
|
|
11196
|
+
report(out)
|
|
11197
|
+
return 1
|
|
11198
|
+
if fast is None and "fast_check: none" not in out:
|
|
11199
|
+
report("standing-authorization: absent repo lost its fast_check line.")
|
|
11200
|
+
report(out)
|
|
11201
|
+
return 1
|
|
11202
|
+
|
|
11203
|
+
# M3 — an unrecognized name is reported with the accepted set, exits
|
|
11204
|
+
# non-zero, and authorizes nothing that sits beside it.
|
|
11205
|
+
unknown = root / "unknown"
|
|
11206
|
+
unknown.mkdir()
|
|
11207
|
+
write_authorize_config(
|
|
11208
|
+
unknown,
|
|
11209
|
+
"authorize:\n - commit\n - deploy\n",
|
|
11210
|
+
)
|
|
11211
|
+
result = run_keel(unknown, "--doctor")
|
|
11212
|
+
combined = result.stdout + result.stderr
|
|
11213
|
+
if result.returncode == 0:
|
|
11214
|
+
report("standing-authorization: an unrecognized action name exited zero.")
|
|
11215
|
+
report(combined)
|
|
11216
|
+
return 1
|
|
11217
|
+
if "deploy" not in combined:
|
|
11218
|
+
report("standing-authorization: the error does not name the offending entry.")
|
|
11219
|
+
report(combined)
|
|
11220
|
+
return 1
|
|
11221
|
+
for action in STANDING_AUTHORIZATION_ACTIONS:
|
|
11222
|
+
if action not in combined:
|
|
11223
|
+
report(
|
|
11224
|
+
"standing-authorization: the error does not name accepted "
|
|
11225
|
+
f"action {action}."
|
|
11226
|
+
)
|
|
11227
|
+
report(combined)
|
|
11228
|
+
return 1
|
|
11229
|
+
if "commit: authorized" in combined:
|
|
11230
|
+
report(
|
|
11231
|
+
"standing-authorization: a rejected declaration still authorized "
|
|
11232
|
+
"the entry beside the bad one."
|
|
11233
|
+
)
|
|
11234
|
+
report(combined)
|
|
11235
|
+
return 1
|
|
11236
|
+
|
|
11237
|
+
report("standing-authorization-declaration scenario passed.")
|
|
11238
|
+
return 0
|
|
11239
|
+
|
|
11240
|
+
|
|
11241
|
+
def standing_authorization_task(boundary: str = "") -> str:
|
|
11242
|
+
return (
|
|
11243
|
+
"- [ ] 1.1 Behavior\n"
|
|
11244
|
+
" - Covers:\n"
|
|
11245
|
+
" - E1: public behavior\n"
|
|
11246
|
+
" - Touch:\n"
|
|
11247
|
+
" - src/feature.js\n"
|
|
11248
|
+
" - Verify:\n"
|
|
11249
|
+
" - Strategy: evidence-first\n"
|
|
11250
|
+
" - M1: node test.js proves the public behavior\n"
|
|
11251
|
+
+ boundary
|
|
11252
|
+
+ " - Evidence:\n"
|
|
11253
|
+
" - Contract: pending\n"
|
|
11254
|
+
" - M1: pending\n"
|
|
11255
|
+
" - Review:\n"
|
|
11256
|
+
" - Status: pending\n"
|
|
11257
|
+
" - Acceptance check: pending\n"
|
|
11258
|
+
" - Scope check: pending\n"
|
|
11259
|
+
" - Findings: pending\n"
|
|
11260
|
+
" - Blocker: none\n"
|
|
11261
|
+
)
|
|
11262
|
+
|
|
11263
|
+
|
|
11264
|
+
def standing_authorization_autonomy(repo: Path) -> list[str] | None:
|
|
11265
|
+
result = run_keel(
|
|
11266
|
+
repo,
|
|
11267
|
+
"gate",
|
|
11268
|
+
"task-start",
|
|
11269
|
+
"--change",
|
|
11270
|
+
"demo",
|
|
11271
|
+
"--task",
|
|
11272
|
+
"1.1",
|
|
11273
|
+
"--json",
|
|
11274
|
+
"--no-guard",
|
|
11275
|
+
)
|
|
11276
|
+
try:
|
|
11277
|
+
payload = json.loads(result.stdout)
|
|
11278
|
+
except json.JSONDecodeError:
|
|
11279
|
+
return None
|
|
11280
|
+
contract = payload.get("contract") or {}
|
|
11281
|
+
capsule = contract.get("capsule") or {}
|
|
11282
|
+
boundaries = capsule.get("boundaries") or {}
|
|
11283
|
+
return boundaries.get("autonomy")
|
|
11284
|
+
|
|
11285
|
+
|
|
11286
|
+
def validate_standing_authorization_inheritance_scenario() -> int:
|
|
11287
|
+
with tempfile.TemporaryDirectory(prefix="keel-authinherit-") as raw_tmp:
|
|
11288
|
+
root = Path(raw_tmp)
|
|
11289
|
+
|
|
11290
|
+
# M1 — a task that authored no boundary inherits the declaration, and
|
|
11291
|
+
# the capsule says where the authorization came from.
|
|
11292
|
+
inherits = root / "inherits"
|
|
11293
|
+
inherits.mkdir()
|
|
11294
|
+
write_gate_fixture(inherits, standing_authorization_task())
|
|
11295
|
+
write_authorize_config(inherits, "authorize:\n - commit\n")
|
|
11296
|
+
autonomy = standing_authorization_autonomy(inherits)
|
|
11297
|
+
if autonomy is None:
|
|
11298
|
+
report("standing-authorization-inheritance: task-start returned no capsule autonomy.")
|
|
11299
|
+
return 1
|
|
11300
|
+
inherited = [entry for entry in autonomy if "commit" in entry]
|
|
11301
|
+
if not inherited:
|
|
11302
|
+
report(
|
|
11303
|
+
"standing-authorization-inheritance: a declared action did not "
|
|
11304
|
+
f"reach the capsule autonomy boundary: {autonomy}"
|
|
11305
|
+
)
|
|
11306
|
+
return 1
|
|
11307
|
+
if not any("keel/config.yaml" in entry for entry in inherited):
|
|
11308
|
+
report(
|
|
11309
|
+
"standing-authorization-inheritance: the inherited entry does "
|
|
11310
|
+
f"not name the repository declaration as its source: {autonomy}"
|
|
11311
|
+
)
|
|
11312
|
+
return 1
|
|
11313
|
+
|
|
11314
|
+
# M2 — an authored boundary is returned unchanged, with nothing
|
|
11315
|
+
# inherited beside it.
|
|
11316
|
+
authored = root / "authored"
|
|
11317
|
+
authored.mkdir()
|
|
11318
|
+
write_gate_fixture(
|
|
11319
|
+
authored,
|
|
11320
|
+
standing_authorization_task(
|
|
11321
|
+
" - Autonomy boundary:\n"
|
|
11322
|
+
" - Default: hard-stop\n"
|
|
11323
|
+
" - Pre-authorized fallback: revert the fixture file and record M1\n"
|
|
11324
|
+
),
|
|
11325
|
+
)
|
|
11326
|
+
write_authorize_config(authored, "authorize:\n - commit\n - push\n")
|
|
11327
|
+
autonomy = standing_authorization_autonomy(authored)
|
|
11328
|
+
if autonomy is None:
|
|
11329
|
+
report("standing-authorization-inheritance: authored-boundary task did not compile.")
|
|
11330
|
+
return 1
|
|
11331
|
+
if "Pre-authorized fallback: revert the fixture file and record M1" not in autonomy:
|
|
11332
|
+
report(
|
|
11333
|
+
"standing-authorization-inheritance: the authored boundary was "
|
|
11334
|
+
f"not preserved: {autonomy}"
|
|
11335
|
+
)
|
|
11336
|
+
return 1
|
|
11337
|
+
if any("keel/config.yaml" in entry for entry in autonomy):
|
|
11338
|
+
report(
|
|
11339
|
+
"standing-authorization-inheritance: the declaration overrode an "
|
|
11340
|
+
f"authored boundary: {autonomy}"
|
|
11341
|
+
)
|
|
11342
|
+
return 1
|
|
11343
|
+
|
|
11344
|
+
# M3 — an action the declaration does not name still hard-stops.
|
|
11345
|
+
autonomy = standing_authorization_autonomy(inherits)
|
|
11346
|
+
if autonomy is None:
|
|
11347
|
+
report("standing-authorization-inheritance: re-compilation returned no autonomy.")
|
|
11348
|
+
return 1
|
|
11349
|
+
if any("push" in entry or "release" in entry for entry in autonomy):
|
|
11350
|
+
report(
|
|
11351
|
+
"standing-authorization-inheritance: an undeclared action was "
|
|
11352
|
+
f"authorized: {autonomy}"
|
|
11353
|
+
)
|
|
11354
|
+
return 1
|
|
11355
|
+
if not any(entry.startswith("Default: hard-stop") for entry in autonomy):
|
|
11356
|
+
report(
|
|
11357
|
+
"standing-authorization-inheritance: the hard-stop default "
|
|
11358
|
+
f"disappeared for undeclared actions: {autonomy}"
|
|
11359
|
+
)
|
|
11360
|
+
return 1
|
|
11361
|
+
|
|
11362
|
+
report("standing-authorization-inheritance scenario passed.")
|
|
11363
|
+
return 0
|
|
11364
|
+
|
|
11365
|
+
|
|
11366
|
+
def validate_standing_authorization_never_weakens_scenario() -> int:
|
|
11367
|
+
"""A declaration removes a confirmation. It must not remove a proof.
|
|
11368
|
+
|
|
11369
|
+
Every check here compares an authorizing repository against an identical
|
|
11370
|
+
one that declares nothing. The declaration is proven inert on the gate
|
|
11371
|
+
result, on the failure text, and on continuity selection — the three places
|
|
11372
|
+
a reader might otherwise assume authorization had bought something.
|
|
11373
|
+
"""
|
|
11374
|
+
|
|
11375
|
+
complete_task = (
|
|
11376
|
+
"- [ ] 1.1 Behavior\n"
|
|
11377
|
+
" - Covers:\n"
|
|
11378
|
+
" - E1: public behavior\n"
|
|
11379
|
+
" - Touch:\n"
|
|
11380
|
+
" - src/feature.js\n"
|
|
11381
|
+
" - Verify:\n"
|
|
11382
|
+
" - Strategy: evidence-first\n"
|
|
11383
|
+
" - M1: node test.js proves the public behavior\n"
|
|
11384
|
+
" - Evidence:\n"
|
|
11385
|
+
" - Contract: pending\n"
|
|
11386
|
+
" - M1: node test.js printed ok\n"
|
|
11387
|
+
" - Review:\n"
|
|
11388
|
+
" - Status: pass\n"
|
|
11389
|
+
" - Acceptance check: reviewed\n"
|
|
11390
|
+
" - Scope check: reviewed\n"
|
|
11391
|
+
" - Findings: none\n"
|
|
11392
|
+
" - Blocker: none\n"
|
|
11393
|
+
)
|
|
11394
|
+
missing_evidence_task = complete_task.replace(
|
|
11395
|
+
" - M1: node test.js printed ok\n", " - M1: pending\n"
|
|
11396
|
+
)
|
|
11397
|
+
|
|
11398
|
+
def gate_result(repo: Path, stage: str) -> dict | None:
|
|
11399
|
+
result = run_keel(
|
|
11400
|
+
repo, "gate", stage, "--change", "demo", "--task", "1.1", "--json"
|
|
11401
|
+
)
|
|
11402
|
+
try:
|
|
11403
|
+
payload = json.loads(result.stdout)
|
|
11404
|
+
except json.JSONDecodeError:
|
|
11405
|
+
return None
|
|
11406
|
+
return {
|
|
11407
|
+
"status": payload.get("status"),
|
|
11408
|
+
"problems": sorted(
|
|
11409
|
+
(problem.get("code", ""), problem.get("message", ""))
|
|
11410
|
+
for problem in payload.get("problems") or []
|
|
11411
|
+
),
|
|
11412
|
+
}
|
|
11413
|
+
|
|
11414
|
+
def pair(root: Path, name: str, tasks: str) -> tuple[Path, Path]:
|
|
11415
|
+
authorizing = root / f"{name}-authorizing"
|
|
11416
|
+
authorizing.mkdir()
|
|
11417
|
+
write_gate_fixture(authorizing, tasks)
|
|
11418
|
+
write_authorize_config(
|
|
11419
|
+
authorizing,
|
|
11420
|
+
"authorize:\n - commit\n - push\n - release\n - archive\n",
|
|
11421
|
+
)
|
|
11422
|
+
silent = root / f"{name}-silent"
|
|
11423
|
+
silent.mkdir()
|
|
11424
|
+
write_gate_fixture(silent, tasks)
|
|
11425
|
+
# Positive control. Every check below compares these two repositories
|
|
11426
|
+
# and passes when they agree, so a declaration that silently failed to
|
|
11427
|
+
# reach the capsule would make each comparison trivially true and prove
|
|
11428
|
+
# nothing. Assert the difference exists before asserting it is inert.
|
|
11429
|
+
live = standing_authorization_autonomy(authorizing) or []
|
|
11430
|
+
inert = standing_authorization_autonomy(silent) or []
|
|
11431
|
+
if not any("keel/config.yaml" in entry for entry in live):
|
|
11432
|
+
report(
|
|
11433
|
+
f"standing-authorization-inert: the {name} authorizing fixture "
|
|
11434
|
+
f"never actually authorized anything: {live}"
|
|
11435
|
+
)
|
|
11436
|
+
raise AssertionError("authorizing fixture is not authorizing")
|
|
11437
|
+
if any("keel/config.yaml" in entry for entry in inert):
|
|
11438
|
+
report(
|
|
11439
|
+
f"standing-authorization-inert: the {name} silent fixture "
|
|
11440
|
+
f"declared something: {inert}"
|
|
11441
|
+
)
|
|
11442
|
+
raise AssertionError("silent fixture is not silent")
|
|
11443
|
+
return authorizing, silent
|
|
11444
|
+
|
|
11445
|
+
with tempfile.TemporaryDirectory(prefix="keel-authinert-") as raw_tmp:
|
|
11446
|
+
root = Path(raw_tmp)
|
|
11447
|
+
|
|
11448
|
+
# M1 — completion returns the same status and problem set either way.
|
|
11449
|
+
authorizing, silent = pair(root, "complete", complete_task)
|
|
11450
|
+
for repo in (authorizing, silent):
|
|
11451
|
+
if gate_result(repo, "task-start") is None:
|
|
11452
|
+
report("standing-authorization-inert: task-start produced no JSON.")
|
|
11453
|
+
return 1
|
|
11454
|
+
authorized_result = gate_result(authorizing, "task-complete")
|
|
11455
|
+
silent_result = gate_result(silent, "task-complete")
|
|
11456
|
+
if authorized_result is None or silent_result is None:
|
|
11457
|
+
report("standing-authorization-inert: task-complete produced no JSON.")
|
|
11458
|
+
return 1
|
|
11459
|
+
if authorized_result != silent_result:
|
|
11460
|
+
report(
|
|
11461
|
+
"standing-authorization-inert: a declaration changed the "
|
|
11462
|
+
f"completion gate result: {authorized_result} != {silent_result}"
|
|
11463
|
+
)
|
|
11464
|
+
return 1
|
|
11465
|
+
|
|
11466
|
+
# M2 — a repo authorizing every action still fails for missing evidence,
|
|
11467
|
+
# with unchanged failure text.
|
|
11468
|
+
authorizing, silent = pair(root, "missing", missing_evidence_task)
|
|
11469
|
+
for repo in (authorizing, silent):
|
|
11470
|
+
if gate_result(repo, "task-start") is None:
|
|
11471
|
+
report("standing-authorization-inert: task-start produced no JSON.")
|
|
11472
|
+
return 1
|
|
11473
|
+
authorized_result = gate_result(authorizing, "task-complete")
|
|
11474
|
+
silent_result = gate_result(silent, "task-complete")
|
|
11475
|
+
if authorized_result is None or silent_result is None:
|
|
11476
|
+
report("standing-authorization-inert: task-complete produced no JSON.")
|
|
11477
|
+
return 1
|
|
11478
|
+
if authorized_result.get("status") == "pass":
|
|
11479
|
+
report(
|
|
11480
|
+
"standing-authorization-inert: authorizing every action let a "
|
|
11481
|
+
"task with missing evidence pass completion."
|
|
11482
|
+
)
|
|
11483
|
+
return 1
|
|
11484
|
+
if authorized_result != silent_result:
|
|
11485
|
+
report(
|
|
11486
|
+
"standing-authorization-inert: a declaration changed the failure "
|
|
11487
|
+
f"text: {authorized_result} != {silent_result}"
|
|
11488
|
+
)
|
|
11489
|
+
return 1
|
|
11490
|
+
|
|
11491
|
+
# M3 — a declaration selects nothing and starts nothing.
|
|
11492
|
+
def continuity(repo: Path) -> dict | None:
|
|
11493
|
+
result = run_keel(repo, "context", "--json")
|
|
11494
|
+
try:
|
|
11495
|
+
payload = json.loads(result.stdout)
|
|
11496
|
+
except json.JSONDecodeError:
|
|
11497
|
+
return None
|
|
11498
|
+
return {
|
|
11499
|
+
"status": payload.get("status"),
|
|
11500
|
+
"selection": payload.get("selection"),
|
|
11501
|
+
"nextAction": payload.get("nextAction"),
|
|
11502
|
+
}
|
|
11503
|
+
|
|
11504
|
+
authorizing, silent = pair(root, "context", complete_task)
|
|
11505
|
+
authorized_context = continuity(authorizing)
|
|
11506
|
+
silent_context = continuity(silent)
|
|
11507
|
+
if authorized_context is None or silent_context is None:
|
|
11508
|
+
report("standing-authorization-inert: keel context produced no JSON.")
|
|
11509
|
+
return 1
|
|
11510
|
+
if authorized_context != silent_context:
|
|
11511
|
+
report(
|
|
11512
|
+
"standing-authorization-inert: a declaration changed continuity "
|
|
11513
|
+
f"selection: {authorized_context} != {silent_context}"
|
|
11514
|
+
)
|
|
11515
|
+
return 1
|
|
11516
|
+
|
|
11517
|
+
report("standing-authorization-never-weakens scenario passed.")
|
|
11518
|
+
return 0
|
|
11519
|
+
|
|
11520
|
+
|
|
10891
11521
|
def validate_fast_check_config_scaffold_scenario() -> int:
|
|
10892
11522
|
with tempfile.TemporaryDirectory(prefix="keel-fastcfg-") as raw_tmp:
|
|
10893
11523
|
repo = Path(raw_tmp)
|
|
@@ -14268,6 +14898,18 @@ SCENARIOS: tuple = (
|
|
|
14268
14898
|
("update-pack-install", validate_update_pack_install_scenario),
|
|
14269
14899
|
("update-default-registry", validate_update_default_registry_scenario),
|
|
14270
14900
|
("verification-layering-docs", validate_verification_layering_docs_scenario),
|
|
14901
|
+
(
|
|
14902
|
+
"standing-authorization-declaration",
|
|
14903
|
+
validate_standing_authorization_declaration_scenario,
|
|
14904
|
+
),
|
|
14905
|
+
(
|
|
14906
|
+
"standing-authorization-inheritance",
|
|
14907
|
+
validate_standing_authorization_inheritance_scenario,
|
|
14908
|
+
),
|
|
14909
|
+
(
|
|
14910
|
+
"standing-authorization-never-weakens",
|
|
14911
|
+
validate_standing_authorization_never_weakens_scenario,
|
|
14912
|
+
),
|
|
14271
14913
|
("fast-check-config-scaffold", validate_fast_check_config_scaffold_scenario),
|
|
14272
14914
|
("fast-pre-push-hooks", validate_fast_pre_push_hooks_scenario),
|
|
14273
14915
|
("fast-pre-push-doctor", validate_fast_pre_push_doctor_scenario),
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
|
|
6
|
+
// The closed vocabulary of actions a repository may standing-authorize. It is
|
|
7
|
+
// closed so an entry outside it can be reported by name: a free-form grant
|
|
8
|
+
// cannot tell a typo from a decision, and silently dropping one leaves the
|
|
9
|
+
// author believing they authorized something they did not.
|
|
10
|
+
const STANDING_AUTHORIZATION_ACTIONS = ["commit", "push", "release", "archive"];
|
|
11
|
+
|
|
12
|
+
const CONFIG_RELATIVE_PATH = path.join("keel", "config.yaml");
|
|
13
|
+
|
|
14
|
+
// The declaration shares keel/config.yaml with fast_check, so the reader stays
|
|
15
|
+
// line-oriented rather than pulling in a YAML dependency for a format Keel
|
|
16
|
+
// controls and keeps flat on purpose.
|
|
17
|
+
function readStandingAuthorization(repo) {
|
|
18
|
+
const configPath = path.join(repo, "keel", "config.yaml");
|
|
19
|
+
const declared = [];
|
|
20
|
+
const unknown = [];
|
|
21
|
+
if (!fs.existsSync(configPath)) return { declared, unknown };
|
|
22
|
+
let inBlock = false;
|
|
23
|
+
for (const line of fs.readFileSync(configPath, "utf8").split(/\r?\n/)) {
|
|
24
|
+
if (/^\s*#/.test(line)) continue;
|
|
25
|
+
if (/^authorize\s*:\s*$/.test(line)) {
|
|
26
|
+
inBlock = true;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (!inBlock) continue;
|
|
30
|
+
if (line.trim() === "") continue;
|
|
31
|
+
const entry = line.match(/^\s+-\s*(\S+)\s*$/);
|
|
32
|
+
// Anything that is not a list item closes the block; the next top-level
|
|
33
|
+
// key belongs to the rest of the file.
|
|
34
|
+
if (!entry) break;
|
|
35
|
+
if (STANDING_AUTHORIZATION_ACTIONS.includes(entry[1])) declared.push(entry[1]);
|
|
36
|
+
else unknown.push(entry[1]);
|
|
37
|
+
}
|
|
38
|
+
// Fail closed. A declaration Keel cannot fully read authorizes nothing,
|
|
39
|
+
// because the alternative is granting the entries beside a typo while the
|
|
40
|
+
// author believes they granted the typo too.
|
|
41
|
+
if (unknown.length > 0) return { declared: [], unknown };
|
|
42
|
+
return { declared, unknown };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = {
|
|
46
|
+
CONFIG_RELATIVE_PATH,
|
|
47
|
+
STANDING_AUTHORIZATION_ACTIONS,
|
|
48
|
+
readStandingAuthorization,
|
|
49
|
+
};
|
|
@@ -4,6 +4,11 @@ const crypto = require("crypto");
|
|
|
4
4
|
const fs = require("fs");
|
|
5
5
|
const path = require("path");
|
|
6
6
|
|
|
7
|
+
const {
|
|
8
|
+
CONFIG_RELATIVE_PATH,
|
|
9
|
+
readStandingAuthorization,
|
|
10
|
+
} = require("./config");
|
|
11
|
+
|
|
7
12
|
const SUPPORTED_MODES = new Set([
|
|
8
13
|
"implementation",
|
|
9
14
|
"diagnose-only",
|
|
@@ -844,6 +849,20 @@ function compileTaskContract(repo, change, task) {
|
|
|
844
849
|
if (!autonomy.some((item) => /^Default:/i.test(item))) {
|
|
845
850
|
autonomy.unshift("Default: hard-stop");
|
|
846
851
|
}
|
|
852
|
+
// A repository declaration supplies the default a task did not author; it
|
|
853
|
+
// never edits one the task did, because a repository-wide default that could
|
|
854
|
+
// override a task's stated boundary would make the capsule unreadable on its
|
|
855
|
+
// own. The entry names its source so an inherited authorization is never
|
|
856
|
+
// mistaken for one this task decided.
|
|
857
|
+
if (explicitAutonomy.length === 0) {
|
|
858
|
+
const { declared } = readStandingAuthorization(repo);
|
|
859
|
+
if (declared.length > 0) {
|
|
860
|
+
autonomy.push(
|
|
861
|
+
`Standing authorization (${CONFIG_RELATIVE_PATH.split(path.sep).join("/")}): `
|
|
862
|
+
+ declared.join(", ")
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
847
866
|
if (!autonomy.some((item) => /^Pre-authorized fallback:/i.test(item))) {
|
|
848
867
|
autonomy.push("Pre-authorized fallback: none");
|
|
849
868
|
}
|