@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.
@@ -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,
@@ -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", "--short", "--untracked-files=all"],
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
- const paths = git.stdout
481
- .split(/\r?\n/)
482
- .filter(Boolean)
483
- .map((line) => line.slice(3).trim());
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 hasRecordedAnchor(selection, task) {
113
+ function recordedAnchor(selection, task) {
114
114
  const plan = contractAnchorPlan(selection, task);
115
- return Boolean(plan && anchoredFingerprint(plan.previous));
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
- function gitPaths(repo) {
350
- const status = spawnSync(
351
- "git",
352
- ["status", "--short", "--untracked-files=all"],
353
- { cwd: repo, encoding: "utf8" }
354
- );
355
- if (status.error || status.status !== 0) return [];
356
- return status.stdout
357
- .split(/\r?\n/)
358
- .filter(Boolean)
359
- .flatMap((line) => {
360
- // A staged rename/copy is one porcelain line, `R old -> new`; attribute
361
- // both endpoints so a rename whose old and new paths are in Touch is not
362
- // a false outside-Touch failure. Every other line carries one path.
363
- const entry = line.slice(3).trim().replace(/\\/g, "/");
364
- const arrow = entry.indexOf(" -> ");
365
- return arrow === -1
366
- ? [entry]
367
- : [entry.slice(0, arrow), entry.slice(arrow + 4)];
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(/\\/g, "/").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
- if (!base) {
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
- const changed = new Set([
453
- ...diff.stdout.split(/\r?\n/).filter(Boolean),
454
- ...dirtyPaths,
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
- !/^none\.?$/i.test(reviewFields.Findings)
587
- && !findingOwnerIsDurable(repo, reviewFields.Findings)
588
- ) {
589
- problems.push(
590
- problem(
591
- "finding-owner",
592
- "Review Findings must be `none` or carry a durable owner — a "
593
- + "`Discard reason:`/`Discard rationale:` prefix, or "
594
- + `${DURABLE_OWNER_FORMS}. Name a path after \`Durable owner:\` so `
595
- + "it reads as the owner rather than a file the finding mentions."
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) checks.problems.push(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 writer and owns Review, gate invocation, the task checkbox, and completion.",
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: {