@christang/keel 5.20.0 → 5.44.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/src/core/gates.js CHANGED
@@ -13,8 +13,9 @@ const {
13
13
  isConcrete,
14
14
  isPassingReviewStatus,
15
15
  parseTasks,
16
+ unfilledToken,
16
17
  } = require("./task-contract");
17
- const { gitPaths, readManifest, startGuard } = require("./guard");
18
+ const { contentSignature, gitPaths, readManifest, startGuard } = require("./guard");
18
19
 
19
20
  const GATE_STAGES = new Set(["task-start", "task-complete", "change-close"]);
20
21
 
@@ -243,7 +244,7 @@ function taskStart(repo, options) {
243
244
  const compiled = compileTaskContract(repo, selection.change, task);
244
245
  const problems = [
245
246
  ...compiled.diagnostics,
246
- ...invalidationProblems(repo, selection.content, selection.tasks),
247
+ ...invalidationProblems(repo, selection.content, selection.tasks, selection.change),
247
248
  ];
248
249
  // Recording the current fingerprint is idempotent: --record replaces the
249
250
  // selected task's Contract anchor whatever it holds, so reauthorizing a task
@@ -349,11 +350,38 @@ function evidenceValue(task, label) {
349
350
  return match ? match[1] : "";
350
351
  }
351
352
 
353
+ // A Review entry is the text the author wrote under its label, not its first
354
+ // line. `parseTasks()` already gathers the whole Evidence body, so the
355
+ // continuation lines arrive here; a line-anchored `(.*)` used to drop them
356
+ // before any check saw them. That failed in both directions: a `Findings` whose
357
+ // `Durable owner:` sat on the fourth line was refused with the owner present
358
+ // and the path existing, and a `Findings` reading `none` above three lines of
359
+ // real findings passed, because the check tested the word and never saw them
360
+ // (issue #49).
361
+ //
362
+ // The entry ends at the next entry at the same or shallower indentation.
363
+ // Without that bound `Findings` — always the last of the four — would run to
364
+ // the end of Evidence and read `- Blocker:` as its own text, trading a
365
+ // fail-closed defect for a fail-open one. A deeper-indented `- ` line is a
366
+ // continuation, which is what makes a Findings written as a sub-list one entry.
367
+ const REVIEW_SIBLING = /^(\s*)-\s*[^\s:][^:\n]*:/;
368
+
352
369
  function reviewValue(task, label) {
353
- const match = field(task, "Evidence").match(
354
- new RegExp(`^\\s*-\\s*${label}:\\s*(.*)$`, "im")
355
- );
356
- return match ? match[1].trim() : "";
370
+ const lines = field(task, "Evidence").split(/\r?\n/);
371
+ const opener = new RegExp(`^(\\s*)-\\s*${label}:\\s*(.*)$`, "i");
372
+ for (let index = 0; index < lines.length; index += 1) {
373
+ const match = lines[index].match(opener);
374
+ if (!match) continue;
375
+ const indent = match[1].length;
376
+ const value = [match[2]];
377
+ for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
378
+ const sibling = lines[cursor].match(REVIEW_SIBLING);
379
+ if (sibling && sibling[1].length <= indent) break;
380
+ value.push(lines[cursor]);
381
+ }
382
+ return value.join("\n").trim();
383
+ }
384
+ return "";
357
385
  }
358
386
 
359
387
  // The durable-owner forms that are pure shape checks, shared by the Review
@@ -366,11 +394,21 @@ const TRACKER_REFERENCE = /\bhttps?:\/\/\S/i;
366
394
 
367
395
  // The owner forms, stated once so every refusal that lists them agrees with
368
396
  // every other and with what the checks below actually accept.
397
+ //
398
+ // What each form is worth is part of the sentence, because a list of accepted
399
+ // spellings reads as a list of verified guarantees. A path is checked for
400
+ // existence at the moment it is cited and never again; a tracker reference is
401
+ // accepted on its shape, because a gate that fetched one would stop being
402
+ // local and offline, which is the property its verdict rests on. Leaving that
403
+ // unsaid is how an author comes to believe a check ran that did not (#100).
369
404
  const DURABLE_OWNER_FORMS =
370
405
  "an absolute `https://…` tracker reference, or any repo-relative path that "
