@christang/keel 5.16.0 → 5.39.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
 
@@ -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
@@ -372,6 +400,35 @@ const DURABLE_OWNER_FORMS =
372
400
  + "repository's own ledger; `keel/HANDOFF.md` is a pointer override rather "
373
401
  + "than an owner";
374
402
 
403
+ // Trailing punctuation a declared path can abut in prose. ASCII sentence marks
404
+ // and their CJK counterparts both belong here: once the extractor stops
405
+ // assuming ASCII, its terminators cannot assume ASCII either. A Chinese
406
+ // sentence ends in `。`, which is not whitespace, so a non-whitespace run
407
+ // swallows it and the gate looks for a file that cannot exist.
408
+ const DECLARED_PATH_TRAILING = /[.,;:!?)\]}"'\u2019\u201d\u3002\uff0c\u3001\uff1b\uff1a\uff01\uff1f\uff09\u3011\u300b\u300d\u300f]+$/;
409
+
410
+ // A declared path is a run of non-whitespace holding a separator. What ends a
411
+ // path is whitespace; what a path is *made of* is the filesystem's business,
412
+ // and answering the first question with the second is what refused
413
+ // `notes/note-006-转岗最难的不是流程/note.md` by reporting that
414
+ // `notes/note-006-` does not exist — a path nobody wrote (issue #60). It is the
415
+ // same class as #40 on the worktree-reading side, which survived because that
416
+ // fix repaired one reader rather than how paths are extracted; this is the one
417
+ // extractor every gate reader of a declared path now uses.
418
+ //
419
+ // The backtick form wins when present. It is the only way to write a path
420
+ // containing whitespace, and `touchEntries` already strips backticks from a
421
+ // Touch entry, so one authorship stops being spelled two ways depending on
422
+ // which reader will read it.
423
+ function declaredPath(value) {
424
+ const text = String(value || "");
425
+ const quoted = text.match(/`([^`\n]*\/[^`\n]*)`/);
426
+ if (quoted) return quoted[1].trim() || null;
427
+ const bare = text.match(/[^\s`]+\/[^\s`]+/);
428
+ if (!bare) return null;
429
+ return bare[0].replace(DECLARED_PATH_TRAILING, "") || null;
430
+ }
431
+
375
432
  // Classify a declared `Durable owner:` value. A gate runs without network, so a
376
433
  // URL is accepted on shape alone; a path is the one form it can actually check,
377
434
  // and checking it is stricter than the prefix whitelist this replaced.
@@ -380,10 +437,10 @@ function durableOwnerVerdict(repo, value) {
380
437
  if (!owner) return { ok: false, reason: "unrecognized" };
381
438
  if (/keel\/HANDOFF\.md/i.test(owner)) return { ok: false, reason: "handoff" };
382
439
  if (TRACKER_REFERENCE.test(owner)) return { ok: true };
383
- const candidate = owner.match(/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+/);
440
+ const candidate = declaredPath(owner);
384
441
  if (!candidate) return { ok: false, reason: "unrecognized" };
385
- if (fs.existsSync(path.join(repo, candidate[0]))) return { ok: true };
386
- return { ok: false, reason: "missing", path: candidate[0] };
442
+ if (fs.existsSync(path.join(repo, candidate))) return { ok: true };
443
+ return { ok: false, reason: "missing", path: candidate };
387
444
  }
388
445
 
389
446
  // A finding has three dispositions and the gate recognized two. One found and
@@ -395,13 +452,15 @@ function durableOwnerVerdict(repo, value) {
395
452
  // same third slot since it shipped, where `Updated by:` names tasks of this
396
453
  // change.
397
454
  // 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.
455
+ // Findings is free prose that normally holds several findings with different
456
+ // dispositions, so a capture reaching to the newline swallows every marker
457
+ // after it — a block recording one fix and one tracker-owned follow-up was
458
+ // refused because the *follow-up's* URL was read as the *fix's* evidence.
459
+ // Measured on this change's own task 1.3. That block used to be one line and
460
+ // may now wrap across several, which widens what a greedy capture would
461
+ // swallow and changes nothing about why this one is narrow. The match is
462
+ // global because each resolved claim owes its own evidence; checking only the
463
+ // first would let a second one assert itself for free.
405
464
  const RESOLVED_HERE = /\bresolved here\s*:[ \t]*(\S*)/gi;
406
465
 
407
466
  // Resolution evidence is deliberately narrower than a durable owner. An
@@ -423,10 +482,10 @@ function resolutionEvidenceVerdict(repo, value, commands) {
423
482
  if (commands.includes(cited[0])) return { ok: true };
424
483
  return { ok: false, reason: "unknown-check", label: cited[0] };
425
484
  }
426
- const candidate = evidence.match(/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+/);
485
+ const candidate = declaredPath(evidence);
427
486
  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] };
