@christang/keel 5.2.3 → 5.3.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/assets/bootstrap/AGENTS.md +1 -1
- package/assets/openspec/schemas/keel-spec-driven/templates/tasks.md +5 -1
- package/bin/keel.js +18 -0
- 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 +54 -5
- package/scripts/install_to_repo.py +17 -0
- package/scripts/validate_plugin.py +747 -21
- package/src/core/gates.js +20 -6
- package/src/core/guard.js +14 -0
- package/src/core/task-contract.js +19 -5
|
@@ -40,7 +40,11 @@
|
|
|
40
40
|
- Blocker: none
|
|
41
41
|
|
|
42
42
|
<!-- Exceptional boundaries are declared only when they differ from defaults:
|
|
43
|
-
- Mode: diagnose-only (with `Touch: none`) or plan-first
|
|
43
|
+
- Mode: diagnose-only or repo-action (both with `Touch: none`), or plan-first.
|
|
44
|
+
repo-action is for a task whose whole effect is an authorized
|
|
45
|
+
repository-level action — a commit, a tag — and which writes no worktree
|
|
46
|
+
file; it is the one mode that may commit, and it still may not push,
|
|
47
|
+
sync, archive, or mark tasks complete
|
|
44
48
|
- Read: additional required starting context beyond the base set
|
|
45
49
|
- Acceptance: a task-specific observable delta the Covers authority does not express
|
|
46
50
|
- Execution recommendation / Rationale: advisory notes for the current agent
|
package/bin/keel.js
CHANGED
|
@@ -1344,10 +1344,28 @@ function runDoctor(options) {
|
|
|
1344
1344
|
printTargetSurface(repo, options.target);
|
|
1345
1345
|
printLensSurface(repo, options.target);
|
|
1346
1346
|
printFastPrePushSurface(repo);
|
|
1347
|
+
printSourceRepoCliResolution(repo);
|
|
1347
1348
|
|
|
1348
1349
|
return checkStatus;
|
|
1349
1350
|
}
|
|
1350
1351
|
|
|
1352
|
+
// Only meaningful in Keel's own repository: a bare `keel` resolves to the
|
|
1353
|
+
// installed package, so gate commands verify the released CLI rather than the
|
|
1354
|
+
// working tree. The failure mode is a silently stale result, not an error, so
|
|
1355
|
+
// it is worth stating rather than leaving to be discovered.
|
|
1356
|
+
function printSourceRepoCliResolution(repo) {
|
|
1357
|
+
if (!isKeelSourceRepo(repo)) return;
|
|
1358
|
+
process.stdout.write("\nKeel source repository:\n");
|
|
1359
|
+
printDoctorLine(
|
|
1360
|
+
"gate CLI resolution",
|
|
1361
|
+
"advisory",
|
|
1362
|
+
"a bare `keel` runs the installed package, so gate results will not "
|
|
1363
|
+
+ "reflect uncommitted changes to gate, contract, or capability code; "
|
|
1364
|
+
+ "run `node bin/keel.js gate <stage> . --change <c> --task <t>` "
|
|
1365
|
+
+ "instead — the repository argument is required"
|
|
1366
|
+
);
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1351
1369
|
function readFastCheck(repo) {
|
|
1352
1370
|
const configPath = path.join(repo, "keel", "config.yaml");
|
|
1353
1371
|
if (!fs.existsSync(configPath)) return null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.3.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.3.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",
|
|
@@ -84,6 +84,29 @@ function pathAllowed(candidate, touch) {
|
|
|
84
84
|
});
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
// A checked task keeps its records writable so it can finish the Evidence its
|
|
88
|
+
// completion gate requires, but earns no further product authorization. Byte
|
|
89
|
+
// hashing used to enforce this by accident, since ticking the box changed
|
|
90
|
+
// tasks.md; now it is stated. An unreadable or unmatched tasks.md is not read as
|
|
91
|
+
// checked — every gate that compiles the capsule catches that, and denying
|
|
92
|
+
// product writes on a parse miss would trade a real capability for a guess.
|
|
93
|
+
function taskIsChecked(repo, manifest) {
|
|
94
|
+
const file = path.join(
|
|
95
|
+
repo, "openspec", "changes", manifest.change, "tasks.md"
|
|
96
|
+
);
|
|
97
|
+
let content = "";
|
|
98
|
+
try {
|
|
99
|
+
content = fs.readFileSync(file, "utf8");
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
const id = manifest.task.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
104
|
+
const match = content.match(
|
|
105
|
+
new RegExp(`^\\s*-\\s*\\[([ xX])\\]\\s*${id}(?![0-9.])`, "m")
|
|
106
|
+
);
|
|
107
|
+
return Boolean(match && match[1].toLowerCase() === "x");
|
|
108
|
+
}
|
|
109
|
+
|
|
87
110
|
function main() {
|
|
88
111
|
let event = {};
|
|
89
112
|
try {
|
|
@@ -114,8 +137,30 @@ function main() {
|
|
|
114
137
|
return 0;
|
|
115
138
|
}
|
|
116
139
|
const pointer = `${manifest.change}#${manifest.task}`;
|
|
140
|
+
// The record layer: the guarded change's own directory holds the records the
|
|
141
|
+
// task produces — its checkbox, Evidence, and Review — not the product it
|
|
142
|
+
// changes. `keel gate task-complete` already refuses to attribute this
|
|
143
|
+
// directory as an outside-Touch failure, so denying it here made the guard
|
|
144
|
+
// stop the one thing the completion gate is waiting for. Derived from the
|
|
145
|
+
// manifest's existing `change` field, so no manifest shape changes.
|
|
146
|
+
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
|
+
if (candidate.startsWith(recordPrefix)) return 0;
|
|
117
156
|
|
|
118
157
|
for (const entry of manifest.authority) {
|
|
158
|
+
// Same boundary: bytes under the guarded change's own directory are
|
|
159
|
+
// records, and the capsule fingerprint — which carries no checkbox state
|
|
160
|
+
// and no Evidence values — is what separates a record write from a
|
|
161
|
+
// contract change. `keel guard status` and `keel gate task-complete`
|
|
162
|
+
// compile the capsule and compare it; this hook cannot, and must not guess.
|
|
163
|
+
if (entry.path.replace(/\\/g, "/").startsWith(recordPrefix)) continue;
|
|
119
164
|
const file = path.join(repo, entry.path);
|
|
120
165
|
let fresh = null;
|
|
121
166
|
try {
|
|
@@ -134,13 +179,17 @@ function main() {
|
|
|
134
179
|
}
|
|
135
180
|
}
|
|
136
181
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
182
|
+
if (taskIsChecked(repo, manifest)) {
|
|
183
|
+
deny(
|
|
184
|
+
`Keel write guard: ${pointer} is checked complete, so it authorizes no `
|
|
185
|
+
+ `further product writes — ${candidate} is denied. Its own `
|
|
186
|
+
+ `${recordPrefix} records stay writable so the task can finish its `
|
|
187
|
+
+ "Evidence. Run `keel guard clear`, then authorize the next task "
|
|
188
|
+
+ "explicitly with `keel gate task-start`."
|
|
189
|
+
);
|
|
141
190
|
return 0;
|
|
142
191
|
}
|
|
143
|
-
|
|
192
|
+
|
|
144
193
|
if (pathAllowed(candidate, manifest.touch)) return 0;
|
|
145
194
|
|
|
146
195
|
deny(
|
|
@@ -33,6 +33,14 @@ KEEL_CONFIG_TEMPLATE = (
|
|
|
33
33
|
"# Example:\n"
|
|
34
34
|
"# fast_check: npm test -- --fast\n"
|
|
35
35
|
)
|
|
36
|
+
KEEL_GITIGNORE_PATH = KEEL_ROOT / ".gitignore"
|
|
37
|
+
KEEL_GITIGNORE_TEMPLATE = (
|
|
38
|
+
"# Keel local state. keel/ otherwise holds project content that belongs in\n"
|
|
39
|
+
"# version control (config.yaml, CHANGELOG.md, archive/); the write-guard\n"
|
|
40
|
+
"# manifest is per-clone session state that a gate run writes and\n"
|
|
41
|
+
"# `keel guard clear` removes.\n"
|
|
42
|
+
"guard.json\n"
|
|
43
|
+
)
|
|
36
44
|
GITHOOKS_DIR = ".githooks"
|
|
37
45
|
PRE_PUSH_PATH = Path(GITHOOKS_DIR) / "pre-push"
|
|
38
46
|
OPENSPEC_ROOT = Path("openspec")
|
|
@@ -222,6 +230,14 @@ def keel_config_action() -> InstallAction:
|
|
|
222
230
|
)
|
|
223
231
|
|
|
224
232
|
|
|
233
|
+
def keel_gitignore_action() -> InstallAction:
|
|
234
|
+
return InstallAction(
|
|
235
|
+
relative_path=KEEL_GITIGNORE_PATH,
|
|
236
|
+
content=KEEL_GITIGNORE_TEMPLATE,
|
|
237
|
+
strategy="keel-config-scaffold",
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
225
241
|
def read_fast_check(repo: Path) -> str | None:
|
|
226
242
|
"""Return the project's declared fast inner-loop command, or None.
|
|
227
243
|
|
|
@@ -472,6 +488,7 @@ def collect_actions(repo: Path, target: str) -> list[InstallAction]:
|
|
|
472
488
|
|
|
473
489
|
actions.append(openspec_config_action())
|
|
474
490
|
actions.append(keel_config_action())
|
|
491
|
+
actions.append(keel_gitignore_action())
|
|
475
492
|
actions.extend(openspec_schema_actions())
|
|
476
493
|
return actions
|
|
477
494
|
|
|
@@ -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.3.0"
|
|
41
|
+
PROTOCOL_VERSION = "5.3.0"
|
|
42
42
|
LEGACY_MANAGED_START = "<!-- keel:start version=2.1 -->"
|
|
43
43
|
OPENSPEC_SCHEMA_NAME = "keel-spec-driven"
|
|
44
44
|
OPENSPEC_CONFIG_PATH = Path("openspec/config.yaml")
|
|
@@ -4227,6 +4227,410 @@ def validate_source_repo_bootstrap_skip_scenario() -> int:
|
|
|
4227
4227
|
return 0
|
|
4228
4228
|
|
|
4229
4229
|
|
|
4230
|
+
TRACKER_OWNER = "https://github.com/TanglmChris/keel/issues/12"
|
|
4231
|
+
|
|
4232
|
+
|
|
4233
|
+
def tracker_owner_tasks(findings: str, closure: str) -> str:
|
|
4234
|
+
"""One complete, checked task plus one Expectation Coverage closure line."""
|
|
4235
|
+
return (
|
|
4236
|
+
"# Tasks\n\n"
|
|
4237
|
+
"## Expectation Coverage\n\n"
|
|
4238
|
+
"- E1:\n"
|
|
4239
|
+
f" - {closure}\n\n"
|
|
4240
|
+
"## 1. Work\n\n"
|
|
4241
|
+
"- [x] 1.1 Complete behavior\n"
|
|
4242
|
+
" - Owner: keel-agent\n"
|
|
4243
|
+
" - Mode: implementation\n"
|
|
4244
|
+
" - Covers:\n"
|
|
4245
|
+
" - E1: public behavior\n"
|
|
4246
|
+
" - Read:\n"
|
|
4247
|
+
" - README.md\n"
|
|
4248
|
+
" - Touch:\n"
|
|
4249
|
+
" - src/feature.js\n"
|
|
4250
|
+
" - Commands:\n"
|
|
4251
|
+
" - M1: node test.js\n"
|
|
4252
|
+
" - Acceptance:\n"
|
|
4253
|
+
" - Public behavior passes.\n"
|
|
4254
|
+
" - Autonomy boundary:\n"
|
|
4255
|
+
" - Default: hard-stop\n"
|
|
4256
|
+
" - Pre-authorized fallback: none\n"
|
|
4257
|
+
" - Coupling: none\n"
|
|
4258
|
+
" - Candidate Boundary:\n"
|
|
4259
|
+
" - One candidate.\n"
|
|
4260
|
+
" - Stop Rules:\n"
|
|
4261
|
+
" - Stop on failure.\n"
|
|
4262
|
+
" - Evidence:\n"
|
|
4263
|
+
" - M1: passed\n"
|
|
4264
|
+
" - Review:\n"
|
|
4265
|
+
" - Status: pass\n"
|
|
4266
|
+
" - Acceptance check: reviewed\n"
|
|
4267
|
+
" - Scope check: reviewed\n"
|
|
4268
|
+
f" - Findings: {findings}\n"
|
|
4269
|
+
" - Blocker: none\n"
|
|
4270
|
+
" - Stop if:\n"
|
|
4271
|
+
" - Scope expands.\n"
|
|
4272
|
+
" - Report:\n"
|
|
4273
|
+
" - Summary\n"
|
|
4274
|
+
)
|
|
4275
|
+
|
|
4276
|
+
|
|
4277
|
+
def validate_tracker_durable_owner_scenario() -> int:
|
|
4278
|
+
"""Issue #12: an issue tracker is a durable follow-up owner.
|
|
4279
|
+
|
|
4280
|
+
The gate enumerated three repository-local forms, so a project whose
|
|
4281
|
+
declared follow-up owner is an issue tracker had to write a local note per
|
|
4282
|
+
finding whose only job was to be a shape the gate recognized. The tracker
|
|
4283
|
+
form is now accepted in both places a durable owner is required, and every
|
|
4284
|
+
previously accepted form still passes.
|
|
4285
|
+
"""
|
|
4286
|
+
with tempfile.TemporaryDirectory(prefix="keel-tracker-owner-") as raw:
|
|
4287
|
+
repo = Path(raw)
|
|
4288
|
+
tasks = repo / "openspec/changes/demo/tasks.md"
|
|
4289
|
+
write_text(repo / "openspec/changes/demo/proposal.md", "# Proposal\n")
|
|
4290
|
+
write_text(
|
|
4291
|
+
repo / "openspec/changes/demo/specs/demo/spec.md",
|
|
4292
|
+
"## ADDED Requirements\n",
|
|
4293
|
+
)
|
|
4294
|
+
|
|
4295
|
+
def complete(findings: str, closure: str = "Covered by: 1.1"):
|
|
4296
|
+
write_text(tasks, tracker_owner_tasks(findings, closure))
|
|
4297
|
+
return run_keel(
|
|
4298
|
+
repo, "gate", "task-complete",
|
|
4299
|
+
"--change", "demo", "--task", "1.1", "--json",
|
|
4300
|
+
)
|
|
4301
|
+
|
|
4302
|
+
def close(closure: str):
|
|
4303
|
+
write_text(tasks, tracker_owner_tasks("none", closure))
|
|
4304
|
+
return run_keel(
|
|
4305
|
+
repo, "gate", "change-close",
|
|
4306
|
+
"--change", "demo", "--action", "sync", "--json",
|
|
4307
|
+
)
|
|
4308
|
+
|
|
4309
|
+
tracker = complete(f"stale local note; owner {TRACKER_OWNER}")
|
|
4310
|
+
if tracker.returncode != 0:
|
|
4311
|
+
report(
|
|
4312
|
+
"tracker-durable-owner: a finding owned by an absolute tracker "
|
|
4313
|
+
"reference was still refused."
|
|
4314
|
+
)
|
|
4315
|
+
report((tracker.stderr or tracker.stdout).strip())
|
|
4316
|
+
return 1
|
|
4317
|
+
|
|
4318
|
+
unowned = complete("stale local note that names no owner at all")
|
|
4319
|
+
unowned_payload = json.loads(unowned.stdout) if unowned.stdout else {}
|
|
4320
|
+
message = " ".join(
|
|
4321
|
+
problem.get("message", "")
|
|
4322
|
+
for problem in unowned_payload.get("problems", [])
|
|
4323
|
+
if problem.get("code") == "finding-owner"
|
|
4324
|
+
)
|
|
4325
|
+
if unowned.returncode != 3 or "http" not in message:
|
|
4326
|
+
report(
|
|
4327
|
+
"tracker-durable-owner: an unowned finding must still fail, and "
|
|
4328
|
+
"the error must list the tracker form among the accepted ones."
|
|
4329
|
+
)
|
|
4330
|
+
report((unowned.stderr or unowned.stdout).strip())
|
|
4331
|
+
return 1
|
|
4332
|
+
|
|
4333
|
+
handoff = complete("stale local note; owner keel/HANDOFF.md")
|
|
4334
|
+
if handoff.returncode != 3:
|
|
4335
|
+
report(
|
|
4336
|
+
"tracker-durable-owner: keel/HANDOFF.md must still be refused "
|
|
4337
|
+
"as a finding owner."
|
|
4338
|
+
)
|
|
4339
|
+
report((handoff.stderr or handoff.stdout).strip())
|
|
4340
|
+
return 1
|
|
4341
|
+
|
|
4342
|
+
archived = complete("stale local note; owner keel/archive/follow-ups/x.md")
|
|
4343
|
+
if archived.returncode != 0:
|
|
4344
|
+
report(
|
|
4345
|
+
"tracker-durable-owner: the pre-existing keel/archive owner form "
|
|
4346
|
+
"regressed."
|
|
4347
|
+
)
|
|
4348
|
+
report((archived.stderr or archived.stdout).strip())
|
|
4349
|
+
return 1
|
|
4350
|
+
|
|
4351
|
+
closed_by_tracker = close(f"Durable owner: {TRACKER_OWNER}")
|
|
4352
|
+
if closed_by_tracker.returncode != 0:
|
|
4353
|
+
report(
|
|
4354
|
+
"tracker-durable-owner: change-close refused an Expectation "
|
|
4355
|
+
"Coverage durable owner naming a tracker reference."
|
|
4356
|
+
)
|
|
4357
|
+
report((closed_by_tracker.stderr or closed_by_tracker.stdout).strip())
|
|
4358
|
+
return 1
|
|
4359
|
+
|
|
4360
|
+
closed_by_task = close("Covered by: 1.1")
|
|
4361
|
+
if closed_by_task.returncode != 0:
|
|
4362
|
+
report(
|
|
4363
|
+
"tracker-durable-owner: the pre-existing Covered by closure "
|
|
4364
|
+
"regressed at change-close."
|
|
4365
|
+
)
|
|
4366
|
+
report((closed_by_task.stderr or closed_by_task.stdout).strip())
|
|
4367
|
+
return 1
|
|
4368
|
+
|
|
4369
|
+
closed_unowned = close("pending")
|
|
4370
|
+
if closed_unowned.returncode != 3:
|
|
4371
|
+
report(
|
|
4372
|
+
"tracker-durable-owner: change-close must still reject an E1 "
|
|
4373
|
+
"with no coverage, owner, or discard reason."
|
|
4374
|
+
)
|
|
4375
|
+
report((closed_unowned.stderr or closed_unowned.stdout).strip())
|
|
4376
|
+
return 1
|
|
4377
|
+
|
|
4378
|
+
if "tracker-durable-owner" not in {name for name, _ in SCENARIOS}:
|
|
4379
|
+
report("tracker-durable-owner: the scenario registry does not include it.")
|
|
4380
|
+
return 1
|
|
4381
|
+
report("tracker-durable-owner scenario passed.")
|
|
4382
|
+
return 0
|
|
4383
|
+
|
|
4384
|
+
|
|
4385
|
+
def validate_guard_manifest_ignored_scenario() -> int:
|
|
4386
|
+
"""Issue #11: the guard manifest was written but declared ignorable nowhere.
|
|
4387
|
+
|
|
4388
|
+
Every gate run left an untracked `keel/guard.json` in the project, and
|
|
4389
|
+
because completion attributes working-tree paths against Touch, that is a
|
|
4390
|
+
permanent exception the author re-adjudicates at every completion.
|
|
4391
|
+
"""
|
|
4392
|
+
with tempfile.TemporaryDirectory(prefix="keel-guard-ignore-") as raw:
|
|
4393
|
+
project = Path(raw)
|
|
4394
|
+
init = run_keel(project, "--install", "--target", "claude")
|
|
4395
|
+
if init.returncode != 0:
|
|
4396
|
+
report("guard-manifest-ignored: keel --install failed.")
|
|
4397
|
+
report((init.stderr or init.stdout).strip())
|
|
4398
|
+
return 1
|
|
4399
|
+
ignore_path = project / "keel/.gitignore"
|
|
4400
|
+
if not ignore_path.is_file():
|
|
4401
|
+
report(
|
|
4402
|
+
"guard-manifest-ignored: keel --install did not scaffold "
|
|
4403
|
+
"keel/.gitignore."
|
|
4404
|
+
)
|
|
4405
|
+
return 1
|
|
4406
|
+
declared = ignore_path.read_text(encoding="utf-8")
|
|
4407
|
+
if "guard.json" not in declared:
|
|
4408
|
+
report(
|
|
4409
|
+
"guard-manifest-ignored: the scaffolded keel/.gitignore does "
|
|
4410
|
+
"not declare the guard manifest."
|
|
4411
|
+
)
|
|
4412
|
+
report(declared)
|
|
4413
|
+
return 1
|
|
4414
|
+
|
|
4415
|
+
# git must actually honour the declaration: initialize a repository,
|
|
4416
|
+
# write a manifest through a passing task-start, and confirm the path
|
|
4417
|
+
# never appears in porcelain status.
|
|
4418
|
+
if subprocess.run(
|
|
4419
|
+
["git", "init", "--quiet"], cwd=project, capture_output=True
|
|
4420
|
+
).returncode != 0:
|
|
4421
|
+
report("guard-manifest-ignored: git init failed in the fixture.")
|
|
4422
|
+
return 1
|
|
4423
|
+
write_text(
|
|
4424
|
+
project / "openspec/changes/demo/tasks.md",
|
|
4425
|
+
tracker_owner_tasks("none", "Covered by: 1.1").replace(
|
|
4426
|
+
"- [x] 1.1", "- [ ] 1.1"
|
|
4427
|
+
),
|
|
4428
|
+
)
|
|
4429
|
+
started = run_keel(
|
|
4430
|
+
project, "gate", "task-start",
|
|
4431
|
+
"--change", "demo", "--task", "1.1", "--json",
|
|
4432
|
+
)
|
|
4433
|
+
if started.returncode != 0 or not (project / "keel/guard.json").is_file():
|
|
4434
|
+
report(
|
|
4435
|
+
"guard-manifest-ignored: the fixture did not produce a guard "
|
|
4436
|
+
"manifest to test the declaration against."
|
|
4437
|
+
)
|
|
4438
|
+
report((started.stderr or started.stdout).strip())
|
|
4439
|
+
return 1
|
|
4440
|
+
status = subprocess.run(
|
|
4441
|
+
["git", "status", "--short", "--untracked-files=all"],
|
|
4442
|
+
cwd=project, capture_output=True, encoding="utf-8",
|
|
4443
|
+
)
|
|
4444
|
+
if "guard.json" in (status.stdout or ""):
|
|
4445
|
+
report(
|
|
4446
|
+
"guard-manifest-ignored: git still reports the guard manifest "
|
|
4447
|
+
"after a gate run, so the declaration does not take effect."
|
|
4448
|
+
)
|
|
4449
|
+
report(status.stdout or "")
|
|
4450
|
+
return 1
|
|
4451
|
+
|
|
4452
|
+
# Scaffold once: a project's own file is never rewritten.
|
|
4453
|
+
own = "# mine\nguard.json\nscratch/\n"
|
|
4454
|
+
write_text(ignore_path, own)
|
|
4455
|
+
again = run_keel(project, "--install", "--target", "claude")
|
|
4456
|
+
if again.returncode != 0 or ignore_path.read_text(encoding="utf-8") != own:
|
|
4457
|
+
report(
|
|
4458
|
+
"guard-manifest-ignored: a second install overwrote the "
|
|
4459
|
+
"project's own keel/.gitignore."
|
|
4460
|
+
)
|
|
4461
|
+
report((again.stderr or again.stdout).strip())
|
|
4462
|
+
return 1
|
|
4463
|
+
|
|
4464
|
+
if not (ROOT / "keel/.gitignore").is_file():
|
|
4465
|
+
report(
|
|
4466
|
+
"guard-manifest-ignored: Keel's own repository does not declare "
|
|
4467
|
+
"the guard manifest ignorable."
|
|
4468
|
+
)
|
|
4469
|
+
return 1
|
|
4470
|
+
if "guard-manifest-ignored" not in {name for name, _ in SCENARIOS}:
|
|
4471
|
+
report("guard-manifest-ignored: the scenario registry does not include it.")
|
|
4472
|
+
return 1
|
|
4473
|
+
report("guard-manifest-ignored scenario passed.")
|
|
4474
|
+
return 0
|
|
4475
|
+
|
|
4476
|
+
|
|
4477
|
+
def validate_guard_status_is_not_enforcement_scenario() -> int:
|
|
4478
|
+
"""Issue #14: `Guard: started` means the manifest was written.
|
|
4479
|
+
|
|
4480
|
+
Authors read it as "writes are being checked now". The two came apart when a
|
|
4481
|
+
session kept plugin state that predated a marketplace switch: the manifest
|
|
4482
|
+
was valid and keel's PreToolUse hook never ran. Keel cannot observe the hook
|
|
4483
|
+
from the repository, so the result must say so rather than imply otherwise.
|
|
4484
|
+
"""
|
|
4485
|
+
needles = ("enforcement", "runtime hook", "cannot observe")
|
|
4486
|
+
# Assertive claims only: the honest sentence legitimately denies that a
|
|
4487
|
+
# written manifest proves anything, so a bare "writes are checked" substring
|
|
4488
|
+
# would match the denial itself.
|
|
4489
|
+
forbidden = (
|
|
4490
|
+
"enforcement is active",
|
|
4491
|
+
"enforcement is live",
|
|
4492
|
+
"writes are guarded",
|
|
4493
|
+
)
|
|
4494
|
+
with tempfile.TemporaryDirectory(prefix="keel-guard-honesty-") as raw:
|
|
4495
|
+
project = Path(raw)
|
|
4496
|
+
write_text(
|
|
4497
|
+
project / "openspec/changes/demo/tasks.md",
|
|
4498
|
+
tracker_owner_tasks("none", "Covered by: 1.1").replace(
|
|
4499
|
+
"- [x] 1.1", "- [ ] 1.1"
|
|
4500
|
+
),
|
|
4501
|
+
)
|
|
4502
|
+
started = run_keel(
|
|
4503
|
+
project, "guard", "start",
|
|
4504
|
+
"--change", "demo", "--task", "1.1", "--json",
|
|
4505
|
+
)
|
|
4506
|
+
if started.returncode != 0:
|
|
4507
|
+
report("guard-status-is-not-enforcement: keel guard start failed.")
|
|
4508
|
+
report((started.stderr or started.stdout).strip())
|
|
4509
|
+
return 1
|
|
4510
|
+
for label, result in (
|
|
4511
|
+
("start", started),
|
|
4512
|
+
(
|
|
4513
|
+
"status",
|
|
4514
|
+
run_keel(project, "guard", "status", "--json"),
|
|
4515
|
+
),
|
|
4516
|
+
):
|
|
4517
|
+
payload = json.loads(result.stdout) if result.stdout else {}
|
|
4518
|
+
warnings = " ".join(payload.get("warnings", []))
|
|
4519
|
+
missing = [needle for needle in needles if needle not in warnings]
|
|
4520
|
+
if missing:
|
|
4521
|
+
report(
|
|
4522
|
+
f"guard-status-is-not-enforcement: keel guard {label} does "
|
|
4523
|
+
"not state that the status describes the manifest and that "
|
|
4524
|
+
"enforcement depends on a runtime hook Keel cannot observe; "
|
|
4525
|
+
f"missing {missing}."
|
|
4526
|
+
)
|
|
4527
|
+
report(result.stdout or result.stderr or "")
|
|
4528
|
+
return 1
|
|
4529
|
+
if not payload.get("status"):
|
|
4530
|
+
report(
|
|
4531
|
+
f"guard-status-is-not-enforcement: keel guard {label} lost "
|
|
4532
|
+
"its status value."
|
|
4533
|
+
)
|
|
4534
|
+
return 1
|
|
4535
|
+
human = run_keel(
|
|
4536
|
+
project,
|
|
4537
|
+
"guard",
|
|
4538
|
+
*(("start", "--change", "demo", "--task", "1.1", "--force")
|
|
4539
|
+
if label == "start" else ("status",)),
|
|
4540
|
+
)
|
|
4541
|
+
if "runtime hook" not in (human.stdout or ""):
|
|
4542
|
+
report(
|
|
4543
|
+
f"guard-status-is-not-enforcement: the human-readable keel "
|
|
4544
|
+
f"guard {label} output omits the enforcement boundary."
|
|
4545
|
+
)
|
|
4546
|
+
report(human.stdout or human.stderr or "")
|
|
4547
|
+
return 1
|
|
4548
|
+
for phrase in forbidden:
|
|
4549
|
+
if phrase in warnings or phrase in (human.stdout or ""):
|
|
4550
|
+
report(
|
|
4551
|
+
f"guard-status-is-not-enforcement: keel guard {label} "
|
|
4552
|
+
f"asserts observed enforcement: {phrase!r}."
|
|
4553
|
+
)
|
|
4554
|
+
return 1
|
|
4555
|
+
# The pre-existing durability statement must survive alongside it.
|
|
4556
|
+
status_payload = json.loads(
|
|
4557
|
+
run_keel(project, "guard", "status", "--json").stdout
|
|
4558
|
+
)
|
|
4559
|
+
if not any(
|
|
4560
|
+
"durable authority" in warning
|
|
4561
|
+
for warning in status_payload.get("warnings", [])
|
|
4562
|
+
):
|
|
4563
|
+
report(
|
|
4564
|
+
"guard-status-is-not-enforcement: the existing durable-authority "
|
|
4565
|
+
"statement was dropped."
|
|
4566
|
+
)
|
|
4567
|
+
return 1
|
|
4568
|
+
if "guard-status-is-not-enforcement" not in {name for name, _ in SCENARIOS}:
|
|
4569
|
+
report(
|
|
4570
|
+
"guard-status-is-not-enforcement: the scenario registry does not "
|
|
4571
|
+
"include it."
|
|
4572
|
+
)
|
|
4573
|
+
return 1
|
|
4574
|
+
report("guard-status-is-not-enforcement scenario passed.")
|
|
4575
|
+
return 0
|
|
4576
|
+
|
|
4577
|
+
|
|
4578
|
+
def validate_source_repo_cli_resolution_scenario() -> int:
|
|
4579
|
+
"""Issue #13 item 3: a bare `keel` runs the installed package.
|
|
4580
|
+
|
|
4581
|
+
When the repository under change *is* Keel, gate commands verify the
|
|
4582
|
+
installed CLI rather than the working tree, and the failure mode is a
|
|
4583
|
+
silently stale result rather than an error. One dogfood round was lost to
|
|
4584
|
+
two spurious problems reported by a stale global CLI.
|
|
4585
|
+
"""
|
|
4586
|
+
own = run_keel(ROOT, "--doctor")
|
|
4587
|
+
out = own.stdout or ""
|
|
4588
|
+
if "node bin/keel.js" not in out:
|
|
4589
|
+
report(
|
|
4590
|
+
"source-repo-cli-resolution: Keel's own repository is not told to "
|
|
4591
|
+
"run gate commands through its own entry point."
|
|
4592
|
+
)
|
|
4593
|
+
report(out)
|
|
4594
|
+
return 1
|
|
4595
|
+
# The local entry point needs an explicit repository argument; an author
|
|
4596
|
+
# who copies the hint without it gets an unrelated failure.
|
|
4597
|
+
hint = next(
|
|
4598
|
+
(line for line in out.splitlines() if "node bin/keel.js" in line), ""
|
|
4599
|
+
)
|
|
4600
|
+
# The line must be advisory: it describes a hazard, not a repository
|
|
4601
|
+
# failure, so it must never push doctor toward a non-ok verdict.
|
|
4602
|
+
for needle in ("gate", ".", "advisory"):
|
|
4603
|
+
if needle not in hint:
|
|
4604
|
+
report(
|
|
4605
|
+
"source-repo-cli-resolution: the hint does not name a usable "
|
|
4606
|
+
f"invocation, or is not advisory; missing {needle!r} in: {hint}"
|
|
4607
|
+
)
|
|
4608
|
+
return 1
|
|
4609
|
+
with tempfile.TemporaryDirectory(prefix="keel-cli-resolution-") as raw:
|
|
4610
|
+
consumer = Path(raw)
|
|
4611
|
+
init = run_keel(consumer, "--init", "--target", "claude")
|
|
4612
|
+
if init.returncode != 0:
|
|
4613
|
+
report("source-repo-cli-resolution: keel --init failed.")
|
|
4614
|
+
report((init.stderr or init.stdout).strip())
|
|
4615
|
+
return 1
|
|
4616
|
+
consumer_doctor = run_keel(consumer, "--doctor")
|
|
4617
|
+
if "node bin/keel.js" in (consumer_doctor.stdout or ""):
|
|
4618
|
+
report(
|
|
4619
|
+
"source-repo-cli-resolution: a consuming project was shown a "
|
|
4620
|
+
"hazard that exists only in Keel's own repository."
|
|
4621
|
+
)
|
|
4622
|
+
report(consumer_doctor.stdout or "")
|
|
4623
|
+
return 1
|
|
4624
|
+
if "source-repo-cli-resolution" not in {name for name, _ in SCENARIOS}:
|
|
4625
|
+
report(
|
|
4626
|
+
"source-repo-cli-resolution: the scenario registry does not "
|
|
4627
|
+
"include it."
|
|
4628
|
+
)
|
|
4629
|
+
return 1
|
|
4630
|
+
report("source-repo-cli-resolution scenario passed.")
|
|
4631
|
+
return 0
|
|
4632
|
+
|
|
4633
|
+
|
|
4230
4634
|
def validate_task_capsule_scenario() -> int:
|
|
4231
4635
|
with tempfile.TemporaryDirectory(prefix="keel-task-capsule-") as raw_tmp:
|
|
4232
4636
|
repo = Path(raw_tmp)
|
|
@@ -9963,6 +10367,294 @@ def expect_guard_allow(
|
|
|
9963
10367
|
return 0
|
|
9964
10368
|
|
|
9965
10369
|
|
|
10370
|
+
RECORD_LAYER_SPEC = (
|
|
10371
|
+
"# demo-cap Specification\n\n"
|
|
10372
|
+
"## Purpose\n"
|
|
10373
|
+
"Fixture capability for the record-layer scenario.\n\n"
|
|
10374
|
+
"## Requirements\n"
|
|
10375
|
+
"### Requirement: Guarded behavior holds\n"
|
|
10376
|
+
"The guarded feature MUST keep its public behavior.\n\n"
|
|
10377
|
+
"#### Scenario: Guarded public behavior passes\n"
|
|
10378
|
+
"- **WHEN** the guarded feature runs\n"
|
|
10379
|
+
"- **THEN** it passes\n"
|
|
10380
|
+
)
|
|
10381
|
+
|
|
10382
|
+
|
|
10383
|
+
def record_layer_tasks(checked: bool = False, touch: str = "src/feature.js") -> str:
|
|
10384
|
+
box = "x" if checked else " "
|
|
10385
|
+
return (
|
|
10386
|
+
"# Tasks\n\n"
|
|
10387
|
+
f"- [{box}] 1.1 Exercise guarded feature\n"
|
|
10388
|
+
" - Covers:\n"
|
|
10389
|
+
" - demo-cap / Guarded behavior holds / Guarded public behavior passes\n"
|
|
10390
|
+
" - Touch:\n"
|
|
10391
|
+
f" - {touch}\n"
|
|
10392
|
+
" - Verify:\n"
|
|
10393
|
+
" - Strategy: evidence-first\n"
|
|
10394
|
+
" - M1: node test.js\n"
|
|
10395
|
+
" - Evidence:\n"
|
|
10396
|
+
" - M1: pending\n"
|
|
10397
|
+
)
|
|
10398
|
+
|
|
10399
|
+
|
|
10400
|
+
def mode_fixture_tasks(mode: str, touch: str) -> str:
|
|
10401
|
+
return (
|
|
10402
|
+
"# Tasks\n\n"
|
|
10403
|
+
"- [ ] 1.1 Establish the version baseline\n"
|
|
10404
|
+
f" - Mode: {mode}\n"
|
|
10405
|
+
" - Covers:\n"
|
|
10406
|
+
" - E1: the repository carries its first commit\n"
|
|
10407
|
+
" - Touch:\n"
|
|
10408
|
+
f" - {touch}\n"
|
|
10409
|
+
" - Verify:\n"
|
|
10410
|
+
" - Strategy: evidence-first\n"
|
|
10411
|
+
" - M1: git rev-parse HEAD resolves and git log reports one commit\n"
|
|
10412
|
+
" - Evidence:\n"
|
|
10413
|
+
" - Contract: pending\n"
|
|
10414
|
+
" - M1: pending\n"
|
|
10415
|
+
)
|
|
10416
|
+
|
|
10417
|
+
|
|
10418
|
+
def validate_repo_action_mode_scenario() -> int:
|
|
10419
|
+
"""Issue #8 example 2: a repository action had no legal contract.
|
|
10420
|
+
|
|
10421
|
+
A task whose whole effect is the repository's first commit writes no
|
|
10422
|
+
worktree file, so it has no concrete Touch; it is not diagnose-only,
|
|
10423
|
+
because it has real side effects needing evidence; and `Touch: none` was
|
|
10424
|
+
accepted for no other mode. The author was forced to name a path the task
|
|
10425
|
+
did not write, which then tripped the drift defect in example 1.
|
|
10426
|
+
"""
|
|
10427
|
+
label = "repo-action-mode"
|
|
10428
|
+
|
|
10429
|
+
def compile_task(repo: Path, mode: str, touch: str):
|
|
10430
|
+
write_text(repo / "openspec/changes/demo/tasks.md", mode_fixture_tasks(mode, touch))
|
|
10431
|
+
return run_keel(
|
|
10432
|
+
repo, "gate", "task-start",
|
|
10433
|
+
"--change", "demo", "--task", "1.1", "--no-guard", "--json",
|
|
10434
|
+
)
|
|
10435
|
+
|
|
10436
|
+
with tempfile.TemporaryDirectory(prefix="keel-repo-action-") as raw:
|
|
10437
|
+
repo = Path(raw)
|
|
10438
|
+
|
|
10439
|
+
started = compile_task(repo, "repo-action", "none")
|
|
10440
|
+
if started.returncode != 0:
|
|
10441
|
+
report(f"{label}: `Mode: repo-action` with `Touch: none` was rejected.")
|
|
10442
|
+
report((started.stderr or started.stdout).strip())
|
|
10443
|
+
return 1
|
|
10444
|
+
capsule = json.loads(started.stdout).get("contract", {}).get("capsule", {})
|
|
10445
|
+
prohibitions = capsule.get("prohibitions", [])
|
|
10446
|
+
if capsule.get("mode") != "repo-action":
|
|
10447
|
+
report(f"{label}: the capsule did not record the repo-action mode.")
|
|
10448
|
+
return 1
|
|
10449
|
+
if "must not write product files" not in prohibitions:
|
|
10450
|
+
report(
|
|
10451
|
+
f"{label}: repo-action must prohibit product writes; got "
|
|
10452
|
+
f"{prohibitions}."
|
|
10453
|
+
)
|
|
10454
|
+
return 1
|
|
10455
|
+
if "must not commit" in prohibitions:
|
|
10456
|
+
report(
|
|
10457
|
+
f"{label}: repo-action must be the mode that may commit; got "
|
|
10458
|
+
f"{prohibitions}."
|
|
10459
|
+
)
|
|
10460
|
+
return 1
|
|
10461
|
+
|
|
10462
|
+
# Every other mode keeps the commit prohibition.
|
|
10463
|
+
for mode, touch in (
|
|
10464
|
+
("implementation", "src/feature.js"),
|
|
10465
|
+
("plan-first", "src/feature.js"),
|
|
10466
|
+
("diagnose-only", "none"),
|
|
10467
|
+
):
|
|
10468
|
+
other = compile_task(repo, mode, touch)
|
|
10469
|
+
if other.returncode != 0:
|
|
10470
|
+
report(f"{label}: `Mode: {mode}` regressed and no longer compiles.")
|
|
10471
|
+
report((other.stderr or other.stdout).strip())
|
|
10472
|
+
return 1
|
|
10473
|
+
other_capsule = (
|
|
10474
|
+
json.loads(other.stdout).get("contract", {}).get("capsule", {})
|
|
10475
|
+
)
|
|
10476
|
+
if "must not commit" not in other_capsule.get("prohibitions", []):
|
|
10477
|
+
report(f"{label}: `Mode: {mode}` lost the commit prohibition.")
|
|
10478
|
+
return 1
|
|
10479
|
+
product_write_prohibited = (
|
|
10480
|
+
"must not write product files"
|
|
10481
|
+
in other_capsule.get("prohibitions", [])
|
|
10482
|
+
)
|
|
10483
|
+
if product_write_prohibited != (mode == "diagnose-only"):
|
|
10484
|
+
report(
|
|
10485
|
+
f"{label}: `Mode: {mode}` changed its product-write "
|
|
10486
|
+
"prohibition."
|
|
10487
|
+
)
|
|
10488
|
+
return 1
|
|
10489
|
+
|
|
10490
|
+
# repo-action means no worktree writes, so a concrete Touch contradicts it.
|
|
10491
|
+
with_touch = compile_task(repo, "repo-action", "src/feature.js")
|
|
10492
|
+
with_touch_problems = (
|
|
10493
|
+
json.loads(with_touch.stdout).get("problems", [])
|
|
10494
|
+
if with_touch.stdout
|
|
10495
|
+
else []
|
|
10496
|
+
)
|
|
10497
|
+
touch_message = " ".join(
|
|
10498
|
+
item.get("message", "")
|
|
10499
|
+
for item in with_touch_problems
|
|
10500
|
+
if item.get("code") == "invalid-touch"
|
|
10501
|
+
)
|
|
10502
|
+
if with_touch.returncode == 0 or "Touch: none" not in touch_message:
|
|
10503
|
+
report(
|
|
10504
|
+
f"{label}: repo-action with a concrete Touch must fail with a "
|
|
10505
|
+
"diagnostic naming the `Touch: none` it requires."
|
|
10506
|
+
)
|
|
10507
|
+
report((with_touch.stderr or with_touch.stdout).strip())
|
|
10508
|
+
return 1
|
|
10509
|
+
|
|
10510
|
+
unsupported = compile_task(repo, "repo-actions", "none")
|
|
10511
|
+
unsupported_message = " ".join(
|
|
10512
|
+
item.get("message", "")
|
|
10513
|
+
for item in (
|
|
10514
|
+
json.loads(unsupported.stdout).get("problems", [])
|
|
10515
|
+
if unsupported.stdout
|
|
10516
|
+
else []
|
|
10517
|
+
)
|
|
10518
|
+
if item.get("code") == "unsupported-mode"
|
|
10519
|
+
)
|
|
10520
|
+
missing = [
|
|
10521
|
+
mode
|
|
10522
|
+
for mode in ("implementation", "diagnose-only", "plan-first", "repo-action")
|
|
10523
|
+
if mode not in unsupported_message
|
|
10524
|
+
]
|
|
10525
|
+
if unsupported.returncode == 0 or missing:
|
|
10526
|
+
report(
|
|
10527
|
+
f"{label}: the unsupported-mode diagnostic must list every "
|
|
10528
|
+
f"supported mode; missing {missing}."
|
|
10529
|
+
)
|
|
10530
|
+
report((unsupported.stderr or unsupported.stdout).strip())
|
|
10531
|
+
return 1
|
|
10532
|
+
|
|
10533
|
+
if label not in {name for name, _ in SCENARIOS}:
|
|
10534
|
+
report(f"{label}: the scenario registry does not include it.")
|
|
10535
|
+
return 1
|
|
10536
|
+
report(f"{label} scenario passed.")
|
|
10537
|
+
return 0
|
|
10538
|
+
|
|
10539
|
+
|
|
10540
|
+
def validate_touch_guard_record_layer_scenario() -> int:
|
|
10541
|
+
"""Issue #8: the guard denied what the completion gate already forgives.
|
|
10542
|
+
|
|
10543
|
+
`scopeProblems` exempts the selected change's own `openspec/changes/<change>/`
|
|
10544
|
+
directory from outside-Touch attribution, but the guard denied writes there
|
|
10545
|
+
and treated the byte hash of that change's tasks.md as authority, so ticking
|
|
10546
|
+
a checkbox or appending Evidence locked the task out of its own bookkeeping.
|
|
10547
|
+
"""
|
|
10548
|
+
label = "touch-guard-record-layer"
|
|
10549
|
+
with tempfile.TemporaryDirectory(prefix="keel-record-layer-") as raw:
|
|
10550
|
+
repo = Path(raw)
|
|
10551
|
+
tasks = repo / "openspec/changes/demo/tasks.md"
|
|
10552
|
+
spec = repo / "openspec/specs/demo-cap/spec.md"
|
|
10553
|
+
write_text(tasks, record_layer_tasks())
|
|
10554
|
+
write_text(spec, RECORD_LAYER_SPEC)
|
|
10555
|
+
write_text(repo / "openspec/changes/other/tasks.md", "# Tasks\n")
|
|
10556
|
+
write_text(repo / "keel/archive/follow-ups/note.md", "# Note\n")
|
|
10557
|
+
|
|
10558
|
+
started = run_keel(
|
|
10559
|
+
repo, "guard", "start", "--change", "demo", "--task", "1.1", "--json"
|
|
10560
|
+
)
|
|
10561
|
+
if started.returncode != 0:
|
|
10562
|
+
report(f"{label}: guard start failed on the fixture.")
|
|
10563
|
+
report((started.stderr or started.stdout).strip())
|
|
10564
|
+
return 1
|
|
10565
|
+
manifest = json.loads(started.stdout).get("manifest", {})
|
|
10566
|
+
authority = [entry.get("path") for entry in manifest.get("authority", [])]
|
|
10567
|
+
if not any(item == "openspec/specs/demo-cap/spec.md" for item in authority):
|
|
10568
|
+
report(
|
|
10569
|
+
f"{label}: the fixture does not record an authority file outside "
|
|
10570
|
+
f"the change directory, so the negative case is untested: {authority}"
|
|
10571
|
+
)
|
|
10572
|
+
return 1
|
|
10573
|
+
|
|
10574
|
+
# The record layer: writable although Touch never named it.
|
|
10575
|
+
if expect_guard_allow(repo, tasks, f"{label} record write"):
|
|
10576
|
+
return 1
|
|
10577
|
+
# A record write already made must not lock the task out of its product
|
|
10578
|
+
# writes — this is the drift half of the reported defect.
|
|
10579
|
+
write_text(tasks, record_layer_tasks().replace("M1: pending", "M1: done"))
|
|
10580
|
+
if expect_guard_allow(repo, repo / "src/feature.js", f"{label} after record write"):
|
|
10581
|
+
return 1
|
|
10582
|
+
|
|
10583
|
+
# The layer is exactly this change's directory, nothing wider.
|
|
10584
|
+
if expect_guard_deny(
|
|
10585
|
+
repo,
|
|
10586
|
+
repo / "openspec/changes/other/tasks.md",
|
|
10587
|
+
["outside Touch"],
|
|
10588
|
+
f"{label} other change",
|
|
10589
|
+
):
|
|
10590
|
+
return 1
|
|
10591
|
+
if expect_guard_deny(
|
|
10592
|
+
repo,
|
|
10593
|
+
repo / "keel/archive/follow-ups/note.md",
|
|
10594
|
+
["outside Touch"],
|
|
10595
|
+
f"{label} archive tree",
|
|
10596
|
+
):
|
|
10597
|
+
return 1
|
|
10598
|
+
|
|
10599
|
+
# Authority outside the change directory still hashes and still denies.
|
|
10600
|
+
write_text(spec, RECORD_LAYER_SPEC.replace("it passes", "it passes twice"))
|
|
10601
|
+
if expect_guard_deny(
|
|
10602
|
+
repo,
|
|
10603
|
+
repo / "src/feature.js",
|
|
10604
|
+
["authority drift"],
|
|
10605
|
+
f"{label} real authority drift",
|
|
10606
|
+
):
|
|
10607
|
+
return 1
|
|
10608
|
+
write_text(spec, RECORD_LAYER_SPEC)
|
|
10609
|
+
|
|
10610
|
+
status = run_keel(repo, "guard", "status", "--json")
|
|
10611
|
+
status_payload = json.loads(status.stdout) if status.stdout else {}
|
|
10612
|
+
codes = [item.get("code") for item in status_payload.get("problems", [])]
|
|
10613
|
+
if status_payload.get("status") != "active" or codes:
|
|
10614
|
+
report(
|
|
10615
|
+
f"{label}: guard status reported {status_payload.get('status')!r} "
|
|
10616
|
+
f"with problems {codes} after a record write; a checkbox or "
|
|
10617
|
+
"Evidence write is not authority drift."
|
|
10618
|
+
)
|
|
10619
|
+
return 1
|
|
10620
|
+
|
|
10621
|
+
# A real contract edit in the same file must still hard-stop, through the
|
|
10622
|
+
# fingerprint rather than through byte hashing.
|
|
10623
|
+
write_text(tasks, record_layer_tasks(touch="src/other.js"))
|
|
10624
|
+
drifted = run_keel(repo, "guard", "status", "--json")
|
|
10625
|
+
drifted_codes = [
|
|
10626
|
+
item.get("code")
|
|
10627
|
+
for item in (json.loads(drifted.stdout) if drifted.stdout else {}).get(
|
|
10628
|
+
"problems", []
|
|
10629
|
+
)
|
|
10630
|
+
]
|
|
10631
|
+
if "fingerprint-drift" not in drifted_codes:
|
|
10632
|
+
report(
|
|
10633
|
+
f"{label}: editing the task's Touch line did not report "
|
|
10634
|
+
f"fingerprint drift; got {drifted_codes}."
|
|
10635
|
+
)
|
|
10636
|
+
return 1
|
|
10637
|
+
|
|
10638
|
+
# Once checked, product writes stop but the task can still finish its
|
|
10639
|
+
# own records — the completion gate requires that Evidence.
|
|
10640
|
+
write_text(tasks, record_layer_tasks(checked=True))
|
|
10641
|
+
if expect_guard_deny(
|
|
10642
|
+
repo,
|
|
10643
|
+
repo / "src/feature.js",
|
|
10644
|
+
["checked complete"],
|
|
10645
|
+
f"{label} completed product write",
|
|
10646
|
+
):
|
|
10647
|
+
return 1
|
|
10648
|
+
if expect_guard_allow(repo, tasks, f"{label} completed record write"):
|
|
10649
|
+
return 1
|
|
10650
|
+
|
|
10651
|
+
if label not in {name for name, _ in SCENARIOS}:
|
|
10652
|
+
report(f"{label}: the scenario registry does not include it.")
|
|
10653
|
+
return 1
|
|
10654
|
+
report(f"{label} scenario passed.")
|
|
10655
|
+
return 0
|
|
10656
|
+
|
|
10657
|
+
|
|
9966
10658
|
def validate_touch_write_guard_scenario() -> int:
|
|
9967
10659
|
with tempfile.TemporaryDirectory(
|
|
9968
10660
|
prefix="keel-touch-guard-", ignore_cleanup_errors=True
|
|
@@ -10752,18 +11444,32 @@ def validate_touch_guard_drift_scenario() -> int:
|
|
|
10752
11444
|
"Exercise guarded feature", "Exercise guarded feature differently"
|
|
10753
11445
|
),
|
|
10754
11446
|
)
|
|
10755
|
-
|
|
10756
|
-
|
|
10757
|
-
|
|
10758
|
-
|
|
10759
|
-
|
|
11447
|
+
# A contract edit inside the guarded change's own directory is caught
|
|
11448
|
+
# where the capsule is compiled, not at the next write: the hook cannot
|
|
11449
|
+
# compile, so it cannot separate this from a checkbox or Evidence write
|
|
11450
|
+
# in the same file. It therefore allows the write and `guard status`
|
|
11451
|
+
# reports the drift. Authority *outside* that directory still denies at
|
|
11452
|
+
# write time — see the touch-guard-record-layer scenario.
|
|
11453
|
+
if expect_guard_allow(
|
|
11454
|
+
repo, repo / "src/feature.js", "touch-guard-drift contract edit"
|
|
10760
11455
|
):
|
|
10761
11456
|
return 1
|
|
10762
11457
|
status = run_keel(repo, "guard", "status", "--json")
|
|
10763
|
-
|
|
11458
|
+
status_payload = json.loads(status.stdout) if status.stdout else {}
|
|
11459
|
+
if status.returncode != 3 or status_payload.get("status") != "drifted":
|
|
10764
11460
|
report("touch-guard-drift: status did not report drifted.")
|
|
10765
11461
|
report((status.stderr or status.stdout).strip())
|
|
10766
11462
|
return 1
|
|
11463
|
+
if not any(
|
|
11464
|
+
item.get("code") == "fingerprint-drift"
|
|
11465
|
+
for item in status_payload.get("problems", [])
|
|
11466
|
+
):
|
|
11467
|
+
report(
|
|
11468
|
+
"touch-guard-drift: the contract edit was not reported as "
|
|
11469
|
+
"fingerprint drift by the check that compiles the capsule."
|
|
11470
|
+
)
|
|
11471
|
+
report(status.stdout or "")
|
|
11472
|
+
return 1
|
|
10767
11473
|
|
|
10768
11474
|
restarted = run_keel(
|
|
10769
11475
|
repo, "guard", "start", "--change", "demo", "--task", "1.1", "--json"
|
|
@@ -10780,30 +11486,41 @@ def validate_touch_guard_drift_scenario() -> int:
|
|
|
10780
11486
|
):
|
|
10781
11487
|
return 1
|
|
10782
11488
|
|
|
11489
|
+
# A fingerprint-neutral edit to the task's own file is a record write,
|
|
11490
|
+
# not drift. This used to fail closed, which is the defect issue #8
|
|
11491
|
+
# reports: appending Evidence blocked every following write.
|
|
10783
11492
|
tasks_path.write_text(
|
|
10784
11493
|
tasks_path.read_text(encoding="utf-8") + "\n", encoding="utf-8"
|
|
10785
11494
|
)
|
|
10786
|
-
if
|
|
10787
|
-
repo,
|
|
10788
|
-
repo / "src/feature.js",
|
|
10789
|
-
["drift", "keel guard start"],
|
|
10790
|
-
"touch-guard-drift cosmetic edit fails closed",
|
|
11495
|
+
if expect_guard_allow(
|
|
11496
|
+
repo, repo / "src/feature.js", "touch-guard-drift cosmetic edit"
|
|
10791
11497
|
):
|
|
10792
11498
|
return 1
|
|
10793
|
-
cosmetic = run_keel(
|
|
10794
|
-
|
|
11499
|
+
cosmetic = run_keel(repo, "guard", "status", "--json")
|
|
11500
|
+
cosmetic_payload = json.loads(cosmetic.stdout) if cosmetic.stdout else {}
|
|
11501
|
+
if cosmetic_payload.get("status") != "active" or cosmetic_payload.get(
|
|
11502
|
+
"problems"
|
|
11503
|
+
):
|
|
11504
|
+
report(
|
|
11505
|
+
"touch-guard-drift: a fingerprint-neutral edit to the task's "
|
|
11506
|
+
"own file was reported as a guard problem."
|
|
11507
|
+
)
|
|
11508
|
+
report(cosmetic.stdout or "")
|
|
11509
|
+
return 1
|
|
11510
|
+
restarted_cosmetic = run_keel(
|
|
11511
|
+
repo, "guard", "start", "--change", "demo", "--task", "1.1",
|
|
11512
|
+
"--force", "--json",
|
|
10795
11513
|
)
|
|
10796
|
-
if
|
|
11514
|
+
if restarted_cosmetic.returncode != 0:
|
|
10797
11515
|
report("touch-guard-drift: cosmetic restart failed.")
|
|
11516
|
+
report((restarted_cosmetic.stderr or restarted_cosmetic.stdout).strip())
|
|
10798
11517
|
return 1
|
|
10799
|
-
third = json.loads(
|
|
11518
|
+
third = json.loads(restarted_cosmetic.stdout)["manifest"]["fingerprint"][
|
|
11519
|
+
"value"
|
|
11520
|
+
]
|
|
10800
11521
|
if third != second:
|
|
10801
11522
|
report("touch-guard-drift: cosmetic edit drifted the capsule fingerprint.")
|
|
10802
11523
|
return 1
|
|
10803
|
-
if expect_guard_allow(
|
|
10804
|
-
repo, repo / "src/feature.js", "touch-guard-drift cosmetic restart"
|
|
10805
|
-
):
|
|
10806
|
-
return 1
|
|
10807
11524
|
|
|
10808
11525
|
write_text(
|
|
10809
11526
|
tasks_path,
|
|
@@ -10999,6 +11716,13 @@ SCENARIOS: tuple = (
|
|
|
10999
11716
|
"source-repo-bootstrap-skip",
|
|
11000
11717
|
validate_source_repo_bootstrap_skip_scenario,
|
|
11001
11718
|
),
|
|
11719
|
+
("tracker-durable-owner", validate_tracker_durable_owner_scenario),
|
|
11720
|
+
("guard-manifest-ignored", validate_guard_manifest_ignored_scenario),
|
|
11721
|
+
(
|
|
11722
|
+
"guard-status-is-not-enforcement",
|
|
11723
|
+
validate_guard_status_is_not_enforcement_scenario,
|
|
11724
|
+
),
|
|
11725
|
+
("source-repo-cli-resolution", validate_source_repo_cli_resolution_scenario),
|
|
11002
11726
|
("task-contract-core", validate_task_contract_core_scenario),
|
|
11003
11727
|
("task-capsule", validate_task_capsule_scenario),
|
|
11004
11728
|
("task-verification-strategies", validate_task_verification_strategies_scenario),
|
|
@@ -11026,6 +11750,8 @@ SCENARIOS: tuple = (
|
|
|
11026
11750
|
("native-helper-targets", validate_native_helper_targets_scenario),
|
|
11027
11751
|
("native-single-task-matrix", validate_native_single_task_matrix_scenario),
|
|
11028
11752
|
("touch-write-guard", validate_touch_write_guard_scenario),
|
|
11753
|
+
("touch-guard-record-layer", validate_touch_guard_record_layer_scenario),
|
|
11754
|
+
("repo-action-mode", validate_repo_action_mode_scenario),
|
|
11029
11755
|
("touch-guard-drift", validate_touch_guard_drift_scenario),
|
|
11030
11756
|
("touch-guard-surface", validate_touch_guard_surface_scenario),
|
|
11031
11757
|
("plugin-compaction-continuity", validate_plugin_compaction_continuity_scenario),
|
package/src/core/gates.js
CHANGED
|
@@ -246,12 +246,21 @@ function reviewValue(task, label) {
|
|
|
246
246
|
return match ? match[1].trim() : "";
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
+
// The durable-owner forms that are pure shape checks, shared by the Review
|
|
250
|
+
// Findings check and the Expectation Coverage check so a form added to one is
|
|
251
|
+
// never missing from the other. Gates run without network and have never
|
|
252
|
+
// confirmed that an archive path resolves either, so an external tracker
|
|
253
|
+
// reference is no less checkable than what was already accepted; whether the
|
|
254
|
+
// owner is real stays a Review judgment.
|
|
255
|
+
const SHARED_DURABLE_OWNER_FORMS =
|
|
256
|
+
/(?:\bkeel\/archive\/[A-Za-z0-9._/-]+|\bhttps?:\/\/\S)/i;
|
|
257
|
+
|
|
249
258
|
function findingOwnerIsDurable(repo, findings) {
|
|
250
259
|
if (/keel\/HANDOFF\.md/i.test(findings)) return false;
|
|
251
260
|
if (/\b(?:explicit\s+)?discard (?:reason|rationale)\s*:/i.test(findings)) {
|
|
252
261
|
return true;
|
|
253
262
|
}
|
|
254
|
-
if (
|
|
263
|
+
if (SHARED_DURABLE_OWNER_FORMS.test(findings)) return true;
|
|
255
264
|
const owner = findings.match(
|
|
256
265
|
/\b(openspec\/changes\/[A-Za-z0-9][A-Za-z0-9._-]*\/(?:proposal|design|tasks)\.md)(?:#\d+(?:\.\d+)*)?/i
|
|
257
266
|
);
|
|
@@ -452,8 +461,8 @@ function completionChecks(repo, task, contract = null) {
|
|
|
452
461
|
"finding-owner",
|
|
453
462
|
"Review Findings must be `none` or carry a durable owner — a "
|
|
454
463
|
+ "`Discard reason:`/`Discard rationale:` prefix, a `keel/archive/…` "
|
|
455
|
-
+ "path,
|
|
456
|
-
+ "`keel/HANDOFF.md` is not an owner."
|
|
464
|
+
+ "path, an existing `openspec/changes/…` artifact, or an absolute "
|
|
465
|
+
+ "`https://…` tracker reference; `keel/HANDOFF.md` is not an owner."
|
|
457
466
|
)
|
|
458
467
|
);
|
|
459
468
|
}
|
|
@@ -520,7 +529,8 @@ function expectationProblems(content, tasks) {
|
|
|
520
529
|
"expectation-coverage",
|
|
521
530
|
"Expectation Coverage must declare each `E<n>` closure — "
|
|
522
531
|
+ "`- E<n>: <expectation> Covered by: <task ids>`, a `Durable owner:` "
|
|
523
|
-
+ "path, or a `Discard reason:` —
|
|
532
|
+
+ "path or `https://…` tracker reference, or a `Discard reason:` — "
|
|
533
|
+
+ "or `- None.`."
|
|
524
534
|
),
|
|
525
535
|
];
|
|
526
536
|
}
|
|
@@ -528,8 +538,12 @@ function expectationProblems(content, tasks) {
|
|
|
528
538
|
for (const entry of entries) {
|
|
529
539
|
const [, id, body] = entry;
|
|
530
540
|
const covered = body.match(/Covered by:\s*([0-9.,\s-]+)/i);
|
|
531
|
-
const
|
|
532
|
-
|
|
541
|
+
const declaredOwner = body.match(/Durable owner:\s*(\S[^\n]*)/i);
|
|
542
|
+
const hasDurableOwner = Boolean(
|
|
543
|
+
declaredOwner
|
|
544
|
+
&& (/^openspec\/changes\//i.test(declaredOwner[1].trim())
|
|
545
|
+
|| SHARED_DURABLE_OWNER_FORMS.test(declaredOwner[1]))
|
|
546
|
+
);
|
|
533
547
|
const discarded = /Discard(?:ed)? (?:reason|rationale):\s*\S/i.test(body);
|
|
534
548
|
if (!covered && !hasDurableOwner && !discarded) {
|
|
535
549
|
problems.push(
|
package/src/core/guard.js
CHANGED
|
@@ -35,6 +35,14 @@ function guardResult(subcommand, status, extra = {}) {
|
|
|
35
35
|
"The guard manifest is a disposable enforcement pointer; OpenSpec and "
|
|
36
36
|
+ "Git remain the only durable authority and selection never derives "
|
|
37
37
|
+ "from it.",
|
|
38
|
+
// The status describes a file Keel wrote. Whether anything reads that
|
|
39
|
+
// file is a target-side fact: enforcement runs as a runtime hook the
|
|
40
|
+
// host loads, and a host that loaded different plugins keeps them for
|
|
41
|
+
// the life of its session. Reporting `started` as though it were a probe
|
|
42
|
+
// result is the same inference `--doctor` already refuses to make.
|
|
43
|
+
"This status describes the manifest only. Enforcement runs as a runtime "
|
|
44
|
+
+ "hook in the host, which Keel cannot observe from the repository, so "
|
|
45
|
+
+ "a written manifest is not evidence that any write was checked.",
|
|
38
46
|
],
|
|
39
47
|
...extra,
|
|
40
48
|
};
|
|
@@ -239,7 +247,13 @@ function guardStatus(repo) {
|
|
|
239
247
|
+ "reauthorize through `keel gate task-start` and `keel guard start`.",
|
|
240
248
|
});
|
|
241
249
|
}
|
|
250
|
+
// Bytes under the guarded change's own directory are records the task
|
|
251
|
+
// produces — checkbox, Evidence, Review — not authority it must not touch.
|
|
252
|
+
// The fingerprint comparison above already covers every part of that file
|
|
253
|
+
// the capsule reads, so hashing it here only reported progress as drift.
|
|
254
|
+
const recordPrefix = `openspec/changes/${manifest.change}/`;
|
|
242
255
|
for (const entry of manifest.authority) {
|
|
256
|
+
if (entry.path.replace(/\\/g, "/").startsWith(recordPrefix)) continue;
|
|
243
257
|
const file = path.join(repo, entry.path);
|
|
244
258
|
if (!fs.existsSync(file) || sha256(fs.readFileSync(file)) !== entry.sha256) {
|
|
245
259
|
problems.push({
|
|
@@ -8,8 +8,17 @@ const SUPPORTED_MODES = new Set([
|
|
|
8
8
|
"implementation",
|
|
9
9
|
"diagnose-only",
|
|
10
10
|
"plan-first",
|
|
11
|
+
// A task whose whole effect is an authorized repository-level action — the
|
|
12
|
+
// repository's first commit, a tag — writes no worktree file, so it has no
|
|
13
|
+
// concrete Touch to declare. It is not diagnose-only either: it has real side
|
|
14
|
+
// effects that need evidence. It is the one mode that may commit.
|
|
15
|
+
"repo-action",
|
|
11
16
|
]);
|
|
12
17
|
|
|
18
|
+
// Modes whose contract is "no worktree writes", so `Touch: none` is required
|
|
19
|
+
// rather than merely tolerated.
|
|
20
|
+
const NO_WRITE_MODES = new Set(["diagnose-only", "repo-action"]);
|
|
21
|
+
|
|
13
22
|
const UNFILLED_TOKEN = /(<[^>]+>|\bTODO\b|\bTBD\b|\bplaceholder\b)/i;
|
|
14
23
|
|
|
15
24
|
function normalizeFieldText(value) {
|
|
@@ -241,16 +250,17 @@ function taskStartContractProblems(task) {
|
|
|
241
250
|
code: "unsupported-mode",
|
|
242
251
|
message:
|
|
243
252
|
`Unsupported Mode \`${mode}\`; expected implementation, `
|
|
244
|
-
+ "diagnose-only, or
|
|
253
|
+
+ "diagnose-only, plan-first, or repo-action.",
|
|
245
254
|
},
|
|
246
255
|
];
|
|
247
256
|
}
|
|
248
|
-
if (mode
|
|
257
|
+
if (NO_WRITE_MODES.has(mode)) {
|
|
249
258
|
if (touch.length !== 1 || touch[0].toLowerCase() !== "none") {
|
|
250
259
|
return [
|
|
251
260
|
{
|
|
252
261
|
code: "invalid-touch",
|
|
253
|
-
message:
|
|
262
|
+
message: `${mode} writes no worktree file and requires `
|
|
263
|
+
+ "`Touch: none`.",
|
|
254
264
|
},
|
|
255
265
|
];
|
|
256
266
|
}
|
|
@@ -819,13 +829,17 @@ function compileTaskContract(repo, change, task) {
|
|
|
819
829
|
helperAuthority: "read-only-evidence-only",
|
|
820
830
|
prohibitions: [
|
|
821
831
|
"must not change Acceptance",
|
|
822
|
-
|
|
832
|
+
// repo-action is the one mode whose authorized effect is the repository
|
|
833
|
+
// action itself, so it alone does not carry the commit prohibition.
|
|
834
|
+
// Whether the action it performed was the authorized one is a Review
|
|
835
|
+
// judgment; what the capsule fixes is the write posture.
|
|
836
|
+
...(mode === "repo-action" ? [] : ["must not commit"]),
|
|
823
837
|
"must not continue to another task",
|
|
824
838
|
"must not mark tasks complete",
|
|
825
839
|
"must not push",
|
|
826
840
|
"must not sync or archive",
|
|
827
841
|
"must not transfer Keel ownership",
|
|
828
|
-
...(mode
|
|
842
|
+
...(NO_WRITE_MODES.has(mode) ? ["must not write product files"] : []),
|
|
829
843
|
],
|
|
830
844
|
};
|
|
831
845
|
if (resolved.diagnostics.length > 0) {
|