@christang/keel 5.4.0 → 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 CHANGED
@@ -125,6 +125,36 @@ Set `KEEL_SESSION_PANEL=1` to draw it as a framed panel with the Keel mark inste
125
125
  default, and turning it on changes nothing but the presentation — the same status and the same
126
126
  next command are in both forms.
127
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
+
128
158
  ### Full vs Lite
129
159
 
130
160
  Use **Full mode** (the OpenSpec flow above) for new features, interface or protocol changes,
@@ -160,7 +190,8 @@ Keel splits verification into two layers so a slow suite never blocks your push:
160
190
  run at CI or at `keel gate change-close`.
161
191
 
162
192
  A task's `Verify` checks stay fast; the slow or exhaustive layer belongs to the full gate, not the
163
- local pre-push. Declare your fast check once in `keel/config.yaml`:
193
+ local pre-push. Declare your fast check in `keel/config.yaml`, the same file that holds your
194
+ standing authorization:
164
195
 
165
196
  ```yaml
166
197
  fast_check: npm test -- --fast # your project's seconds-scale check
@@ -1,4 +1,4 @@
1
- <!-- keel:start version=5.4.0 -->
1
+ <!-- keel:start version=5.5.0 -->
2
2
  ## Keel Bootstrap
3
3
 
4
4
  - Start every session with `keel context`; OpenSpec artifacts and Git are the only durable authority — never native memory, goals, or transcripts.
@@ -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. A pre-authorized fallback must
130
- state an exact reversible bound and the evidence required after use.
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, autonomy defaults to hard-stop,
6
- Coupling defaults to none, helpers stay read-only/evidence-only, and
7
- commit, push, sync, archive, and cross-task continuation stay
8
- unauthorized. Declare a field only when it differs from these defaults. -->
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
@@ -2,7 +2,7 @@
2
2
  "name": "@christang/keel",
3
3
  "displayName": "Keel",
4
4
  "description": "Keel OpenSpec execution discipline CLI for Claude Code, Codex, and OpenCode.",
5
- "version": "5.4.0",
5
+ "version": "5.5.0",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keel",
3
- "version": "5.4.0",
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.4.0",
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",
@@ -37,8 +37,8 @@ REQUIRED_SCRIPTS = [
37
37
  "scripts/validate_plugin.py",
38
38
  ]
39
39
 
40
- PACKAGE_VERSION = "5.4.0"
41
- PROTOCOL_VERSION = "5.4.0"
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:
@@ -11111,6 +11119,405 @@ def validate_verification_layering_docs_scenario() -> int:
11111
11119
  return 0
11112
11120
 
11113
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
+
11114
11521
  def validate_fast_check_config_scaffold_scenario() -> int:
11115
11522
  with tempfile.TemporaryDirectory(prefix="keel-fastcfg-") as raw_tmp:
11116
11523
  repo = Path(raw_tmp)
@@ -14491,6 +14898,18 @@ SCENARIOS: tuple = (
14491
14898
  ("update-pack-install", validate_update_pack_install_scenario),
14492
14899
  ("update-default-registry", validate_update_default_registry_scenario),
14493
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
+ ),
14494
14913
  ("fast-check-config-scaffold", validate_fast_check_config_scaffold_scenario),
14495
14914
  ("fast-pre-push-hooks", validate_fast_pre_push_hooks_scenario),
14496
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
  }