@christang/keel 5.20.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
@@ -424,13 +452,15 @@ function durableOwnerVerdict(repo, value) {
424
452
  // same third slot since it shipped, where `Updated by:` names tasks of this
425
453
  // change.
426
454
  // 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.
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.
434
464
  const RESOLVED_HERE = /\bresolved here\s*:[ \t]*(\S*)/gi;
435
465
 
436
466
  // Resolution evidence is deliberately narrower than a durable owner. An
@@ -520,6 +550,9 @@ function findingOwnerIsDurable(repo, findings) {
520
550
  // what a manifest written before this field, a cleared guard, or a
521
551
  // `--no-guard` start all produce. Reading null as empty would attribute the
522
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.
523
556
  function recordedBaseline(repo, change, task) {
524
557
  const loaded = readManifest(repo);
525
558
  if (loaded.state !== "ok") return null;
@@ -608,20 +641,27 @@ function scopeEvidence(
608
641
  }
609
642
 
610
643
  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.
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.
615
650
  //
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);
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
+ );
621
661
  return attributeChanged(
622
662
  repo,
623
663
  task,
624
- dirtyPaths.filter((item) => !startedDirty.has(item)),
664
+ dirtyPaths.filter((item) => !unchangedSinceStart.has(item)),
625
665
  contract,
626
666
  change,
627
667
  tasks
@@ -701,7 +741,7 @@ function attributeChanged(repo, task, changedList, contract, change, tasks) {
701
741
  };
702
742
  }
703
743
 
704
- function completionChecks(repo, task, contract = null) {
744
+ function completionChecks(repo, task, contract = null, changeVerify = null) {
705
745
  const problems = [];
706
746
  const commands = contract
707
747
  ? contract.capsule.verification.commands.map((item) => item.label)
@@ -750,11 +790,65 @@ function completionChecks(repo, task, contract = null) {
750
790
  }
751
791
  }
752
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
+ }
753
830
  const blocker = evidenceValue(task, "Blocker");
754
831
  if (isConcrete(blocker)) {
755
832
  problems.push(problem("blocker", `Task records a blocker: ${blocker}`));
756
833
  }
757
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
+
758
852
  const reviewFields = {
759
853
  Status: reviewValue(task, "Status"),
760
854
  "Acceptance check": reviewValue(task, "Acceptance check"),
@@ -817,14 +911,14 @@ function completionChecks(repo, task, contract = null) {
817
911
  problems.push(
818
912
  problem(
819
913
  "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 "
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 "
824
920
  + `${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."
921
+ + "`Discard reason:`/`Discard rationale:` prefix."
828
922
  )
829
923
  );
830
924
  }
@@ -855,7 +949,8 @@ function taskComplete(repo, options) {
855
949
  }
856
950
  const contract = compileTaskContract(repo, selection.change, task);
857
951
  const usableContract = contract.diagnostics.length === 0 ? contract : null;
858
- const checks = completionChecks(repo, task, usableContract);
952
+ const changeVerify = changeVerifyChecks(selection.content, selection.tasks);
953
+ const checks = completionChecks(repo, task, usableContract, changeVerify);
859
954
  checks.problems.push(...contract.diagnostics);
860
955
  const missingAnchor = missingAnchorProblem(selection, task);
861
956
  if (missingAnchor) {
@@ -895,6 +990,131 @@ function taskComplete(repo, options) {
895
990
  );
896
991
  }
897
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
+
898
1118
  // Follow-up Ownership governs work a change left undone. This is the opposite
899
1119
  // shape: statements left standing by work the change completed. It is asked at
900
1120
  // task-start rather than change-close because the whole value is that the
@@ -919,10 +1139,7 @@ function invalidationProblems(repo, content, tasks) {
919
1139
  ),
920
1140
  ];
921
1141
  }
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);
1142
+ const section = sectionBody(content, heading, tasks);
926
1143
  if (/^\s*-\s+None\.?\s*$/im.test(section)) return [];
927
1144
  const entries = [
928
1145
  ...section.matchAll(
@@ -1022,10 +1239,7 @@ function expectationProblems(repo, content, tasks) {
1022
1239
  ),
1023
1240
  ];
1024
1241
  }
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);
1242
+ const section = sectionBody(content, heading, tasks);
1029
1243
  if (/^\s*-\s+None\.?\s*$/im.test(section)) return [];
1030
1244
  const entries = [
1031
1245
  ...section.matchAll(
@@ -1117,6 +1331,7 @@ function changeClose(repo, options) {
1117
1331
  const problems = [];
1118
1332
  const reviewProblems = [];
1119
1333
  const contracts = [];
1334
+ const changeVerify = changeVerifyChecks(selection.content, selection.tasks);
1120
1335
  if (selection.tasks.length === 0) {
1121
1336
  problems.push(problem("missing-tasks", "Change has no executable tasks."));
1122
1337
  }
@@ -1162,7 +1377,8 @@ function changeClose(repo, options) {
1162
1377
  const checks = completionChecks(
1163
1378
  repo,
1164
1379
  task,
1165
- contract.diagnostics.length === 0 ? contract : null
1380
+ contract.diagnostics.length === 0 ? contract : null,
1381
+ changeVerify
1166
1382
  );
1167
1383
  problems.push(
1168
1384
  ...checks.problems.map((item) =>
@@ -1176,6 +1392,7 @@ function changeClose(repo, options) {
1176
1392
  );
1177
1393
  }
1178
1394
  problems.push(...expectationProblems(repo, selection.content, selection.tasks));
1395
+ problems.push(...changeVerifyProblems(selection.content, selection.tasks));
1179
1396
 
1180
1397
  const changePath = path.dirname(selection.tasksPath);
1181
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,