@christang/keel 5.7.0 → 5.16.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 +180 -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 +26 -2
- 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 +9382 -5446
- package/src/core/config.js +58 -0
- package/src/core/context.js +41 -5
- package/src/core/gates.js +277 -46
- package/src/core/goal.js +13 -1
- package/src/core/guard.js +53 -0
- 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
|
@@ -14,7 +14,7 @@ const {
|
|
|
14
14
|
isPassingReviewStatus,
|
|
15
15
|
parseTasks,
|
|
16
16
|
} = require("./task-contract");
|
|
17
|
-
const { startGuard } = require("./guard");
|
|
17
|
+
const { gitPaths, readManifest, startGuard } = require("./guard");
|
|
18
18
|
|
|
19
19
|
const GATE_STAGES = new Set(["task-start", "task-complete", "change-close"]);
|
|
20
20
|
|
|
@@ -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,29 @@ function findingOwnerIsDurable(repo, findings) {
|
|
|
346
475
|
);
|
|
347
476
|
}
|
|
348
477
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
+
// `gitPaths` moved to guard.js, which owns the worktree reading now that the
|
|
487
|
+
// task-start record and this comparison must use the same one.
|
|
488
|
+
|
|
489
|
+
// The dirty set recorded when this task was authorized, or null when nobody
|
|
490
|
+
// recorded one. Null and empty are different answers: an empty list says
|
|
491
|
+
// nothing was dirty at task start, and null says no record exists, which is
|
|
492
|
+
// what a manifest written before this field, a cleared guard, or a
|
|
493
|
+
// `--no-guard` start all produce. Reading null as empty would attribute the
|
|
494
|
+
// whole worktree to the task and fail every completion in a dirty repository.
|
|
495
|
+
function recordedBaseline(repo, change, task) {
|
|
496
|
+
const loaded = readManifest(repo);
|
|
497
|
+
if (loaded.state !== "ok") return null;
|
|
498
|
+
const manifest = loaded.manifest;
|
|
499
|
+
if (manifest.change !== change || manifest.task !== task) return null;
|
|
500
|
+
return Array.isArray(manifest.startedDirty) ? manifest.startedDirty : null;
|
|
369
501
|
}
|
|
370
502
|
|
|
371
503
|
function touchEntries(task, contract = null) {
|
|
@@ -386,9 +518,15 @@ function globPattern(value) {
|
|
|
386
518
|
);
|
|
387
519
|
}
|
|
388
520
|
|
|
521
|
+
// No separator rewriting on either side. Git emits forward slashes on every
|
|
522
|
+
// platform, so rewriting backslashes normalized a separator that never
|
|
523
|
+
// arrives — and it did so on the Touch entry and on the candidate, two wrongs
|
|
524
|
+
// that cancelled for a filename holding a literal backslash while making
|
|
525
|
+
// `src/back/slash.js` in Touch match a changed `src/back\slash.js`, a file the
|
|
526
|
+
// task never declared.
|
|
389
527
|
function pathAllowed(candidate, touch) {
|
|
390
528
|
return touch.some((entry) => {
|
|
391
|
-
const normalized = entry.replace(
|
|
529
|
+
const normalized = entry.replace(/^\.\//, "");
|
|
392
530
|
if (normalized.endsWith("/")) return candidate.startsWith(normalized);
|
|
393
531
|
if (normalized.includes("*")) return globPattern(normalized).test(candidate);
|
|
394
532
|
return candidate === normalized;
|
|
@@ -420,7 +558,15 @@ function scopeEvidence(
|
|
|
420
558
|
tasks = null
|
|
421
559
|
) {
|
|
422
560
|
const dirtyPaths = gitPaths(repo);
|
|
423
|
-
|
|
561
|
+
// An explicit base wins. It asks a broader question than the record does —
|
|
562
|
+
// everything since that commit, not only since this task started — and
|
|
563
|
+
// substituting the narrower answer would make `--base` mean something other
|
|
564
|
+
// than what it says.
|
|
565
|
+
const baseline = base ? null : recordedBaseline(repo, change, task.id);
|
|
566
|
+
if (!base && !baseline) {
|
|
567
|
+
// No base and no record: the original conservatism, and the reason for it
|
|
568
|
+
// is unchanged. Git alone cannot say which task of a half-finished change
|
|
569
|
+
// wrote a given path, so the dirty state stays semantic review evidence.
|
|
424
570
|
return {
|
|
425
571
|
problems: [],
|
|
426
572
|
warnings:
|
|
@@ -433,6 +579,27 @@ function scopeEvidence(
|
|
|
433
579
|
};
|
|
434
580
|
}
|
|
435
581
|
|
|
582
|
+
if (!base) {
|
|
583
|
+
// Dirty now and not dirty when the task started. This answers "did this
|
|
584
|
+
// task write it", which is the question the boundary actually asks; it
|
|
585
|
+
// does not answer "which task wrote it", which is why the completed-
|
|
586
|
+
// sibling exclusion below still applies and still reports itself.
|
|
587
|
+
//
|
|
588
|
+
// A path already dirty at task start is subtracted even if the task also
|
|
589
|
+
// modified it. That is the price of a baseline that is not a commit, and
|
|
590
|
+
// it buys the far larger class this exists to avoid: failing every
|
|
591
|
+
// completion in a worktree that was dirty before the task began.
|
|
592
|
+
const startedDirty = new Set(baseline);
|
|
593
|
+
return attributeChanged(
|
|
594
|
+
repo,
|
|
595
|
+
task,
|
|
596
|
+
dirtyPaths.filter((item) => !startedDirty.has(item)),
|
|
597
|
+
contract,
|
|
598
|
+
change,
|
|
599
|
+
tasks
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
|
|
436
603
|
const verified = spawnSync(
|
|
437
604
|
"git",
|
|
438
605
|
["rev-parse", "--verify", `${base}^{commit}`],
|
|
@@ -441,18 +608,33 @@ function scopeEvidence(
|
|
|
441
608
|
if (verified.error || verified.status !== 0) {
|
|
442
609
|
throw new GateInputError(`invalid trustworthy Git base: ${base}`);
|
|
443
610
|
}
|
|
611
|
+
// `-z` for the same reason as `gitPaths`: `--name-only` escapes a non-ASCII
|
|
612
|
+
// path to octal, and it does not quote the same cases `status` quotes.
|
|
444
613
|
const diff = spawnSync(
|
|
445
614
|
"git",
|
|
446
|
-
["diff", "--name-only", base, "--"],
|
|
615
|
+
["diff", "--name-only", "-z", base, "--"],
|
|
447
616
|
{ cwd: repo, encoding: "utf8" }
|
|
448
617
|
);
|
|
449
618
|
if (diff.error || diff.status !== 0) {
|
|
450
619
|
throw new GateInputError(`could not compare Git base: ${base}`);
|
|
451
620
|
}
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
621
|
+
return attributeChanged(
|
|
622
|
+
repo,
|
|
623
|
+
task,
|
|
624
|
+
[...diff.stdout.split("\0").filter(Boolean), ...dirtyPaths],
|
|
625
|
+
contract,
|
|
626
|
+
change,
|
|
627
|
+
tasks
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// The one place a candidate path becomes a problem, whichever comparison
|
|
632
|
+
// produced it. Both callers reach it: the recorded-baseline path and the
|
|
633
|
+
// explicit-base path differ only in how they decide which paths are
|
|
634
|
+
// candidates, and a second copy of this would be a second definition of what
|
|
635
|
+
// Touch means.
|
|
636
|
+
function attributeChanged(repo, task, changedList, contract, change, tasks) {
|
|
637
|
+
const changed = new Set(changedList);
|
|
456
638
|
const touch = touchEntries(task, contract);
|
|
457
639
|
// The disposable guard manifest is the one artifact the gate contract itself
|
|
458
640
|
// permits a gate to write, and the selected change's own authoring artifacts
|
|
@@ -464,7 +646,6 @@ function scopeEvidence(
|
|
|
464
646
|
const warnings = [];
|
|
465
647
|
const outside = [];
|
|
466
648
|
const candidates = [...changed]
|
|
467
|
-
.map((item) => item.replace(/\\/g, "/"))
|
|
468
649
|
.filter((item) => item !== "keel/guard.json")
|
|
469
650
|
.filter((item) => !(authoringPrefix && item.startsWith(authoringPrefix)))
|
|
470
651
|
.filter((item) => !pathAllowed(item, touch))
|
|
@@ -582,19 +763,37 @@ function completionChecks(repo, task, contract = null) {
|
|
|
582
763
|
`Current-agent Review is incomplete — ${details.join("; ")}.`
|
|
583
764
|
)
|
|
584
765
|
);
|
|
585
|
-
} else if (
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
766
|
+
} else if (!/^none\.?$/i.test(reviewFields.Findings)) {
|
|
767
|
+
// `Resolved here:` is evaluated on its own terms rather than falling
|
|
768
|
+
// through to the owner forms. Otherwise `Resolved here: https://…` would
|
|
769
|
+
// pass as a tracker owner, which is the one reading this disposition must
|
|
770
|
+
// not have: a link to work someone else will do is not evidence that this
|
|
771
|
+
// task did it.
|
|
772
|
+
const resolved = [...reviewFields.Findings.matchAll(RESOLVED_HERE)];
|
|
773
|
+
if (resolved.length > 0) {
|
|
774
|
+
for (const claim of resolved) {
|
|
775
|
+
const verdict = resolutionEvidenceVerdict(repo, claim[1], commands);
|
|
776
|
+
if (verdict.ok) continue;
|
|
777
|
+
problems.push(
|
|
778
|
+
problem("finding-resolution-evidence", resolutionEvidenceMessage(verdict))
|
|
779
|
+
);
|
|
780
|
+
break;
|
|
781
|
+
}
|
|
782
|
+
} else if (!findingOwnerIsDurable(repo, reviewFields.Findings)) {
|
|
783
|
+
problems.push(
|
|
784
|
+
problem(
|
|
785
|
+
"finding-owner",
|
|
786
|
+
"Review Findings must be `none` or carry a disposition. A finding "
|
|
787
|
+
+ "fixed in this task is `Resolved here:` naming an `M<n>` check "
|
|
788
|
+
+ "this task declares or a repo-relative path that exists; one "
|
|
789
|
+
+ "someone must still do is `Durable owner:` naming "
|
|
790
|
+
+ `${DURABLE_OWNER_FORMS}; one deliberately not being done is a `
|
|
791
|
+
+ "`Discard reason:`/`Discard rationale:` prefix. Name a path after "
|
|
792
|
+
+ "`Durable owner:` so it reads as the owner rather than a file the "
|
|
793
|
+
+ "finding mentions."
|
|
794
|
+
)
|
|
795
|
+
);
|
|
796
|
+
}
|
|
598
797
|
}
|
|
599
798
|
return { problems, reviewProblems };
|
|
600
799
|
}
|
|
@@ -625,7 +824,17 @@ function taskComplete(repo, options) {
|
|
|
625
824
|
const checks = completionChecks(repo, task, usableContract);
|
|
626
825
|
checks.problems.push(...contract.diagnostics);
|
|
627
826
|
const missingAnchor = missingAnchorProblem(selection, task);
|
|
628
|
-
if (missingAnchor)
|
|
827
|
+
if (missingAnchor) {
|
|
828
|
+
checks.problems.push(missingAnchor);
|
|
829
|
+
} else if (usableContract) {
|
|
830
|
+
// Only when an anchor exists: a missing one is already its own problem, and
|
|
831
|
+
// reporting drift on top of it would name a comparison that never ran.
|
|
832
|
+
const drift = contractDriftProblem(
|
|
833
|
+
recordedAnchor(selection, task),
|
|
834
|
+
usableContract
|
|
835
|
+
);
|
|
836
|
+
if (drift) checks.problems.push(drift);
|
|
837
|
+
}
|
|
629
838
|
const scope = scopeEvidence(
|
|
630
839
|
repo,
|
|
631
840
|
task,
|
|
@@ -894,6 +1103,28 @@ function changeClose(repo, options) {
|
|
|
894
1103
|
);
|
|
895
1104
|
continue;
|
|
896
1105
|
}
|
|
1106
|
+
// Completion is not the last moment a live change's contract can move. The
|
|
1107
|
+
// window between the final checkbox and the archive was unguarded, at the
|
|
1108
|
+
// one gate whose job is closing that window — and the loop already compiled
|
|
1109
|
+
// every task's capsule, so the comparison costs nothing here either.
|
|
1110
|
+
const anchor = recordedAnchor(selection, task);
|
|
1111
|
+
if (!anchor) {
|
|
1112
|
+
problems.push(
|
|
1113
|
+
problem(
|
|
1114
|
+
"missing-contract-anchor",
|
|
1115
|
+
`Task ${task.id} is checked complete but its Evidence \`Contract\` `
|
|
1116
|
+
+ "anchor holds no compiled fingerprint, so the close has nothing "
|
|
1117
|
+
+ "to compare and cannot verify the contract this task was "
|
|
1118
|
+
+ "completed under. Return it through `keel gate task-start "
|
|
1119
|
+
+ "--record` and `keel gate task-complete` before closing."
|
|
1120
|
+
)
|
|
1121
|
+
);
|
|
1122
|
+
} else if (contract.diagnostics.length === 0) {
|
|
1123
|
+
const drift = contractDriftProblem(anchor, contract);
|
|
1124
|
+
if (drift) {
|
|
1125
|
+
problems.push(problem(drift.code, `Task ${task.id}: ${drift.message}`));
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
897
1128
|
const checks = completionChecks(
|
|
898
1129
|
repo,
|
|
899
1130
|
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: {
|