371
- + "exists — `keel/archive/…`, an `openspec/changes/…` artifact, or the "
372
- + "repository's own ledger; `keel/HANDOFF.md` is a pointer override rather "
373
- + "than an owner";
406
+ + "exists and outlives this change — an archived `openspec/changes/archive/…` "
407
+ + "artifact, `keel/archive/…`, or the repository's own ledger; "
408
+ + "`keel/HANDOFF.md` is a pointer override rather than an owner. A path is "
409
+ + "checked for existence when it is cited and is not re-checked afterwards, "
410
+ + "and a tracker reference is accepted on its shape because a gate runs "
411
+ + "offline and never fetches one";
374
412
 
375
413
  // Trailing punctuation a declared path can abut in prose. ASCII sentence marks
376
414
  // and their CJK counterparts both belong here: once the extractor stops
@@ -401,20 +439,55 @@ function declaredPath(value) {
401
439
  return bare[0].replace(DECLARED_PATH_TRAILING, "") || null;
402
440
  }
403
441
 
442
+ // A path inside the selected change's own directory exists now and cannot
443
+ // exist later: archiving moves `openspec/changes/<name>/` under
444
+ // `openspec/changes/archive/`, so the one guarantee the gate offers expires in
445
+ // the next step of the workflow that accepted it. Measured in this repository,
446
+ // 10 declarations name such a path and all 10 are dead; the field report
447
+ // measured 35 of 36 (issue #100). Existence is necessary and not sufficient —
448
+ // the same line `keel/HANDOFF.md` already sits on.
449
+ //
450
+ // The rule is the directory, not the file: every file under it moves together,
451
+ // and naming `design.md` would refuse one spelling of one instance. And it is
452
+ // *this* change's directory, not change directories in general — the protocol
453
+ // names a new OpenSpec change as a legitimate owner of deferred work, and no
454
+ // measured pointer has that shape.
455
+ function insideOwnChangeDirectory(candidate, change) {
456
+ if (!change || !candidate) return false;
457
+ const prefix = `openspec/changes/${change}/`;
458
+ return String(candidate).replace(/^\.\//, "").startsWith(prefix);
459
+ }
460
+
404
461
  // Classify a declared `Durable owner:` value. A gate runs without network, so a
405
462
  // URL is accepted on shape alone; a path is the one form it can actually check,
406
- // and checking it is stricter than the prefix whitelist this replaced.
407
- function durableOwnerVerdict(repo, value) {
463
+ // and checking it is stricter than the prefix whitelist this replaced — for as
464
+ // long as the path outlives the change, which `insideOwnChangeDirectory` is
465
+ // there to decide.
466
+ function durableOwnerVerdict(repo, value, change) {
408
467
  const owner = String(value || "").trim();
409
468
  if (!owner) return { ok: false, reason: "unrecognized" };
410
469
  if (/keel\/HANDOFF\.md/i.test(owner)) return { ok: false, reason: "handoff" };
411
470
  if (TRACKER_REFERENCE.test(owner)) return { ok: true };
412
471
  const candidate = declaredPath(owner);
413
472
  if (!candidate) return { ok: false, reason: "unrecognized" };
473
+ if (insideOwnChangeDirectory(candidate, change)) {
474
+ return { ok: false, reason: "transient", path: candidate };
475
+ }
414
476
  if (fs.existsSync(path.join(repo, candidate))) return { ok: true };
415
477
  return { ok: false, reason: "missing", path: candidate };
416
478
  }
417
479
 
480
+ // The one sentence every transient refusal makes, so the three consumers say
481
+ // it the same way. It names the cause (the directory moves) rather than the
482
+ // symptom, because the author cannot repair a spelling problem they do not
483
+ // have.
484
+ function transientOwnerMessage(candidate) {
485
+ return `\`${candidate}\` is inside this change's own directory, which moves `
486
+ + "to `openspec/changes/archive/` when the change is archived — the file "
487
+ + "exists now and the pointer is guaranteed to break. Name something that "
488
+ + `outlives the change: ${DURABLE_OWNER_FORMS}.`;
489
+ }
490
+
418
491
  // A finding has three dispositions and the gate recognized two. One found and
419
492
  // fixed inside the task recording it has no owner to name and nothing to
420
493
  // discard, so the only text that passed was `Discard reason:` — filing a repair
@@ -424,13 +497,15 @@ function durableOwnerVerdict(repo, value) {
424
497
  // same third slot since it shipped, where `Updated by:` names tasks of this
425
498
  // change.
426
499
  // The capture is the single token after the marker, not the rest of the line.
427
- // Findings is one line of free prose that normally holds several findings with
428
- // different dispositions, so a capture reaching to the newline swallows every
429
- // marker after it — a block recording one fix and one tracker-owned follow-up
430
- // was refused because the *follow-up's* URL was read as the *fix's* evidence.
431
- // Measured on this change's own task 1.3. The match is global because each
432
- // resolved claim owes its own evidence; checking only the first would let a
433
- // second one assert itself for free.
500
+ // Findings is free prose that normally holds several findings with different
501
+ // dispositions, so a capture reaching to the newline swallows every marker
502
+ // after it — a block recording one fix and one tracker-owned follow-up was
503
+ // refused because the *follow-up's* URL was read as the *fix's* evidence.
504
+ // Measured on this change's own task 1.3. That block used to be one line and
505
+ // may now wrap across several, which widens what a greedy capture would
506
+ // swallow and changes nothing about why this one is narrow. The match is
507
+ // global because each resolved claim owes its own evidence; checking only the
508
+ // first would let a second one assert itself for free.
434
509
  const RESOLVED_HERE = /\bresolved here\s*:[ \t]*(\S*)/gi;
435
510
 
436
511
  // Resolution evidence is deliberately narrower than a durable owner. An
@@ -439,7 +514,7 @@ const RESOLVED_HERE = /\bresolved here\s*:[ \t]*(\S*)/gi;
439
514
  // it or the artifact that shows it. A bare marker is refused because a
440
515
  // disposition that asserts its own conclusion would be a way out of the other
441
516
  // two, and the third state would decay into the easiest exit.
442
- function resolutionEvidenceVerdict(repo, value, commands) {
517
+ function resolutionEvidenceVerdict(repo, value, commands, change) {
443
518
  const evidence = String(value || "").trim();
444
519
  if (!evidence) return { ok: false, reason: "empty" };
445
520
  // The tracker form is tested before the path form: a URL contains something
@@ -454,6 +529,12 @@ function resolutionEvidenceVerdict(repo, value, commands) {
454
529
  }
455
530
  const candidate = declaredPath(evidence);
456
531
  if (!candidate) return { ok: false, reason: "unrecognized" };
532
+ // Resolution evidence is a file like any other and moves with the directory
533
+ // holding it, so it earns the same verdict rather than a second answer to
534
+ // the same question.
535
+ if (insideOwnChangeDirectory(candidate, change)) {
536
+ return { ok: false, reason: "transient", path: candidate };
537
+ }
457
538
  if (fs.existsSync(path.join(repo, candidate))) return { ok: true };
458
539
  return { ok: false, reason: "missing", path: candidate };
459
540
  }
@@ -474,13 +555,16 @@ function resolutionEvidenceMessage(verdict) {
474
555
  if (verdict.reason === "unknown-check") {
475
556
  return `${lead}${verdict.label} is not a check this task declares.${tail}`;
476
557
  }
558
+ if (verdict.reason === "transient") {
559
+ return `${lead}${transientOwnerMessage(verdict.path)}${tail}`;
560
+ }
477
561
  if (verdict.reason === "missing") {
478
562
  return `${lead}\`${verdict.path}\` does not exist.${tail}`;
479
563
  }
480
564
  return `${lead}it names neither a check nor a path.${tail}`;
481
565
  }
482
566
 
483
- function findingOwnerIsDurable(repo, findings) {
567
+ function findingOwnerIsDurable(repo, findings, change) {
484
568
  if (/keel\/HANDOFF\.md/i.test(findings)) return false;
485
569
  if (/\b(?:explicit\s+)?discard (?:reason|rationale)\s*:/i.test(findings)) {
486
570
  return true;
@@ -490,11 +574,17 @@ function findingOwnerIsDurable(repo, findings) {
490
574
  // prose, and a finding that merely mentions the source file it concerns has
491
575
  // not thereby given that finding an owner.
492
576
  const declared = findings.match(/Durable owner:\s*(\S[^\n]*)/i);
493
- if (declared) return durableOwnerVerdict(repo, declared[1]).ok;
577
+ if (declared) return durableOwnerVerdict(repo, declared[1], change).ok;
494
578
  const artifact = findings.match(
495
579
  /\b(openspec\/changes\/[A-Za-z0-9][A-Za-z0-9._-]*\/(?:proposal|design|tasks)\.md)(?:#\d+(?:\.\d+)*)?/i
496
580
  );
497
- if (artifact && fs.existsSync(path.join(repo, artifact[1]))) return true;
581
+ if (
582
+ artifact
583
+ && !insideOwnChangeDirectory(artifact[1], change)
584
+ && fs.existsSync(path.join(repo, artifact[1]))
585
+ ) {
586
+ return true;
587
+ }
498
588
  // Same extractor, scoped to the archive prefix: the segment after
499
589
  // `keel/archive/` is a path like any other and was equally ASCII-bound.
500
590
  const archive = findings.match(/keel\/archive\/[^\s`]*/i);
@@ -520,6 +610,9 @@ function findingOwnerIsDurable(repo, findings) {
520
610
  // what a manifest written before this field, a cleared guard, or a
521
611
  // `--no-guard` start all produce. Reading null as empty would attribute the
522
612
  // whole worktree to the task and fail every completion in a dirty repository.
613
+ // Each entry is `{ path, sha256 }`, the content signature `contentSignature`
614
+ // read at that same moment — the record of *what* was dirty, not only that a
615
+ // path was.
523
616
  function recordedBaseline(repo, change, task) {
524
617
  const loaded = readManifest(repo);
525
618
  if (loaded.state !== "ok") return null;
@@ -608,20 +701,27 @@ function scopeEvidence(
608
701
  }
609
702
 
610
703
  if (!base) {
611
- // Dirty now and not dirty when the task started. This answers "did this
612
- // task write it", which is the question the boundary actually asks; it
613
- // does not answer "which task wrote it", which is why the completed-
614
- // sibling exclusion below still applies and still reports itself.
704
+ // Dirty now and not dirty when the task started, or dirty now with
705
+ // content that no longer matches what was there at task start. Either
706
+ // answers "did this task write it", which is the question the boundary
707
+ // actually asks; it does not answer "which task wrote it", which is why
708
+ // the completed-sibling exclusion below still applies and still reports
709
+ // itself.
615
710
  //
616
- // A path already dirty at task start is subtracted even if the task also
617
- // modified it. That is the price of a baseline that is not a commit, and
618
- // it buys the far larger class this exists to avoid: failing every
619
- // completion in a worktree that was dirty before the task began.
620
- const startedDirty = new Set(baseline);
711
+ // A path already dirty at task start is exempt only while its content
712
+ // stays the one recorded then recording a hash instead of only a name
713
+ // is what lets that hold without falling back to subtracting the whole
714
+ // path, which exempted every later write to it, not just the one that
715
+ // predated the task (#72).
716
+ const unchangedSinceStart = new Set(
717
+ baseline
718
+ .filter((entry) => contentSignature(repo, entry.path) === entry.sha256)
719
+ .map((entry) => entry.path)
720
+ );
621
721
  return attributeChanged(
622
722
  repo,
623
723
  task,
624
- dirtyPaths.filter((item) => !startedDirty.has(item)),
724
+ dirtyPaths.filter((item) => !unchangedSinceStart.has(item)),
625
725
  contract,
626
726
  change,
627
727
  tasks
@@ -701,7 +801,7 @@ function attributeChanged(repo, task, changedList, contract, change, tasks) {
701
801
  };
702
802
  }
703
803
 
704
- function completionChecks(repo, task, contract = null) {
804
+ function completionChecks(repo, task, contract = null, changeVerify = null, change = null) {
705
805
  const problems = [];
706
806
  const commands = contract
707
807
  ? contract.capsule.verification.commands.map((item) => item.label)
@@ -750,11 +850,65 @@ function completionChecks(repo, task, contract = null) {
750
850
  }
751
851
  }
752
852
  }
853
+ // A `(regression)` check's bare Evidence may defer to a change-level `C<n>`
854
+ // check instead of recording its own result (issue #95). The regression
855
+ // flag comes from the compiled contract, the same source the exemption
856
+ // above already trusts, so this cannot disagree with what `(regression)`
857
+ // itself decided. Resolution only — whether the reference is declared, not
858
+ // whether it has run yet — because at task-complete time it legitimately
859
+ // may not have; `changeVerifyProblems` requires it answered by close.
860
+ if (contract) {
861
+ const declaredLabels = new Set(
862
+ (changeVerify ? changeVerify.checks : []).map((entry) => entry.label)
863
+ );
864
+ for (const entry of contract.capsule.verification.commands) {
865
+ const deferred = deferredChangeCheck(evidenceValue(task, entry.label));
866
+ if (!deferred) continue;
867
+ if (!entry.regression) {
868
+ problems.push(
869
+ problem(
870
+ "deferred-evidence-not-regression",
871
+ `${entry.label} Evidence defers to ${deferred}, but ${entry.label} `
872
+ + "is not tagged `(regression)`; only a `(regression)`-tagged "
873
+ + "check may defer to a change-level check."
874
+ )
875
+ );
876
+ continue;
877
+ }
878
+ if (!declaredLabels.has(deferred)) {
879
+ problems.push(
880
+ problem(
881
+ "deferred-check-unresolved",
882
+ `${entry.label} defers to ${deferred}, but tasks.md's \`## `
883
+ + `Change Verify\` does not declare it. Declare it there, or `
884
+ + `record concrete Evidence for ${entry.label} directly.`
885
+ )
886
+ );
887
+ }
888
+ }
889
+ }
753
890
  const blocker = evidenceValue(task, "Blocker");
754
891
  if (isConcrete(blocker)) {
755
892
  problems.push(problem("blocker", `Task records a blocker: ${blocker}`));
756
893
  }
757
894
 
895
+ // Reauthorizations (#70) is a log, not a stop condition: absent, `none`, and
896
+ // concrete text all pass. Only an abandoned `<slot>` token — real content
897
+ // the author started and never finished — is refused, the same distinction
898
+ // `unfilledToken()` already draws for every other field that uses it.
899
+ const reauthorizationsToken = unfilledToken(reviewValue(task, "Reauthorizations"));
900
+ if (reauthorizationsToken) {
901
+ problems.push(
902
+ problem(
903
+ "reauthorizations-shape",
904
+ `Reauthorizations carries the unfilled slot \`${reauthorizationsToken}\`, `
905
+ + "so it is not concrete. Replace that slot with the value it stands "
906
+ + "for, or fence it in inline code when it is literal text rather "
907
+ + "than a slot left to fill."
908
+ )
909
+ );
910
+ }
911
+
758
912
  const reviewFields = {
759
913
  Status: reviewValue(task, "Status"),
760
914
  "Acceptance check": reviewValue(task, "Acceptance check"),
@@ -806,25 +960,25 @@ function completionChecks(repo, task, contract = null) {
806
960
  const resolved = [...reviewFields.Findings.matchAll(RESOLVED_HERE)];
807
961
  if (resolved.length > 0) {
808
962
  for (const claim of resolved) {
809
- const verdict = resolutionEvidenceVerdict(repo, claim[1], commands);
963
+ const verdict = resolutionEvidenceVerdict(repo, claim[1], commands, change);
810
964
  if (verdict.ok) continue;
811
965
  problems.push(
812
966
  problem("finding-resolution-evidence", resolutionEvidenceMessage(verdict))
813
967
  );
814
968
  break;
815
969
  }
816
- } else if (!findingOwnerIsDurable(repo, reviewFields.Findings)) {
970
+ } else if (!findingOwnerIsDurable(repo, reviewFields.Findings, change)) {
817
971
  problems.push(
818
972
  problem(
819
973
  "finding-owner",
820
- "Review Findings must be `none` or carry a disposition. A finding "
821
- + "fixed in this task is `Resolved here:` naming an `M<n>` check "
822
- + "this task declares or a repo-relative path that exists; one "
823
- + "someone must still do is `Durable owner:` naming "
974
+ "Review Findings must be `none` or carry a disposition name a "
975
+ + "path after `Durable owner:` so it reads as the owner rather "
976
+ + "than a file the finding mentions. A finding fixed in this "
977
+ + "task is `Resolved here:` naming an `M<n>` check this task "
978
+ + "declares or a repo-relative path that exists; one someone "
979
+ + "must still do is `Durable owner:` naming "
824
980
  + `${DURABLE_OWNER_FORMS}; one deliberately not being done is a `
825
- + "`Discard reason:`/`Discard rationale:` prefix. Name a path after "
826
- + "`Durable owner:` so it reads as the owner rather than a file the "
827
- + "finding mentions."
981
+ + "`Discard reason:`/`Discard rationale:` prefix."
828
982
  )
829
983
  );
830
984
  }
@@ -855,7 +1009,14 @@ function taskComplete(repo, options) {
855
1009
  }
856
1010
  const contract = compileTaskContract(repo, selection.change, task);
857
1011
  const usableContract = contract.diagnostics.length === 0 ? contract : null;
858
- const checks = completionChecks(repo, task, usableContract);
1012
+ const changeVerify = changeVerifyChecks(selection.content, selection.tasks);
1013
+ const checks = completionChecks(
1014
+ repo,
1015
+ task,
1016
+ usableContract,
1017
+ changeVerify,
1018
+ selection.change
1019
+ );
859
1020
  checks.problems.push(...contract.diagnostics);
860
1021
  const missingAnchor = missingAnchorProblem(selection, task);
861
1022
  if (missingAnchor) {
@@ -895,6 +1056,131 @@ function taskComplete(repo, options) {
895
1056
  );
896
1057
  }
897
1058
 
1059
+ // The body of a change-level section — `## Invalidates`, `## Expectation
1060
+ // Coverage` — ending at the next `##` heading or at the next task, whichever
1061
+ // comes first. The heading half alone is the right bound for a document made of
1062
+ // headings, and a tasks file is not one: its dominant structure is a list, so a
1063
+ // section that is not the file's last one ran over the whole task list and read
1064
+ // what the tasks had declared. An `E<n>` line under a task's `Covers` was
1065
+ // judged as a coverage entry and reported unclosed; a `repo-action` task's
1066
+ // `Touch` of a bare `- none` was read as the section's `- None.` and closed a
1067
+ // declaration that closed nothing. The first failed loudly and named an entry
1068
+ // that was fine, the second failed silently, and which one an author met
1069
+ // depended only on where they had put the section — a position no template,
1070
+ // diagnostic, or document has ever stated.
1071
+ //
1072
+ // The task half is the task list already parsed for this file rather than a
1073
+ // second checkbox pattern, so it cannot drift from the boundary `parseTasks()`
1074
+ // applies to a task's own body. The heading half stays as it was: the two
1075
+ // spellings are not interchangeable, and unifying them truncates a tail-position
1076
+ // section at an indented `##` line inside its own body, which is this same
1077
+ // defect pointed the other way.
1078
+ function sectionBody(content, headingOffset, tasks) {
1079
+ const lines = content.split(/\r?\n/);
1080
+ const headingLine = content.slice(0, headingOffset).split(/\r?\n/).length - 1;
1081
+ let end = lines.length;
1082
+ for (const task of tasks) {
1083
+ if (task.line > headingLine && task.line < end) end = task.line;
1084
+ }
1085
+ for (let cursor = headingLine + 1; cursor < end; cursor += 1) {
1086
+ if (/^##\s+/.test(lines[cursor])) {
1087
+ end = cursor;
1088
+ break;
1089
+ }
1090
+ }
1091
+ return lines.slice(headingLine + 1, end).join("\n");
1092
+ }
1093
+
1094
+ // A `(regression)` check's bare Evidence may point at a change-level check
1095
+ // instead of recording its own result — issue #95. `deferred to C<n>` is
1096
+ // matched at the front of the value, the same way `Resolved here:`/`Durable
1097
+ // owner:` prefixes are read elsewhere in this file, so a real result that
1098
+ // happens to mention "deferred" mid-sentence is not misread as one.
1099
+ function deferredChangeCheck(value) {
1100
+ const match = String(value || "").trim().match(/^deferred to (C[1-9]\d*)\b/i);
1101
+ return match ? match[1] : null;
1102
+ }
1103
+
1104
+ // `## Change Verify` is a change-level section a `(regression)` check's
1105
+ // Evidence can defer to — a check that only needs to run once for the whole
1106
+ // change instead of once per task. Parsed the same way as `## Invalidates`/
1107
+ // `## Expectation Coverage`: located by heading, bounded by `sectionBody()`.
1108
+ // Absent by default; a change that no task defers in never needs it.
1109
+ function changeVerifyChecks(content, tasks) {
1110
+ const heading = content.search(/^## Change Verify\s*$/m);
1111
+ if (heading < 0) return null;
1112
+ const section = sectionBody(content, heading, tasks);
1113
+ const strategyEntry = section.match(/^\s*-\s*Strategy:\s*(.*)$/im);
1114
+ const checks = [
1115
+ ...section.matchAll(/^\s*-\s*(C[1-9]\d*):\s*(.*)$/gim),
1116
+ ].map((match) => ({ label: match[1], check: match[2].trim() }));
1117
+ return {
1118
+ strategy: strategyEntry ? strategyEntry[1].trim() : "",
1119
+ checks,
1120
+ };
1121
+ }
1122
+
1123
+ // The `## Change Evidence` counterpart to `changeVerifyChecks` — one `C<n>:`
1124
+ // result per declared check, read the same way a task's own `M<n>` Evidence
1125
+ // already is.
1126
+ function changeEvidenceValue(content, tasks, label) {
1127
+ const heading = content.search(/^## Change Evidence\s*$/m);
1128
+ if (heading < 0) return "";
1129
+ const section = sectionBody(content, heading, tasks);
1130
+ const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1131
+ const match = section.match(new RegExp(`^\\s*-\\s*${escaped}:\\s*(.*)$`, "im"));
1132
+ return match ? match[1].trim() : "";
1133
+ }
1134
+
1135
+ // `change-close`-only: `## Change Verify`'s own shape, and completeness of
1136
+ // `## Change Evidence` for every check it declares — whether or not any task
1137
+ // actually defers to it, because a declared check still owes its own result.
1138
+ // Per-task resolution (does a deferred reference resolve at all) runs inside
1139
+ // `completionChecks`, shared by `task-complete` and this loop's own call to
1140
+ // it, so it is not repeated here.
1141
+ function changeVerifyProblems(content, tasks) {
1142
+ const changeVerify = changeVerifyChecks(content, tasks);
1143
+ if (!changeVerify) return [];
1144
+ const problems = [];
1145
+ const labels = changeVerify.checks.map((entry) => entry.label);
1146
+ const expected = labels.map((_, index) => `C${index + 1}`);
1147
+ if (labels.length === 0 || !isConcrete(changeVerify.strategy)) {
1148
+ problems.push(
1149
+ problem(
1150
+ "change-verify-shape",
1151
+ "`## Change Verify` requires a concrete `Strategy:` line and at "
1152
+ + "least one `C<n>:` check."
1153
+ )
1154
+ );
1155
+ } else if (labels.some((label, index) => label !== expected[index])) {
1156
+ problems.push(
1157
+ problem(
1158
+ "change-verify-shape",
1159
+ "`## Change Verify` labels must be contiguous and ordered: "
1160
+ + `expected ${expected.join(", ")}; found ${labels.join(", ")}.`
1161
+ )
1162
+ );
1163
+ }
1164
+ for (const entry of changeVerify.checks) {
1165
+ if (!isConcrete(entry.check)) {
1166
+ problems.push(
1167
+ problem("change-verify-shape", `${entry.label} must define a concrete check.`)
1168
+ );
1169
+ }
1170
+ }
1171
+ for (const entry of changeVerify.checks) {
1172
+ if (!isConcrete(changeEvidenceValue(content, tasks, entry.label))) {
1173
+ problems.push(
1174
+ problem(
1175
+ "change-evidence-missing",
1176
+ `Missing concrete \`## Change Evidence\` for ${entry.label}.`
1177
+ )
1178
+ );
1179
+ }
1180
+ }
1181
+ return problems;
1182
+ }
1183
+
898
1184
  // Follow-up Ownership governs work a change left undone. This is the opposite
899
1185
  // shape: statements left standing by work the change completed. It is asked at
900
1186
  // task-start rather than change-close because the whole value is that the
@@ -905,7 +1191,7 @@ function taskComplete(repo, options) {
905
1191
  // the author was not already holding in mind, so a list of remembered files
906
1192
  // reproduces the failure; a searchable phrase is what turns the declaration
907
1193
  // into a grep. What the phrase says is the agent's judgment, not the gate's.
908
- function invalidationProblems(repo, content, tasks) {
1194
+ function invalidationProblems(repo, content, tasks, change) {
909
1195
  const heading = content.search(/^## Invalidates\s*$/m);
910
1196
  if (heading < 0) {
911
1197
  return [
@@ -919,10 +1205,7 @@ function invalidationProblems(repo, content, tasks) {
919
1205
  ),
920
1206
  ];
921
1207
  }
922
- const bodyStart = content.indexOf("\n", heading);
923
- const remainder = bodyStart < 0 ? "" : content.slice(bodyStart + 1);
924
- const nextHeading = remainder.search(/^##\s+/m);
925
- const section = nextHeading < 0 ? remainder : remainder.slice(0, nextHeading);
1208
+ const section = sectionBody(content, heading, tasks);
926
1209
  if (/^\s*-\s+None\.?\s*$/im.test(section)) return [];
927
1210
  const entries = [
928
1211
  ...section.matchAll(
@@ -956,7 +1239,7 @@ function invalidationProblems(repo, content, tasks) {
956
1239
  const updated = body.match(/Updated by:\s*([0-9.,\s-]+)/i);
957
1240
  const declaredOwner = body.match(/Durable owner:\s*(\S[^\n]*)/i);
958
1241
  const verdict = declaredOwner
959
- ? durableOwnerVerdict(repo, declaredOwner[1])
1242
+ ? durableOwnerVerdict(repo, declaredOwner[1], change)
960
1243
  : { ok: false, reason: "absent" };
961
1244
  const discarded = /Discard(?:ed)? (?:reason|rationale):\s*\S/i.test(body);
962
1245
  if (!updated && !verdict.ok && !discarded) {
@@ -968,6 +1251,13 @@ function invalidationProblems(repo, content, tasks) {
968
1251
  + "file exists in this repository."
969
1252
  )
970
1253
  );
1254
+ } else if (verdict.reason === "transient") {
1255
+ problems.push(
1256
+ problem(
1257
+ "invalidation-owner-transient",
1258
+ `${id} names ${transientOwnerMessage(verdict.path)}`
1259
+ )
1260
+ );
971
1261
  } else if (verdict.reason === "handoff") {
972
1262
  problems.push(
973
1263
  problem(
@@ -1010,7 +1300,7 @@ function invalidationProblems(repo, content, tasks) {
1010
1300
  return problems;
1011
1301
  }
1012
1302
 
1013
- function expectationProblems(repo, content, tasks) {
1303
+ function expectationProblems(repo, content, tasks, change) {
1014
1304
  const heading = content.search(/^## Expectation Coverage\s*$/m);
1015
1305
  if (heading < 0) {
1016
1306
  return [
@@ -1022,10 +1312,7 @@ function expectationProblems(repo, content, tasks) {
1022
1312
  ),
1023
1313
  ];
1024
1314
  }
1025
- const bodyStart = content.indexOf("\n", heading);
1026
- const remainder = bodyStart < 0 ? "" : content.slice(bodyStart + 1);
1027
- const nextHeading = remainder.search(/^##\s+/m);
1028
- const section = nextHeading < 0 ? remainder : remainder.slice(0, nextHeading);
1315
+ const section = sectionBody(content, heading, tasks);
1029
1316
  if (/^\s*-\s+None\.?\s*$/im.test(section)) return [];
1030
1317
  const entries = [
1031
1318
  ...section.matchAll(
@@ -1049,7 +1336,7 @@ function expectationProblems(repo, content, tasks) {
1049
1336
  const covered = body.match(/Covered by:\s*([0-9.,\s-]+)/i);
1050
1337
  const declaredOwner = body.match(/Durable owner:\s*(\S[^\n]*)/i);
1051
1338
  const verdict = declaredOwner
1052
- ? durableOwnerVerdict(repo, declaredOwner[1])
1339
+ ? durableOwnerVerdict(repo, declaredOwner[1], change)
1053
1340
  : { ok: false, reason: "absent" };
1054
1341
  const discarded = /Discard(?:ed)? (?:reason|rationale):\s*\S/i.test(body);
1055
1342
  if (!covered && !verdict.ok && !discarded) {
@@ -1061,6 +1348,13 @@ function expectationProblems(repo, content, tasks) {
1061
1348
  + "file exists in this repository."
1062
1349
  )
1063
1350
  );
1351
+ } else if (verdict.reason === "transient") {
1352
+ problems.push(
1353
+ problem(
1354
+ "expectation-owner-transient",
1355
+ `${id} names ${transientOwnerMessage(verdict.path)}`
1356
+ )
1357
+ );
1064
1358
  } else {
1065
1359
  problems.push(
1066
1360
  problem(
@@ -1117,6 +1411,7 @@ function changeClose(repo, options) {
1117
1411
  const problems = [];
1118
1412
  const reviewProblems = [];
1119
1413
  const contracts = [];
1414
+ const changeVerify = changeVerifyChecks(selection.content, selection.tasks);
1120
1415
  if (selection.tasks.length === 0) {
1121
1416
  problems.push(problem("missing-tasks", "Change has no executable tasks."));
1122
1417
  }
@@ -1162,7 +1457,9 @@ function changeClose(repo, options) {
1162
1457
  const checks = completionChecks(
1163
1458
  repo,
1164
1459
  task,
1165
- contract.diagnostics.length === 0 ? contract : null
1460
+ contract.diagnostics.length === 0 ? contract : null,
1461
+ changeVerify,
1462
+ selection.change
1166
1463
  );
1167
1464
  problems.push(
1168
1465
  ...checks.problems.map((item) =>
@@ -1175,7 +1472,10 @@ function changeClose(repo, options) {
1175
1472
  )
1176
1473
  );
1177
1474
  }
1178
- problems.push(...expectationProblems(repo, selection.content, selection.tasks));
1475
+ problems.push(
1476
+ ...expectationProblems(repo, selection.content, selection.tasks, selection.change)
1477
+ );
1478
+ problems.push(...changeVerifyProblems(selection.content, selection.tasks));
1179
1479
 
1180
1480
  const changePath = path.dirname(selection.tasksPath);
1181
1481
  if (!hasDeltaSpec(changePath)) {