487
+ if (fs.existsSync(path.join(repo, candidate))) return { ok: true };
488
+ return { ok: false, reason: "missing", path: candidate };
430
489
  }
431
490
 
432
491
  function resolutionEvidenceMessage(verdict) {
@@ -466,13 +525,12 @@ function findingOwnerIsDurable(repo, findings) {
466
525
  /\b(openspec\/changes\/[A-Za-z0-9][A-Za-z0-9._-]*\/(?:proposal|design|tasks)\.md)(?:#\d+(?:\.\d+)*)?/i
467
526
  );
468
527
  if (artifact && fs.existsSync(path.join(repo, artifact[1]))) return true;
469
- return /\bkeel\/archive\/[A-Za-z0-9._/-]+/i.test(findings)
470
- && fs.existsSync(
471
- path.join(
472
- repo,
473
- findings.match(/\bkeel\/archive\/[A-Za-z0-9._/-]+/i)[0]
474
- )
475
- );
528
+ // Same extractor, scoped to the archive prefix: the segment after
529
+ // `keel/archive/` is a path like any other and was equally ASCII-bound.
530
+ const archive = findings.match(/keel\/archive\/[^\s`]*/i);
531
+ if (!archive) return false;
532
+ const archivePath = declaredPath(archive[0]);
533
+ return Boolean(archivePath) && fs.existsSync(path.join(repo, archivePath));
476
534
  }
477
535
 
478
536
  // Read in `-z` form, because every other form escapes. Git octal-escapes any
@@ -492,6 +550,9 @@ function findingOwnerIsDurable(repo, findings) {
492
550
  // what a manifest written before this field, a cleared guard, or a
493
551
  // `--no-guard` start all produce. Reading null as empty would attribute the
494
552
  // whole worktree to the task and fail every completion in a dirty repository.
553
+ // Each entry is `{ path, sha256 }`, the content signature `contentSignature`
554
+ // read at that same moment — the record of *what* was dirty, not only that a
555
+ // path was.
495
556
  function recordedBaseline(repo, change, task) {
496
557
  const loaded = readManifest(repo);
497
558
  if (loaded.state !== "ok") return null;
@@ -580,20 +641,27 @@ function scopeEvidence(
580
641
  }
581
642
 
582
643
  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.
644
+ // Dirty now and not dirty when the task started, or dirty now with
645
+ // content that no longer matches what was there at task start. Either
646
+ // answers "did this task write it", which is the question the boundary
647
+ // actually asks; it does not answer "which task wrote it", which is why
648
+ // the completed-sibling exclusion below still applies and still reports
649
+ // itself.
587
650
  //
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);
651
+ // A path already dirty at task start is exempt only while its content
652
+ // stays the one recorded then — recording a hash instead of only a name
653
+ // is what lets that hold without falling back to subtracting the whole
654
+ // path, which exempted every later write to it, not just the one that
655
+ // predated the task (#72).
656
+ const unchangedSinceStart = new Set(
657
+ baseline
658
+ .filter((entry) => contentSignature(repo, entry.path) === entry.sha256)
659
+ .map((entry) => entry.path)
660
+ );
593
661
  return attributeChanged(
594
662
  repo,
595
663
  task,
596
- dirtyPaths.filter((item) => !startedDirty.has(item)),
664
+ dirtyPaths.filter((item) => !unchangedSinceStart.has(item)),
597
665
  contract,
598
666
  change,
599
667
  tasks
@@ -673,12 +741,18 @@ function attributeChanged(repo, task, changedList, contract, change, tasks) {
673
741
  };
674
742
  }
675
743
 
676
- function completionChecks(repo, task, contract = null) {
744
+ function completionChecks(repo, task, contract = null, changeVerify = null) {
677
745
  const problems = [];
678
746
  const commands = contract
679
747
  ? contract.capsule.verification.commands.map((item) => item.label)
680
748
  : commandLabels(task);
681
- if (commands.length === 0) {
749
+ // With no contract, the labels came from the expanded v3 `Commands` field,
750
+ // which a compact task never declares — so their absence is a fact about the
751
+ // fallback, not about the task. The compiler's own diagnostics are already in
752
+ // `problems` (the caller pushes them unconditionally, and an unusable
753
+ // contract has at least one), so this cannot turn a refusal into a pass. The
754
+ // per-label evidence checks below stay: a genuine v3 task yields real labels.
755
+ if (contract && commands.length === 0) {
682
756
  problems.push(problem("missing-commands", "Commands must define at least one M<n>."));
683
757
  }
684
758
  for (const label of commands) {
@@ -716,11 +790,65 @@ function completionChecks(repo, task, contract = null) {
716
790
  }
717
791
  }
718
792
  }
793
+ // A `(regression)` check's bare Evidence may defer to a change-level `C<n>`
794
+ // check instead of recording its own result (issue #95). The regression
795
+ // flag comes from the compiled contract, the same source the exemption
796
+ // above already trusts, so this cannot disagree with what `(regression)`
797
+ // itself decided. Resolution only — whether the reference is declared, not
798
+ // whether it has run yet — because at task-complete time it legitimately
799
+ // may not have; `changeVerifyProblems` requires it answered by close.
800
+ if (contract) {
801
+ const declaredLabels = new Set(
802
+ (changeVerify ? changeVerify.checks : []).map((entry) => entry.label)
803
+ );
804
+ for (const entry of contract.capsule.verification.commands) {
805
+ const deferred = deferredChangeCheck(evidenceValue(task, entry.label));
806
+ if (!deferred) continue;
807
+ if (!entry.regression) {
808
+ problems.push(
809
+ problem(
810
+ "deferred-evidence-not-regression",
811
+ `${entry.label} Evidence defers to ${deferred}, but ${entry.label} `
812
+ + "is not tagged `(regression)`; only a `(regression)`-tagged "
813
+ + "check may defer to a change-level check."
814
+ )
815
+ );
816
+ continue;
817
+ }
818
+ if (!declaredLabels.has(deferred)) {
819
+ problems.push(
820
+ problem(
821
+ "deferred-check-unresolved",
822
+ `${entry.label} defers to ${deferred}, but tasks.md's \`## `
823
+ + `Change Verify\` does not declare it. Declare it there, or `
824
+ + `record concrete Evidence for ${entry.label} directly.`
825
+ )
826
+ );
827
+ }
828
+ }
829
+ }
719
830
  const blocker = evidenceValue(task, "Blocker");
