@christang/keel 5.7.0 → 5.14.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 +4 -2
- package/bin/keel.js +158 -58
- 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/agents/keel-single-task-goal-claude.md +3 -2
- package/plugins/keel/agents/keel-single-task-goal-codex.md +2 -2
- package/plugins/keel/scripts/pretooluse-guard.js +65 -2
- package/plugins/keel/scripts/session-start.js +87 -0
- package/plugins/keel/skills/keel-review-checklist/SKILL.md +25 -1
- package/plugins/keel/skills/keel-run-single-task-goal/SKILL.md +3 -3
- package/scripts/run_python.js +101 -5
- package/scripts/validate_plugin.py +7298 -3678
- package/src/core/config.js +58 -0
- package/src/core/context.js +41 -5
- package/src/core/gates.js +236 -35
- package/src/core/goal.js +13 -1
- package/src/core/helper.js +27 -1
- package/src/core/projection.js +55 -0
- package/src/core/task-contract.js +30 -0
package/src/core/config.js
CHANGED
|
@@ -9,6 +9,14 @@ const path = require("path");
|
|
|
9
9
|
// author believing they authorized something they did not.
|
|
10
10
|
const STANDING_AUTHORIZATION_ACTIONS = ["commit", "push", "release", "archive"];
|
|
11
11
|
|
|
12
|
+
// The closed vocabulary of capability tiers a repository may declare for a
|
|
13
|
+
// delegated task. The names describe the capability the work requires, never
|
|
14
|
+
// the size of the work: a tier named for size would authorize the agent's guess
|
|
15
|
+
// about difficulty, which is the judgement 5.7.0 refused for triage. Keel names
|
|
16
|
+
// no concrete model — one is target-specific and expires at the next release,
|
|
17
|
+
// and the declaration must still be correct after both.
|
|
18
|
+
const DELEGATION_TIERS = ["routine", "standard", "deep"];
|
|
19
|
+
|
|
12
20
|
const CONFIG_RELATIVE_PATH = path.join("keel", "config.yaml");
|
|
13
21
|
|
|
14
22
|
// The declarations share keel/config.yaml with fast_check, so the reader stays
|
|
@@ -61,6 +69,54 @@ function readStandingAuthorization(repo) {
|
|
|
61
69
|
return { declared, unknown };
|
|
62
70
|
}
|
|
63
71
|
|
|
72
|
+
// A nested block of `name: value` entries under one top-level key. Delegation
|
|
73
|
+
// needs a key with a value rather than a bare list, so it cannot reuse
|
|
74
|
+
// configList; the reader stays line-oriented for the same reason the others do.
|
|
75
|
+
function configMap(repo, key) {
|
|
76
|
+
const configPath = path.join(repo, "keel", "config.yaml");
|
|
77
|
+
const entries = {};
|
|
78
|
+
if (!fs.existsSync(configPath)) return entries;
|
|
79
|
+
const opener = new RegExp(`^${key}\\s*:\\s*$`);
|
|
80
|
+
let inBlock = false;
|
|
81
|
+
for (const line of fs.readFileSync(configPath, "utf8").split(/\r?\n/)) {
|
|
82
|
+
if (/^\s*#/.test(line)) continue;
|
|
83
|
+
if (opener.test(line)) {
|
|
84
|
+
inBlock = true;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (!inBlock) continue;
|
|
88
|
+
if (line.trim() === "") continue;
|
|
89
|
+
const entry = line.match(/^\s+(\w+)\s*:\s*(\S+)\s*$/);
|
|
90
|
+
// Anything that is not an indented entry closes the block, exactly as it
|
|
91
|
+
// does for a list; the next top-level key belongs to the rest of the file.
|
|
92
|
+
if (!entry) break;
|
|
93
|
+
entries[entry[1]] = entry[2];
|
|
94
|
+
}
|
|
95
|
+
return entries;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Who runs a task. Declaring a tier is what permits delegation at that tier —
|
|
99
|
+
// there is no separate on/off entry, because a tier with no permission and a
|
|
100
|
+
// permission with no tier are both incomplete, and one field cannot disagree
|
|
101
|
+
// with itself. An absent declaration delegates nothing, which is the default.
|
|
102
|
+
function readDelegationPolicy(repo) {
|
|
103
|
+
const block = configMap(repo, "delegation");
|
|
104
|
+
const tier = block.tier;
|
|
105
|
+
// The accepted set travels with every answer so a caller reporting a refusal
|
|
106
|
+
// can name the alternatives without importing the vocabulary itself. A copy,
|
|
107
|
+
// because a caller that sorted or spliced it in place would edit the closed
|
|
108
|
+
// set for everyone who read it afterward.
|
|
109
|
+
const accepted = [...DELEGATION_TIERS];
|
|
110
|
+
if (!tier) return { declared: false, tier: null, unknown: [], accepted };
|
|
111
|
+
// Fail closed, the same way an unrecognized `authorize:` action does: a
|
|
112
|
+
// declaration Keel cannot fully read authorizes nothing, because the author
|
|
113
|
+
// of a typo believes they declared what they typed.
|
|
114
|
+
if (!DELEGATION_TIERS.includes(tier)) {
|
|
115
|
+
return { declared: true, tier: null, unknown: [tier], accepted };
|
|
116
|
+
}
|
|
117
|
+
return { declared: true, tier, unknown: [], accepted };
|
|
118
|
+
}
|
|
119
|
+
|
|
64
120
|
function configScalar(repo, key) {
|
|
65
121
|
const configPath = path.join(repo, "keel", "config.yaml");
|
|
66
122
|
if (!fs.existsSync(configPath)) return null;
|
|
@@ -159,7 +215,9 @@ function triageIssue(repo, labels) {
|
|
|
159
215
|
|
|
160
216
|
module.exports = {
|
|
161
217
|
CONFIG_RELATIVE_PATH,
|
|
218
|
+
DELEGATION_TIERS,
|
|
162
219
|
STANDING_AUTHORIZATION_ACTIONS,
|
|
220
|
+
readDelegationPolicy,
|
|
163
221
|
readPrecedentStore,
|
|
164
222
|
readStandingAuthorization,
|
|
165
223
|
readTriagePolicy,
|
package/src/core/context.js
CHANGED
|
@@ -471,16 +471,27 @@ function resolveHandoff(repo, handoff) {
|
|
|
471
471
|
}
|
|
472
472
|
|
|
473
473
|
function gitWarnings(repo) {
|
|
474
|
+
// `-z`, so a non-ASCII path is reported as the filesystem spells it rather
|
|
475
|
+
// than as the octal escape Git produces in every other form. The gate's
|
|
476
|
+
// `gitPaths` reads the same way, for the same reason.
|
|
474
477
|
const git = spawnSync(
|
|
475
478
|
"git",
|
|
476
|
-
["status", "--
|
|
479
|
+
["status", "--porcelain=v1", "-z", "--untracked-files=all"],
|
|
477
480
|
{ cwd: repo, encoding: "utf8" }
|
|
478
481
|
);
|
|
479
482
|
if (git.error || git.status !== 0 || !git.stdout.trim()) return [];
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
483
|
+
// Each record is `XY <path>`; a rename or copy adds a second bare field for
|
|
484
|
+
// its other endpoint, which carries no status prefix to strip.
|
|
485
|
+
const fields = git.stdout.split("\0").filter(Boolean);
|
|
486
|
+
const paths = [];
|
|
487
|
+
for (let index = 0; index < fields.length; index += 1) {
|
|
488
|
+
const record = fields[index];
|
|
489
|
+
paths.push(record.slice(3));
|
|
490
|
+
if (record[0] === "R" || record[0] === "C") {
|
|
491
|
+
index += 1;
|
|
492
|
+
if (index < fields.length) paths.push(fields[index]);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
484
495
|
return paths.length > 0
|
|
485
496
|
? [`Working tree has uncommitted paths (selection-neutral): ${paths.join(", ")}`]
|
|
486
497
|
: [];
|
|
@@ -495,11 +506,36 @@ function resolveContext(repo, options) {
|
|
|
495
506
|
context = handoff ? resolveHandoff(repo, handoff) : inferContext(repo);
|
|
496
507
|
}
|
|
497
508
|
context.warnings.push(...gitWarnings(repo));
|
|
509
|
+
// Set here rather than by the caller, so every consumer of the projection —
|
|
510
|
+
// text, JSON, and any host reading it — carries the version without having
|
|
511
|
+
// to know to add it.
|
|
512
|
+
context.keel = keelVersion();
|
|
498
513
|
return context;
|
|
499
514
|
}
|
|
500
515
|
|
|
516
|
+
// The version comparison has to survive a runtime too old to contain it. The
|
|
517
|
+
// SessionStart check shipped in 5.9.0, so a plugin older than that carries no
|
|
518
|
+
// check at all, and its silence is indistinguishable from three versions
|
|
519
|
+
// agreeing — measured 2026-08-02 with plugin 5.7.1, CLI 5.7.0, and protocol
|
|
520
|
+
// 5.12.0, where nothing was reported. An absent mechanism cannot announce
|
|
521
|
+
// itself, so the answer is not another check inside the plugin: the version
|
|
522
|
+
// rides on the surface the protocol already requires an agent to read, and
|
|
523
|
+
// `AGENTS.md` — which is read from the working tree and therefore cannot be
|
|
524
|
+
// stale — asks for it to be reported beside the version the repository
|
|
525
|
+
// declares.
|
|
526
|
+
function keelVersion() {
|
|
527
|
+
try {
|
|
528
|
+
return require(path.join(__dirname, "..", "..", "package.json")).version;
|
|
529
|
+
} catch {
|
|
530
|
+
return "unknown";
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
501
534
|
function renderContext(result) {
|
|
502
535
|
const lines = [
|
|
536
|
+
// First, because it is the provenance of everything under it. A result
|
|
537
|
+
// that does not say which Keel produced it cannot be compared to anything.
|
|
538
|
+
`Keel: ${result.keel || keelVersion()}`,
|
|
503
539
|
`Keel context: ${result.status}`,
|
|
504
540
|
`Next action: ${result.nextAction.kind}`,
|
|
505
541
|
];
|
package/src/core/gates.js
CHANGED
|
@@ -110,9 +110,36 @@ function loadSelection(repo, options, requireTask = true) {
|
|
|
110
110
|
// of the task they just finished. A task that has started records a fingerprint
|
|
111
111
|
// in its Evidence `Contract` anchor, so that anchor is what makes the inference
|
|
112
112
|
// safe — and without one there is nothing for completion to compare against.
|
|
113
|
-
function
|
|
113
|
+
function recordedAnchor(selection, task) {
|
|
114
114
|
const plan = contractAnchorPlan(selection, task);
|
|
115
|
-
return
|
|
115
|
+
return plan ? anchoredFingerprint(plan.previous) : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function hasRecordedAnchor(selection, task) {
|
|
119
|
+
return Boolean(recordedAnchor(selection, task));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The anchor was parsed, shape-checked, and then discarded: completion asked
|
|
123
|
+
// whether sixty-four hex characters were present and never whether they were
|
|
124
|
+
// the ones this task compiles to. `keel context` and `keel guard status` both
|
|
125
|
+
// already compared, and two shipped requirements already described completion
|
|
126
|
+
// as comparing, so this is the one surface that did not do what was written
|
|
127
|
+
// down about it. A digest that matches can only have come from the schema that
|
|
128
|
+
// produced it, so the `keel-task-capsule/v1` prefix is diagnostic detail here
|
|
129
|
+
// rather than a second thing to require.
|
|
130
|
+
function contractDriftProblem(recorded, contract) {
|
|
131
|
+
if (!recorded || recorded === contract.fingerprint.value) return null;
|
|
132
|
+
return problem(
|
|
133
|
+
"contract-drift",
|
|
134
|
+
`The Evidence \`Contract\` anchor records sha256:${recorded}, but this `
|
|
135
|
+
+ `task now compiles to ${contract.fingerprint.algorithm}:`
|
|
136
|
+
+ `${contract.fingerprint.value} under ${contract.schema}. The contract `
|
|
137
|
+
+ "changed after the anchor was recorded, so the authority this task was "
|
|
138
|
+
+ "implemented under is not the authority it is being judged against. "
|
|
139
|
+
+ "Reauthorize with `keel gate task-start --record`, which rewrites the "
|
|
140
|
+
+ "anchor in place; execution evidence produced under the previous "
|
|
141
|
+
+ "contract is stale and has to be cleared or re-verified first."
|
|
142
|
+
);
|
|
116
143
|
}
|
|
117
144
|
|
|
118
145
|
// A task that recorded no anchor has no drift detection at all while presenting
|
|
@@ -173,6 +200,43 @@ function anchoredFingerprint(previous) {
|
|
|
173
200
|
return match ? match[1].toLowerCase() : null;
|
|
174
201
|
}
|
|
175
202
|
|
|
203
|
+
// Two tasks in one change declaring the same Touch set, both driven red-green,
|
|
204
|
+
// are shaped like one behavior split in half. Issue #41 records the case that
|
|
205
|
+
// produced this: a split that passed task-start and the Slice Start Gate and
|
|
206
|
+
// was not executable — implementing the first alone broke a shipping scenario,
|
|
207
|
+
// and once it was right the second had no honest red left.
|
|
208
|
+
//
|
|
209
|
+
// A warning, deliberately, not a `needs-review`. A genuine vertical split can
|
|
210
|
+
// share files, so the shape is a signal rather than a verdict; and there is no
|
|
211
|
+
// way to acknowledge a `needs-review`, so making it one would leave a
|
|
212
|
+
// legitimate split unstartable. The reader is given the other task's id and
|
|
213
|
+
// compares two things, rather than being told something is wrong.
|
|
214
|
+
function taskShapeWarnings(repo, selection, task, compiled) {
|
|
215
|
+
if (!compiled || compiled.diagnostics.length > 0) return [];
|
|
216
|
+
const strategy = compiled.capsule.verification.strategy.toLowerCase();
|
|
217
|
+
if (!RED_GREEN_VERIFICATION_STRATEGIES.has(strategy)) return [];
|
|
218
|
+
const touch = [...compiled.capsule.touch].sort().join("\n");
|
|
219
|
+
if (!touch) return [];
|
|
220
|
+
const matches = [];
|
|
221
|
+
for (const sibling of selection.tasks) {
|
|
222
|
+
if (sibling.id === task.id) continue;
|
|
223
|
+
const other = compileTaskContract(repo, selection.change, sibling);
|
|
224
|
+
if (other.diagnostics.length > 0) continue;
|
|
225
|
+
if ([...other.capsule.touch].sort().join("\n") !== touch) continue;
|
|
226
|
+
matches.push(sibling.id);
|
|
227
|
+
}
|
|
228
|
+
if (matches.length === 0) return [];
|
|
229
|
+
return [
|
|
230
|
+
`Task ${task.id} declares the same Touch set as `
|
|
231
|
+
+ `${matches.join(", ")} and both are driven ${strategy}. Two tasks over `
|
|
232
|
+
+ "the same files under a red-green strategy are often one behavior "
|
|
233
|
+
+ "split in half, where the first half is wrong on its own and the "
|
|
234
|
+
+ "second has no honest red left. This is a prompt, not a verdict — a "
|
|
235
|
+
+ "genuine vertical split can share files. Compare them before "
|
|
236
|
+
+ "implementing.",
|
|
237
|
+
];
|
|
238
|
+
}
|
|
239
|
+
|
|
176
240
|
function taskStart(repo, options) {
|
|
177
241
|
const selection = loadSelection(repo, options);
|
|
178
242
|
const task = selection.selected[0];
|
|
@@ -207,7 +271,7 @@ function taskStart(repo, options) {
|
|
|
207
271
|
selection.change,
|
|
208
272
|
[task.id],
|
|
209
273
|
problems,
|
|
210
|
-
|
|
274
|
+
taskShapeWarnings(repo, selection, task, compiled),
|
|
211
275
|
problems.length === 0
|
|
212
276
|
? compiled
|
|
213
277
|
: null
|
|
@@ -322,6 +386,71 @@ function durableOwnerVerdict(repo, value) {
|
|
|
322
386
|
return { ok: false, reason: "missing", path: candidate[0] };
|
|
323
387
|
}
|
|
324
388
|
|
|
389
|
+
// A finding has three dispositions and the gate recognized two. One found and
|
|
390
|
+
// fixed inside the task recording it has no owner to name and nothing to
|
|
391
|
+
// discard, so the only text that passed was `Discard reason:` — filing a repair
|
|
392
|
+
// as a dismissal. This capability and `keel-review-checklist` both already
|
|
393
|
+
// scoped the ownership requirement to an *unresolved* finding; only the
|
|
394
|
+
// implementation applied it to all of them. `## Invalidates` has carried the
|
|
395
|
+
// same third slot since it shipped, where `Updated by:` names tasks of this
|
|
396
|
+
// change.
|
|
397
|
+
// The capture is the single token after the marker, not the rest of the line.
|
|
398
|
+
// Findings is one line of free prose that normally holds several findings with
|
|
399
|
+
// different dispositions, so a capture reaching to the newline swallows every
|
|
400
|
+
// marker after it — a block recording one fix and one tracker-owned follow-up
|
|
401
|
+
// was refused because the *follow-up's* URL was read as the *fix's* evidence.
|
|
402
|
+
// Measured on this change's own task 1.3. The match is global because each
|
|
403
|
+
// resolved claim owes its own evidence; checking only the first would let a
|
|
404
|
+
// second one assert itself for free.
|
|
405
|
+
const RESOLVED_HERE = /\bresolved here\s*:[ \t]*(\S*)/gi;
|
|
406
|
+
|
|
407
|
+
// Resolution evidence is deliberately narrower than a durable owner. An
|
|
408
|
+
// `http`/`https` reference says someone else will do the work later, which is
|
|
409
|
+
// exactly the durable-owner state; what proves a fix is the check that covers
|
|
410
|
+
// it or the artifact that shows it. A bare marker is refused because a
|
|
411
|
+
// disposition that asserts its own conclusion would be a way out of the other
|
|
412
|
+
// two, and the third state would decay into the easiest exit.
|
|
413
|
+
function resolutionEvidenceVerdict(repo, value, commands) {
|
|
414
|
+
const evidence = String(value || "").trim();
|
|
415
|
+
if (!evidence) return { ok: false, reason: "empty" };
|
|
416
|
+
// The tracker form is tested before the path form: a URL contains something
|
|
417
|
+
// shaped like a path, so leaving it to fall through would refuse
|
|
418
|
+
// `https://…/issues/43` by reporting that `github.com/…/issues/43` is not a
|
|
419
|
+
// file — a true sentence about the wrong thing.
|
|
420
|
+
if (TRACKER_REFERENCE.test(evidence)) return { ok: false, reason: "tracker" };
|
|
421
|
+
const cited = evidence.match(/\bM\d+\b/);
|
|
422
|
+
if (cited) {
|
|
423
|
+
if (commands.includes(cited[0])) return { ok: true };
|
|
424
|
+
return { ok: false, reason: "unknown-check", label: cited[0] };
|
|
425
|
+
}
|
|
426
|
+
const candidate = evidence.match(/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+/);
|
|
427
|
+
if (!candidate) return { ok: false, reason: "unrecognized" };
|
|
428
|
+
if (fs.existsSync(path.join(repo, candidate[0]))) return { ok: true };
|
|
429
|
+
return { ok: false, reason: "missing", path: candidate[0] };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function resolutionEvidenceMessage(verdict) {
|
|
433
|
+
const lead = "Review Findings records a finding as resolved here, but its "
|
|
434
|
+
+ "evidence is not usable — ";
|
|
435
|
+
const tail = " Resolution evidence is an `M<n>` check this task declares, or "
|
|
436
|
+
+ "a repo-relative path that exists.";
|
|
437
|
+
if (verdict.reason === "empty") {
|
|
438
|
+
return `${lead}the marker names nothing at all.${tail}`;
|
|
439
|
+
}
|
|
440
|
+
if (verdict.reason === "tracker") {
|
|
441
|
+
return `${lead}a tracker reference says the work is owned elsewhere, which `
|
|
442
|
+
+ "is the durable-owner state, not a proof that this task fixed it. Write "
|
|
443
|
+
+ `\`Durable owner:\` instead, or name what proves the fix.${tail}`;
|
|
444
|
+
}
|
|
445
|
+
if (verdict.reason === "unknown-check") {
|
|
446
|
+
return `${lead}${verdict.label} is not a check this task declares.${tail}`;
|
|
447
|
+
}
|
|
448
|
+
if (verdict.reason === "missing") {
|
|
449
|
+
return `${lead}\`${verdict.path}\` does not exist.${tail}`;
|
|
450
|
+
}
|
|
451
|
+
return `${lead}it names neither a check nor a path.${tail}`;
|
|
452
|
+
}
|
|
453
|
+
|
|
325
454
|
function findingOwnerIsDurable(repo, findings) {
|
|
326
455
|
if (/keel\/HANDOFF\.md/i.test(findings)) return false;
|
|
327
456
|
if (/\b(?:explicit\s+)?discard (?:reason|rationale)\s*:/i.test(findings)) {
|
|
@@ -346,26 +475,41 @@ function findingOwnerIsDurable(repo, findings) {
|
|
|
346
475
|
);
|
|
347
476
|
}
|
|
348
477
|
|
|
478
|
+
// Read in `-z` form, because every other form escapes. Git octal-escapes any
|
|
479
|
+
// path holding a non-ASCII byte and quotes it; `core.quotepath=false` removes
|
|
480
|
+
// the octal and still quotes a space, a quote, or a backslash; and `status`
|
|
481
|
+
// and `diff` do not agree on which cases they quote. `-z` emits raw bytes in
|
|
482
|
+
// all of them, which deletes the decoding problem rather than adding a
|
|
483
|
+
// decoder — and it is a flag rather than a repository setting, so the answer
|
|
484
|
+
// does not depend on how the repository happens to be configured.
|
|
485
|
+
//
|
|
486
|
+
// Nothing rewrites backslashes here any more. Git emits forward slashes on
|
|
487
|
+
// every platform, so the rewrite normalized a separator that never arrives
|
|
488
|
+
// while turning `\346` into `/346`, which is how a path declared on the first
|
|
489
|
+
// line of Touch was reported as outside Touch (issue #40).
|
|
349
490
|
function gitPaths(repo) {
|
|
350
491
|
const status = spawnSync(
|
|
351
492
|
"git",
|
|
352
|
-
["status", "--
|
|
493
|
+
["status", "--porcelain=v1", "-z", "--untracked-files=all"],
|
|
353
494
|
{ cwd: repo, encoding: "utf8" }
|
|
354
495
|
);
|
|
355
496
|
if (status.error || status.status !== 0) return [];
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
497
|
+
// Each record is `XY <path>`, NUL-terminated. A rename or copy is followed
|
|
498
|
+
// by a second bare field holding its other endpoint — the new path first in
|
|
499
|
+
// `-z`, the reverse of the ` -> ` line format. The order is immaterial:
|
|
500
|
+
// both endpoints are attributed, so a rename whose paths are both in Touch
|
|
501
|
+
// is not a false outside-Touch failure.
|
|
502
|
+
const fields = status.stdout.split("\0").filter(Boolean);
|
|
503
|
+
const paths = [];
|
|
504
|
+
for (let index = 0; index < fields.length; index += 1) {
|
|
505
|
+
const record = fields[index];
|
|
506
|
+
paths.push(record.slice(3));
|
|
507
|
+
if (record[0] === "R" || record[0] === "C") {
|
|
508
|
+
index += 1;
|
|
509
|
+
if (index < fields.length) paths.push(fields[index]);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return paths;
|
|
369
513
|
}
|
|
370
514
|
|
|
371
515
|
function touchEntries(task, contract = null) {
|
|
@@ -386,9 +530,15 @@ function globPattern(value) {
|
|
|
386
530
|
);
|
|
387
531
|
}
|
|
388
532
|
|
|
533
|
+
// No separator rewriting on either side. Git emits forward slashes on every
|
|
534
|
+
// platform, so rewriting backslashes normalized a separator that never
|
|
535
|
+
// arrives — and it did so on the Touch entry and on the candidate, two wrongs
|
|
536
|
+
// that cancelled for a filename holding a literal backslash while making
|
|
537
|
+
// `src/back/slash.js` in Touch match a changed `src/back\slash.js`, a file the
|
|
538
|
+
// task never declared.
|
|
389
539
|
function pathAllowed(candidate, touch) {
|
|
390
540
|
return touch.some((entry) => {
|
|
391
|
-
const normalized = entry.replace(
|
|
541
|
+
const normalized = entry.replace(/^\.\//, "");
|
|
392
542
|
if (normalized.endsWith("/")) return candidate.startsWith(normalized);
|
|
393
543
|
if (normalized.includes("*")) return globPattern(normalized).test(candidate);
|
|
394
544
|
return candidate === normalized;
|
|
@@ -441,16 +591,18 @@ function scopeEvidence(
|
|
|
441
591
|
if (verified.error || verified.status !== 0) {
|
|
442
592
|
throw new GateInputError(`invalid trustworthy Git base: ${base}`);
|
|
443
593
|
}
|
|
594
|
+
// `-z` for the same reason as `gitPaths`: `--name-only` escapes a non-ASCII
|
|
595
|
+
// path to octal, and it does not quote the same cases `status` quotes.
|
|
444
596
|
const diff = spawnSync(
|
|
445
597
|
"git",
|
|
446
|
-
["diff", "--name-only", base, "--"],
|
|
598
|
+
["diff", "--name-only", "-z", base, "--"],
|
|
447
599
|
{ cwd: repo, encoding: "utf8" }
|
|
448
600
|
);
|
|
449
601
|
if (diff.error || diff.status !== 0) {
|
|
450
602
|
throw new GateInputError(`could not compare Git base: ${base}`);
|
|
451
603
|
}
|
|
452
604
|
const changed = new Set([
|
|
453
|
-
...diff.stdout.split(
|
|
605
|
+
...diff.stdout.split("\0").filter(Boolean),
|
|
454
606
|
...dirtyPaths,
|
|
455
607
|
]);
|
|
456
608
|
const touch = touchEntries(task, contract);
|
|
@@ -464,7 +616,6 @@ function scopeEvidence(
|
|
|
464
616
|
const warnings = [];
|
|
465
617
|
const outside = [];
|
|
466
618
|
const candidates = [...changed]
|
|
467
|
-
.map((item) => item.replace(/\\/g, "/"))
|
|
468
619
|
.filter((item) => item !== "keel/guard.json")
|
|
469
620
|
.filter((item) => !(authoringPrefix && item.startsWith(authoringPrefix)))
|
|
470
621
|
.filter((item) => !pathAllowed(item, touch))
|
|
@@ -582,19 +733,37 @@ function completionChecks(repo, task, contract = null) {
|
|
|
582
733
|
`Current-agent Review is incomplete — ${details.join("; ")}.`
|
|
583
734
|
)
|
|
584
735
|
);
|
|
585
|
-
} else if (
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
736
|
+
} else if (!/^none\.?$/i.test(reviewFields.Findings)) {
|
|
737
|
+
// `Resolved here:` is evaluated on its own terms rather than falling
|
|
738
|
+
// through to the owner forms. Otherwise `Resolved here: https://…` would
|
|
739
|
+
// pass as a tracker owner, which is the one reading this disposition must
|
|
740
|
+
// not have: a link to work someone else will do is not evidence that this
|
|
741
|
+
// task did it.
|
|
742
|
+
const resolved = [...reviewFields.Findings.matchAll(RESOLVED_HERE)];
|
|
743
|
+
if (resolved.length > 0) {
|
|
744
|
+
for (const claim of resolved) {
|
|
745
|
+
const verdict = resolutionEvidenceVerdict(repo, claim[1], commands);
|
|
746
|
+
if (verdict.ok) continue;
|
|
747
|
+
problems.push(
|
|
748
|
+
problem("finding-resolution-evidence", resolutionEvidenceMessage(verdict))
|
|
749
|
+
);
|
|
750
|
+
break;
|
|
751
|
+
}
|
|
752
|
+
} else if (!findingOwnerIsDurable(repo, reviewFields.Findings)) {
|
|
753
|
+
problems.push(
|
|
754
|
+
problem(
|
|
755
|
+
"finding-owner",
|
|
756
|
+
"Review Findings must be `none` or carry a disposition. A finding "
|
|
757
|
+
+ "fixed in this task is `Resolved here:` naming an `M<n>` check "
|
|
758
|
+
+ "this task declares or a repo-relative path that exists; one "
|
|
759
|
+
+ "someone must still do is `Durable owner:` naming "
|
|
760
|
+
+ `${DURABLE_OWNER_FORMS}; one deliberately not being done is a `
|
|
761
|
+
+ "`Discard reason:`/`Discard rationale:` prefix. Name a path after "
|
|
762
|
+
+ "`Durable owner:` so it reads as the owner rather than a file the "
|
|
763
|
+
+ "finding mentions."
|
|
764
|
+
)
|
|
765
|
+
);
|
|
766
|
+
}
|
|
598
767
|
}
|
|
599
768
|
return { problems, reviewProblems };
|
|
600
769
|
}
|
|
@@ -625,7 +794,17 @@ function taskComplete(repo, options) {
|
|
|
625
794
|
const checks = completionChecks(repo, task, usableContract);
|
|
626
795
|
checks.problems.push(...contract.diagnostics);
|
|
627
796
|
const missingAnchor = missingAnchorProblem(selection, task);
|
|
628
|
-
if (missingAnchor)
|
|
797
|
+
if (missingAnchor) {
|
|
798
|
+
checks.problems.push(missingAnchor);
|
|
799
|
+
} else if (usableContract) {
|
|
800
|
+
// Only when an anchor exists: a missing one is already its own problem, and
|
|
801
|
+
// reporting drift on top of it would name a comparison that never ran.
|
|
802
|
+
const drift = contractDriftProblem(
|
|
803
|
+
recordedAnchor(selection, task),
|
|
804
|
+
usableContract
|
|
805
|
+
);
|
|
806
|
+
if (drift) checks.problems.push(drift);
|
|
807
|
+
}
|
|
629
808
|
const scope = scopeEvidence(
|
|
630
809
|
repo,
|
|
631
810
|
task,
|
|
@@ -894,6 +1073,28 @@ function changeClose(repo, options) {
|
|
|
894
1073
|
);
|
|
895
1074
|
continue;
|
|
896
1075
|
}
|
|
1076
|
+
// Completion is not the last moment a live change's contract can move. The
|
|
1077
|
+
// window between the final checkbox and the archive was unguarded, at the
|
|
1078
|
+
// one gate whose job is closing that window — and the loop already compiled
|
|
1079
|
+
// every task's capsule, so the comparison costs nothing here either.
|
|
1080
|
+
const anchor = recordedAnchor(selection, task);
|
|
1081
|
+
if (!anchor) {
|
|
1082
|
+
problems.push(
|
|
1083
|
+
problem(
|
|
1084
|
+
"missing-contract-anchor",
|
|
1085
|
+
`Task ${task.id} is checked complete but its Evidence \`Contract\` `
|
|
1086
|
+
+ "anchor holds no compiled fingerprint, so the close has nothing "
|
|
1087
|
+
+ "to compare and cannot verify the contract this task was "
|
|
1088
|
+
+ "completed under. Return it through `keel gate task-start "
|
|
1089
|
+
+ "--record` and `keel gate task-complete` before closing."
|
|
1090
|
+
)
|
|
1091
|
+
);
|
|
1092
|
+
} else if (contract.diagnostics.length === 0) {
|
|
1093
|
+
const drift = contractDriftProblem(anchor, contract);
|
|
1094
|
+
if (drift) {
|
|
1095
|
+
problems.push(problem(drift.code, `Task ${task.id}: ${drift.message}`));
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
897
1098
|
const checks = completionChecks(
|
|
898
1099
|
repo,
|
|
899
1100
|
task,
|
package/src/core/goal.js
CHANGED
|
@@ -47,7 +47,14 @@ function renderCondition(goal) {
|
|
|
47
47
|
`Write boundary (Touch): ${goal.touch.join(", ")}.`,
|
|
48
48
|
"Stop/Autonomy boundary:",
|
|
49
49
|
...goal.stopBoundary.map((item) => `- ${item}`),
|
|
50
|
-
"Ownership: the current agent is the sole
|
|
50
|
+
"Ownership: the current agent is the sole holder of write authority and owns Review, gate invocation, the task checkbox, and completion. A declared delegate writes only inside Touch and the current agent re-runs each check before recording Evidence.",
|
|
51
|
+
...(goal.delegation
|
|
52
|
+
? [
|
|
53
|
+
`Delegation: tier ${goal.delegation.tier}, declared by `
|
|
54
|
+
+ `${goal.delegation.source}. Keel carries the tier and does not `
|
|
55
|
+
+ "select or observe a model.",
|
|
56
|
+
]
|
|
57
|
+
: []),
|
|
51
58
|
"Done only when: task-complete passes and the current agent has durably checked the task; then stop and require a new explicit authorization before any next task.",
|
|
52
59
|
];
|
|
53
60
|
return lines.join("\n");
|
|
@@ -166,6 +173,11 @@ function compileGoalProjection(repo, options) {
|
|
|
166
173
|
owner: capsule.owner,
|
|
167
174
|
ownership: "current-agent-sole-writer",
|
|
168
175
|
helperPolicy: capsule.helperAuthority,
|
|
176
|
+
// Carried inside the condition rather than beside it, so a declared
|
|
177
|
+
// delegation is subject to the same budget as everything else the
|
|
178
|
+
// activation must state. A field that never reaches the condition
|
|
179
|
+
// cannot overflow it, and would then be a boundary the goal omits.
|
|
180
|
+
delegation: capsule.delegation || null,
|
|
169
181
|
terminalStates: TERMINAL_STATES,
|
|
170
182
|
evidencePresentation: EVIDENCE_PRESENTATION,
|
|
171
183
|
authorizationEvidence: {
|
package/src/core/helper.js
CHANGED
|
@@ -56,8 +56,34 @@ function blockedBrief(target, reason, extra = {}) {
|
|
|
56
56
|
};
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
// Symbolic links are resolved on both sides, because `process.cwd()` comes
|
|
60
|
+
// back already resolved while `path.resolve` never follows a link — on macOS,
|
|
61
|
+
// where `/tmp` is a link to `/private/tmp`, that made a path inside the
|
|
62
|
+
// worktree look external and let the helper write its baseline into the
|
|
63
|
+
// repository it had just promised not to touch. The baseline usually does not
|
|
64
|
+
// exist yet, so the nearest existing ancestor is what resolves. The write
|
|
65
|
+
// guard hook answers the same question and keeps its own copy of this rule,
|
|
66
|
+
// because it is a standalone script that cannot import from here.
|
|
67
|
+
function realPathOrNearest(target) {
|
|
68
|
+
let current = path.resolve(target);
|
|
69
|
+
const trailing = [];
|
|
70
|
+
for (;;) {
|
|
71
|
+
try {
|
|
72
|
+
return path.join(fs.realpathSync(current), ...trailing);
|
|
73
|
+
} catch {
|
|
74
|
+
const parent = path.dirname(current);
|
|
75
|
+
if (parent === current) return path.resolve(target);
|
|
76
|
+
trailing.unshift(path.basename(current));
|
|
77
|
+
current = parent;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
59
82
|
function isExternal(repo, candidate) {
|
|
60
|
-
const rel = path.relative(
|
|
83
|
+
const rel = path.relative(
|
|
84
|
+
realPathOrNearest(repo),
|
|
85
|
+
realPathOrNearest(candidate)
|
|
86
|
+
);
|
|
61
87
|
return (
|
|
62
88
|
rel === ".."
|
|
63
89
|
|| rel.startsWith(`..${path.sep}`)
|
package/src/core/projection.js
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
// Keel 4.1.0 one-way native projection contract.
|
|
4
4
|
|
|
5
|
+
const fs = require("fs");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
|
|
8
|
+
const { readDelegationPolicy } = require("./config");
|
|
5
9
|
const { resolveContext } = require("./context");
|
|
6
10
|
const { loadTaskContract } = require("./task-contract");
|
|
7
11
|
const { probeCapabilities } = require("./capabilities");
|
|
@@ -34,6 +38,39 @@ function blocked(target, event, reason, warnings = []) {
|
|
|
34
38
|
};
|
|
35
39
|
}
|
|
36
40
|
|
|
41
|
+
// Both refusals are decided before a delegate starts, never inferred from what
|
|
42
|
+
// it did. An absent manifest passes every write through silently, so a delegate
|
|
43
|
+
// that wrote successfully under one proves nothing about having been checked —
|
|
44
|
+
// there is no observable difference afterwards, which is why the condition has
|
|
45
|
+
// to be answered here.
|
|
46
|
+
function delegationRefusal(repo, delegation) {
|
|
47
|
+
// The policy is read directly rather than through the capsule, because the
|
|
48
|
+
// capsule cannot express the difference this refusal turns on. A tier outside
|
|
49
|
+
// the vocabulary fails closed at the config layer and reaches the capsule as
|
|
50
|
+
// no delegation at all — identical to a repository that declared nothing. One
|
|
51
|
+
// of those should proceed silently and the other must be reported, so the
|
|
52
|
+
// unresolved declaration has to be seen where it still exists.
|
|
53
|
+
const { unknown, accepted } = readDelegationPolicy(repo);
|
|
54
|
+
if (unknown.length > 0) {
|
|
55
|
+
return (
|
|
56
|
+
`Delegation declares tier "${unknown.join(", ")}", which this target `
|
|
57
|
+
+ `does not provide. Accepted: ${accepted.join(", ")}. Keel refuses `
|
|
58
|
+
+ "rather than substituting a tier, because work would otherwise run at "
|
|
59
|
+
+ "a capability nobody declared while reporting success."
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
if (!delegation) return null;
|
|
63
|
+
if (!fs.existsSync(path.join(repo, "keel", "guard.json"))) {
|
|
64
|
+
return (
|
|
65
|
+
"Delegation requires an active write guard, and keel/guard.json is "
|
|
66
|
+
+ "absent. Without it every write passes through unchecked and looks "
|
|
67
|
+
+ "identical to a write the guard allowed. Run `keel gate task-start` "
|
|
68
|
+
+ "for the selected task, then delegate."
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
37
74
|
function capabilityKey(event) {
|
|
38
75
|
if (event === "startup") return "continuity.start";
|
|
39
76
|
if (["resume", "compaction"].includes(event)) return "continuity.reinject";
|
|
@@ -157,6 +194,24 @@ function projectRuntime(repo, options) {
|
|
|
157
194
|
if (event === "subagent-stop") {
|
|
158
195
|
projection.returnAuthority = "report-and-evidence-only";
|
|
159
196
|
}
|
|
197
|
+
// Delegation extends the brief Keel already publishes rather than adding a
|
|
198
|
+
// carrier beside the host's own agent interface. The host spawns; this is the
|
|
199
|
+
// one-way view of OpenSpec it is handed.
|
|
200
|
+
if (event === "subagent-start") {
|
|
201
|
+
const refusal = delegationRefusal(repo, capsule.delegation);
|
|
202
|
+
if (refusal) return blocked(options.target, event, refusal, warnings);
|
|
203
|
+
}
|
|
204
|
+
if (event === "subagent-start" && capsule.delegation) {
|
|
205
|
+
projection.delegation = {
|
|
206
|
+
tier: capsule.delegation.tier,
|
|
207
|
+
source: capsule.delegation.source,
|
|
208
|
+
writeBoundary: capsule.touch,
|
|
209
|
+
note:
|
|
210
|
+
"Keel carries the declared tier and does not select a model; the "
|
|
211
|
+
+ "target resolves it. Keel cannot observe which model executed, so "
|
|
212
|
+
+ "the tier is what is recorded and never a claim about what ran.",
|
|
213
|
+
};
|
|
214
|
+
}
|
|
160
215
|
|
|
161
216
|
return {
|
|
162
217
|
schemaVersion: 1,
|