720
831
  if (isConcrete(blocker)) {
721
832
  problems.push(problem("blocker", `Task records a blocker: ${blocker}`));
722
833
  }
723
834
 
835
+ // Reauthorizations (#70) is a log, not a stop condition: absent, `none`, and
836
+ // concrete text all pass. Only an abandoned `<slot>` token — real content
837
+ // the author started and never finished — is refused, the same distinction
838
+ // `unfilledToken()` already draws for every other field that uses it.
839
+ const reauthorizationsToken = unfilledToken(reviewValue(task, "Reauthorizations"));
840
+ if (reauthorizationsToken) {
841
+ problems.push(
842
+ problem(
843
+ "reauthorizations-shape",
844
+ `Reauthorizations carries the unfilled slot \`${reauthorizationsToken}\`, `
845
+ + "so it is not concrete. Replace that slot with the value it stands "
846
+ + "for, or fence it in inline code when it is literal text rather "
847
+ + "than a slot left to fill."
848
+ )
849
+ );
850
+ }
851
+
724
852
  const reviewFields = {
725
853
  Status: reviewValue(task, "Status"),
726
854
  "Acceptance check": reviewValue(task, "Acceptance check"),
@@ -783,14 +911,14 @@ function completionChecks(repo, task, contract = null) {
783
911
  problems.push(
784
912
  problem(
785
913
  "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 "
914
+ "Review Findings must be `none` or carry a disposition — name a "
915
+ + "path after `Durable owner:` so it reads as the owner rather "
916
+ + "than a file the finding mentions. A finding fixed in this "
917
+ + "task is `Resolved here:` naming an `M<n>` check this task "
918
+ + "declares or a repo-relative path that exists; one someone "
919
+ + "must still do is `Durable owner:` naming "
790
920
  + `${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."
921
+ + "`Discard reason:`/`Discard rationale:` prefix."
794
922
  )
795
923
  );
796
924
  }
@@ -821,7 +949,8 @@ function taskComplete(repo, options) {
821
949
  }
822
950
  const contract = compileTaskContract(repo, selection.change, task);
823
951
  const usableContract = contract.diagnostics.length === 0 ? contract : null;
824
- const checks = completionChecks(repo, task, usableContract);
952
+ const changeVerify = changeVerifyChecks(selection.content, selection.tasks);
953
+ const checks = completionChecks(repo, task, usableContract, changeVerify);
825
954
  checks.problems.push(...contract.diagnostics);
826
955
  const missingAnchor = missingAnchorProblem(selection, task);
827
956
  if (missingAnchor) {
@@ -861,6 +990,131 @@ function taskComplete(repo, options) {
861
990
  );
862
991
  }
863
992
 
993
+ // The body of a change-level section — `## Invalidates`, `## Expectation
994
+ // Coverage` — ending at the next `##` heading or at the next task, whichever
995
+ // comes first. The heading half alone is the right bound for a document made of
996
+ // headings, and a tasks file is not one: its dominant structure is a list, so a
997
+ // section that is not the file's last one ran over the whole task list and read
998
+ // what the tasks had declared. An `E<n>` line under a task's `Covers` was
999
+ // judged as a coverage entry and reported unclosed; a `repo-action` task's
1000
+ // `Touch` of a bare `- none` was read as the section's `- None.` and closed a
1001
+ // declaration that closed nothing. The first failed loudly and named an entry
1002
+ // that was fine, the second failed silently, and which one an author met
1003
+ // depended only on where they had put the section — a position no template,
1004
+ // diagnostic, or document has ever stated.
1005
+ //
1006
+ // The task half is the task list already parsed for this file rather than a
1007
+ // second checkbox pattern, so it cannot drift from the boundary `parseTasks()`
1008
+ // applies to a task's own body. The heading half stays as it was: the two
1009
+ // spellings are not interchangeable, and unifying them truncates a tail-position
1010
+ // section at an indented `##` line inside its own body, which is this same
1011
+ // defect pointed the other way.
1012
+ function sectionBody(content, headingOffset, tasks) {
1013
+ const lines = content.split(/\r?\n/);
1014
+ const headingLine = content.slice(0, headingOffset).split(/\r?\n/).length - 1;
1015
+ let end = lines.length;
1016
+ for (const task of tasks) {
1017
+ if (task.line > headingLine && task.line < end) end = task.line;
1018
+ }
1019
+ for (let cursor = headingLine + 1; cursor < end; cursor += 1) {
1020
+ if (/^##\s+/.test(lines[cursor])) {
1021
+ end = cursor;
1022
+ break;
1023
+ }
1024
+ }
1025
+ return lines.slice(headingLine + 1, end).join("\n");
1026
+ }
1027
+
1028
+ // A `(regression)` check's bare Evidence may point at a change-level check
1029
+ // instead of recording its own result — issue #95. `deferred to C<n>` is
1030
+ // matched at the front of the value, the same way `Resolved here:`/`Durable
1031
+ // owner:` prefixes are read elsewhere in this file, so a real result that
1032
+ // happens to mention "deferred" mid-sentence is not misread as one.
1033
+ function deferredChangeCheck(value) {
1034
+ const match = String(value || "").trim().match(/^deferred to (C[1-9]\d*)\b/i);
1035
+ return match ? match[1] : null;
1036
+ }
1037
+
1038
+ // `## Change Verify` is a change-level section a `(regression)` check's
1039
+ // Evidence can defer to — a check that only needs to run once for the whole
1040
+ // change instead of once per task. Parsed the same way as `## Invalidates`/
1041
+ // `## Expectation Coverage`: located by heading, bounded by `sectionBody()`.
1042
+ // Absent by default; a change that no task defers in never needs it.
1043
+ function changeVerifyChecks(content, tasks) {
1044
+ const heading = content.search(/^## Change Verify\s*$/m);
1045
+ if (heading < 0) return null;
1046
+ const section = sectionBody(content, heading, tasks);
1047
+ const strategyEntry = section.match(/^\s*-\s*Strategy:\s*(.*)$/im);
1048
+ const checks = [
1049
+ ...section.matchAll(/^\s*-\s*(C[1-9]\d*):\s*(.*)$/gim),
1050
+ ].map((match) => ({ label: match[1], check: match[2].trim() }));
1051
+ return {
1052
+ strategy: strategyEntry ? strategyEntry[1].trim() : "",
1053
+ checks,
1054
+ };
1055
+ }
1056
+
1057
+ // The `## Change Evidence` counterpart to `changeVerifyChecks` — one `C<n>:`
1058
+ // result per declared check, read the same way a task's own `M<n>` Evidence
1059
+ // already is.
1060
+ function changeEvidenceValue(content, tasks, label) {
1061
+ const heading = content.search(/^## Change Evidence\s*$/m);
1062
+ if (heading < 0) return "";
1063
+ const section = sectionBody(content, heading, tasks);
1064
+ const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1065
+ const match = section.match(new RegExp(`^\\s*-\\s*${escaped}:\\s*(.*)$`, "im"));
1066
+ return match ? match[1].trim() : "";
1067
+ }
1068
+
1069
+ // `change-close`-only: `## Change Verify`'s own shape, and completeness of
1070
+ // `## Change Evidence` for every check it declares — whether or not any task
1071
+ // actually defers to it, because a declared check still owes its own result.
1072
+ // Per-task resolution (does a deferred reference resolve at all) runs inside
1073
+ // `completionChecks`, shared by `task-complete` and this loop's own call to
1074
+ // it, so it is not repeated here.
1075
+ function changeVerifyProblems(content, tasks) {
1076
+ const changeVerify = changeVerifyChecks(content, tasks);
1077
+ if (!changeVerify) return [];
1078
+ const problems = [];
1079
+ const labels = changeVerify.checks.map((entry) => entry.label);
1080
+ const expected = labels.map((_, index) => `C${index + 1}`);
1081
+ if (labels.length === 0 || !isConcrete(changeVerify.strategy)) {
1082
+ problems.push(
1083
+ problem(
1084
+ "change-verify-shape",
1085
+ "`## Change Verify` requires a concrete `Strategy:` line and at "
1086
+ + "least one `C<n>:` check."
1087
+ )
1088
+ );
1089
+ } else if (labels.some((label, index) => label !== expected[index])) {
1090
+ problems.push(
1091
+ problem(
1092
+ "change-verify-shape",
1093
+ "`## Change Verify` labels must be contiguous and ordered: "
1094
+ + `expected ${expected.join(", ")}; found ${labels.join(", ")}.`
1095
+ )
1096
+ );
1097
+ }
1098
+ for (const entry of changeVerify.checks) {
1099
+ if (!isConcrete(entry.check)) {
1100
+ problems.push(
1101
+ problem("change-verify-shape", `${entry.label} must define a concrete check.`)
1102
+ );
1103
+ }
1104
+ }
1105
+ for (const entry of changeVerify.checks) {
1106
+ if (!isConcrete(changeEvidenceValue(content, tasks, entry.label))) {
1107
+ problems.push(
1108
+ problem(
1109
+ "change-evidence-missing",
1110
+ `Missing concrete \`## Change Evidence\` for ${entry.label}.`
1111
+ )
1112
+ );
1113
+ }
1114
+ }
1115
+ return problems;
1116
+ }
1117
+
864
1118
  // Follow-up Ownership governs work a change left undone. This is the opposite
865
1119
  // shape: statements left standing by work the change completed. It is asked at
866
1120
  // task-start rather than change-close because the whole value is that the
@@ -885,10 +1139,7 @@ function invalidationProblems(repo, content, tasks) {
885
1139
  ),
886
1140
  ];
887
1141
  }
888
- const bodyStart = content.indexOf("\n", heading);
889
- const remainder = bodyStart < 0 ? "" : content.slice(bodyStart + 1);
890
- const nextHeading = remainder.search(/^##\s+/m);
891
- const section = nextHeading < 0 ? remainder : remainder.slice(0, nextHeading);
1142
+ const section = sectionBody(content, heading, tasks);
892
1143
  if (/^\s*-\s+None\.?\s*$/im.test(section)) return [];
893
1144
  const entries = [
894
1145
  ...section.matchAll(
@@ -988,10 +1239,7 @@ function expectationProblems(repo, content, tasks) {
988
1239
  ),
989
1240
  ];
990
1241
  }
991
- const bodyStart = content.indexOf("\n", heading);
992
- const remainder = bodyStart < 0 ? "" : content.slice(bodyStart + 1);
993
- const nextHeading = remainder.search(/^##\s+/m);
994
- const section = nextHeading < 0 ? remainder : remainder.slice(0, nextHeading);
1242
+ const section = sectionBody(content, heading, tasks);
995
1243
  if (/^\s*-\s+None\.?\s*$/im.test(section)) return [];
996
1244
  const entries = [
997
1245
  ...section.matchAll(
@@ -1083,6 +1331,7 @@ function changeClose(repo, options) {
1083
1331
  const problems = [];
1084
1332
  const reviewProblems = [];
1085
1333
  const contracts = [];
1334
+ const changeVerify = changeVerifyChecks(selection.content, selection.tasks);
1086
1335
  if (selection.tasks.length === 0) {
1087
1336
  problems.push(problem("missing-tasks", "Change has no executable tasks."));
1088
1337
  }
@@ -1128,7 +1377,8 @@ function changeClose(repo, options) {
1128
1377
  const checks = completionChecks(
1129
1378
  repo,
1130
1379
  task,
1131
- contract.diagnostics.length === 0 ? contract : null
1380
+ contract.diagnostics.length === 0 ? contract : null,
1381
+ changeVerify
1132
1382
  );
1133
1383
  problems.push(
1134
1384
  ...checks.problems.map((item) =>
@@ -1142,6 +1392,7 @@ function changeClose(repo, options) {
1142
1392
  );
1143
1393
  }
1144
1394
  problems.push(...expectationProblems(repo, selection.content, selection.tasks));
1395
+ problems.push(...changeVerifyProblems(selection.content, selection.tasks));
1145
1396
 
1146
1397
  const changePath = path.dirname(selection.tasksPath);
1147
1398
  if (!hasDeltaSpec(changePath)) {
package/src/core/guard.js CHANGED
@@ -59,6 +59,20 @@ function sha256(buffer) {
59
59
  return crypto.createHash("sha256").update(buffer).digest("hex");
60
60
  }
61
61
 
62
+ // The content a dirty path held at the moment it was read, or `null` when
63
+ // nothing could be read — a deleted path, mid-rename, or one that never
64
+ // existed. `null` is a signature like any other: it round-trips through the
65
+ // same equality check a real hash does, so a path that stays absent compares
66
+ // equal and one that gets created or deleted compares unequal, with no
67
+ // special case for either direction.
68
+ function contentSignature(repo, relative) {
69
+ try {
70
+ return sha256(fs.readFileSync(path.join(repo, relative)));
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
62
76
  function guardResult(subcommand, status, extra = {}) {
63
77
  return {
64
78
  schemaVersion: 1,
@@ -68,17 +82,17 @@ function guardResult(subcommand, status, extra = {}) {
68
82
  manifestPath: "keel/guard.json",
69
83
  problems: [],
70
84
  warnings: [
71
- "The guard manifest is a disposable enforcement pointer; OpenSpec and "
72
- + "Git remain the only durable authority and selection never derives "
85
+ "The guard manifest is a disposable enforcement pointer, not durable "
86
+ + "authority — OpenSpec and Git are, and selection never derives "
73
87
  + "from it.",
74
88
  // The status describes a file Keel wrote. Whether anything reads that
75
89
  // file is a target-side fact: enforcement runs as a runtime hook the
76
90
  // host loads, and a host that loaded different plugins keeps them for
77
91
  // the life of its session. Reporting `started` as though it were a probe
78
92
  // result is the same inference `--doctor` already refuses to make.
79
- "This status describes the manifest only. Enforcement runs as a runtime "
80
- + "hook in the host, which Keel cannot observe from the repository, so "
81
- + "a written manifest is not evidence that any write was checked.",
93
+ "This describes the manifest only. Enforcement runs as a runtime hook "
94
+ + "Keel cannot observe, so a written manifest proves no write was "
95
+ + "checked.",
82
96
  ],
83
97
  ...extra,
84
98
  };
@@ -161,13 +175,25 @@ function readManifest(repo) {
161
175
  // written by a Keel that omits it, are both valid; what they are not is
162
176
  // evidence that nothing was dirty. The consumer distinguishes absent from
163
177
  // empty, so an empty list means "nothing was dirty" and an absent one means
164
- // "nobody looked".
178
+ // "nobody looked". Each entry carries the path's content signature, not
179
+ // just its name, so completion can tell "still the content recorded at
180
+ // task start" from "dirty again for a different reason" — `sha256` is
181
+ // `null` for a path that had nothing to read.
165
182
  if (
166
183
  manifest.startedDirty !== undefined
167
184
  && (!Array.isArray(manifest.startedDirty)
168
- || manifest.startedDirty.some((item) => typeof item !== "string"))
185
+ || manifest.startedDirty.some(
186
+ (item) =>
187
+ !item
188
+ || typeof item.path !== "string"
189
+ || !item.path
190
+ || (item.sha256 !== null
191
+ && !/^[0-9a-f]{64}$/.test(String(item.sha256 || "")))
192
+ ))
169
193
  ) {
170
- shapeErrors.push("startedDirty must be a string list when present");
194
+ shapeErrors.push(
195
+ "startedDirty must be a list of hashed dirty paths when present"
196
+ );
171
197
  }
172
198
  if (shapeErrors.length > 0) {
173
199
  return {
@@ -231,8 +257,14 @@ function startGuard(repo, options) {
231
257
 
232
258
  const paths = authorityPaths(repo, options.change, loaded.contract);
233
259
  // Read before the manifest is written, so the manifest is never in its own
234
- // record and cannot be attributed to the task it authorizes.
235
- const startedDirty = gitPaths(repo);
260
+ // record and cannot be attributed to the task it authorizes. Each dirty
261
+ // path is hashed at this same moment, so a later comparison can tell
262
+ // whether the task changed it again rather than only whether it stayed
263
+ // dirty.
264
+ const startedDirty = gitPaths(repo).map((relative) => ({
265
+ path: relative,
266
+ sha256: contentSignature(repo, relative),
267
+ }));
236
268
  const manifest = {
237
269
  schema: MANIFEST_SCHEMA,
238
270
  change: options.change,
@@ -265,12 +297,40 @@ function guardStatus(repo) {
265
297
  const problems = [];
266
298
  const loaded = loadTaskContract(repo, manifest.change, manifest.task);
267
299
  if (!loaded) {
268
- problems.push({
269
- code: "authority-drift",
270
- message:
271
- `Guarded task ${manifest.change}#${manifest.task} no longer resolves; `
272
- + "reauthorize through `keel gate task-start` and `keel guard start`.",
273
- });
300
+ // `loadTaskContract` returns null for two unrelated reasons — the tasks
301
+ // file is not there, or the task id is not in it — and only one of them
302
+ // has a reauthorization to perform. Telling the reader to reauthorize a
303
+ // change that has been archived sends them to `keel gate task-start`,
304
+ // which reports a missing tasks file, and to `keel guard start`, which
305
+ // reports that the task does not exist; neither names `keel guard clear`,
306
+ // which is the only action that resolves it.
307
+ //
308
+ // The change *directory* is the test, not the tasks file, and it is the
309
+ // same object `plugins/keel/scripts/pretooluse-guard.js` tests for the
310
+ // same question. Two surfaces deciding it by different means would
311
+ // eventually disagree about a state a reader is looking at from both. It
312
+ // also leaves a live change whose tasks.md is absent — mid-authoring — on
313
+ // the reauthorize path, where reauthorizing genuinely is the way out.
314
+ const changeDir = path.join(repo, "openspec", "changes", manifest.change);
315
+ problems.push(
316
+ fs.existsSync(changeDir)
317
+ ? {
318
+ code: "authority-drift",
319
+ message:
320
+ `Guarded task ${manifest.change}#${manifest.task} no longer `
321
+ + "resolves; reauthorize through `keel gate task-start` and "
322
+ + "`keel guard start`.",
323
+ }
324
+ : {
325
+ code: "stale-manifest",
326
+ message:
327
+ `This manifest is stale: it guards ${manifest.change}`
328
+ + `#${manifest.task}, but openspec/changes/${manifest.change} no `
329
+ + "longer exists, so the task it names cannot be reauthorized and "
330
+ + "its Touch list authorizes nothing. Run `keel guard clear`, then "
331
+ + "start the task you are actually working on.",
332
+ }
333
+ );
274
334
  const drifted = guardResult("status", "drifted", { manifest });
275
335
  drifted.problems = problems;
276
336
  return drifted;
@@ -354,6 +414,7 @@ module.exports = {
354
414
  GuardInputError,
355
415
  MANIFEST_SCHEMA,
356
416
  clearGuard,
417
+ contentSignature,
357
418
  gitPaths,
358
419
  guardStatus,
359
420
  readManifest,