@codyswann/lisa 3.2.0 → 3.4.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.
Files changed (175) hide show
  1. package/dist/cli/doctor-lisa-owned-artifacts.d.ts.map +1 -1
  2. package/dist/cli/doctor-lisa-owned-artifacts.js +41 -2
  3. package/dist/cli/doctor-lisa-owned-artifacts.js.map +1 -1
  4. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  5. package/dist/core/upstream-evidence-manifest.js +90 -16
  6. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  7. package/expo/create-only/.github/workflows/nightly-e2e-health.yml +21 -2
  8. package/package.json +1 -1
  9. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  10. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  11. package/plugins/lisa/.codex-plugin/skills/lisa-implement/SKILL.md +3 -1
  12. package/plugins/lisa/.codex-plugin/skills/lisa-quality-review/SKILL.md +7 -0
  13. package/plugins/lisa/.codex-plugin/skills/lisa-review-local/SKILL.md +27 -0
  14. package/plugins/lisa/.codex-plugin/skills/lisa-tdd-implementation/SKILL.md +1 -0
  15. package/plugins/lisa/.codex-plugin/skills/lisa-tracker-source-artifacts/SKILL.md +2 -0
  16. package/plugins/lisa/rules/eager/design-source-of-truth.md +38 -0
  17. package/plugins/lisa/rules/eager/do-it-now.md +11 -0
  18. package/plugins/lisa/rules/eager/empirical-inquiry.md +1 -0
  19. package/plugins/lisa/rules/eager/falsifiable-checks.md +2 -0
  20. package/plugins/lisa/rules/eager/integration-access-layer.md +5 -0
  21. package/plugins/lisa/rules/eager/learnings-ladder.md +18 -0
  22. package/plugins/lisa/rules/eager/local-ci-first.md +15 -0
  23. package/plugins/lisa/rules/eager/not-blocked-just-waiting.md +13 -0
  24. package/plugins/lisa/rules/eager/session-status-updates.md +19 -0
  25. package/plugins/lisa/rules/eager/wiki-knowledge-source.md +6 -11
  26. package/plugins/lisa/rules/reference/design-source-of-truth.md +195 -0
  27. package/plugins/lisa/rules/reference/do-it-now.md +37 -0
  28. package/plugins/lisa/rules/reference/empirical-inquiry.md +1 -0
  29. package/plugins/lisa/rules/reference/falsifiable-checks.md +24 -2
  30. package/plugins/lisa/rules/reference/integration-access-layer.md +8 -0
  31. package/plugins/lisa/rules/reference/learnings-ladder.md +41 -0
  32. package/plugins/lisa/rules/reference/local-ci-first.md +34 -0
  33. package/plugins/lisa/rules/reference/not-blocked-just-waiting.md +41 -0
  34. package/plugins/lisa/rules/reference/session-status-updates.md +60 -0
  35. package/plugins/lisa/rules/reference/wiki-knowledge-source.md +21 -5
  36. package/plugins/lisa/scripts/design-source-gate.mjs +530 -0
  37. package/plugins/lisa/skills/lisa-implement/SKILL.md +3 -1
  38. package/plugins/lisa/skills/lisa-quality-review/SKILL.md +7 -0
  39. package/plugins/lisa/skills/lisa-review-local/SKILL.md +27 -0
  40. package/plugins/lisa/skills/lisa-tdd-implementation/SKILL.md +1 -0
  41. package/plugins/lisa/skills/lisa-tracker-source-artifacts/SKILL.md +2 -0
  42. package/plugins/lisa-agy/plugin.json +1 -1
  43. package/plugins/lisa-agy/scripts/design-source-gate.mjs +530 -0
  44. package/plugins/lisa-agy/skills/lisa-implement/SKILL.md +3 -1
  45. package/plugins/lisa-agy/skills/lisa-quality-review/SKILL.md +7 -0
  46. package/plugins/lisa-agy/skills/lisa-review-local/SKILL.md +27 -0
  47. package/plugins/lisa-agy/skills/lisa-tdd-implementation/SKILL.md +1 -0
  48. package/plugins/lisa-agy/skills/lisa-tracker-source-artifacts/SKILL.md +2 -0
  49. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  51. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  52. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-copilot/rules/eager/design-source-of-truth.md +38 -0
  56. package/plugins/lisa-copilot/rules/eager/do-it-now.md +11 -0
  57. package/plugins/lisa-copilot/rules/eager/empirical-inquiry.md +1 -0
  58. package/plugins/lisa-copilot/rules/eager/falsifiable-checks.md +2 -0
  59. package/plugins/lisa-copilot/rules/eager/integration-access-layer.md +5 -0
  60. package/plugins/lisa-copilot/rules/eager/learnings-ladder.md +18 -0
  61. package/plugins/lisa-copilot/rules/eager/local-ci-first.md +15 -0
  62. package/plugins/lisa-copilot/rules/eager/not-blocked-just-waiting.md +13 -0
  63. package/plugins/lisa-copilot/rules/eager/session-status-updates.md +19 -0
  64. package/plugins/lisa-copilot/rules/eager/wiki-knowledge-source.md +6 -11
  65. package/plugins/lisa-copilot/rules/reference/design-source-of-truth.md +195 -0
  66. package/plugins/lisa-copilot/rules/reference/do-it-now.md +37 -0
  67. package/plugins/lisa-copilot/rules/reference/empirical-inquiry.md +1 -0
  68. package/plugins/lisa-copilot/rules/reference/falsifiable-checks.md +24 -2
  69. package/plugins/lisa-copilot/rules/reference/integration-access-layer.md +8 -0
  70. package/plugins/lisa-copilot/rules/reference/learnings-ladder.md +41 -0
  71. package/plugins/lisa-copilot/rules/reference/local-ci-first.md +34 -0
  72. package/plugins/lisa-copilot/rules/reference/not-blocked-just-waiting.md +41 -0
  73. package/plugins/lisa-copilot/rules/reference/session-status-updates.md +60 -0
  74. package/plugins/lisa-copilot/rules/reference/wiki-knowledge-source.md +21 -5
  75. package/plugins/lisa-copilot/scripts/design-source-gate.mjs +530 -0
  76. package/plugins/lisa-copilot/skills/lisa-implement/SKILL.md +3 -1
  77. package/plugins/lisa-copilot/skills/lisa-quality-review/SKILL.md +7 -0
  78. package/plugins/lisa-copilot/skills/lisa-review-local/SKILL.md +27 -0
  79. package/plugins/lisa-copilot/skills/lisa-tdd-implementation/SKILL.md +1 -0
  80. package/plugins/lisa-copilot/skills/lisa-tracker-source-artifacts/SKILL.md +2 -0
  81. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  82. package/plugins/lisa-cursor/rules/design-source-of-truth-reference.mdc +200 -0
  83. package/plugins/lisa-cursor/rules/design-source-of-truth.mdc +43 -0
  84. package/plugins/lisa-cursor/rules/do-it-now-reference.mdc +42 -0
  85. package/plugins/lisa-cursor/rules/do-it-now.mdc +16 -0
  86. package/plugins/lisa-cursor/rules/empirical-inquiry-reference.mdc +1 -0
  87. package/plugins/lisa-cursor/rules/empirical-inquiry.mdc +1 -0
  88. package/plugins/lisa-cursor/rules/falsifiable-checks-reference.mdc +24 -2
  89. package/plugins/lisa-cursor/rules/falsifiable-checks.mdc +2 -0
  90. package/plugins/lisa-cursor/rules/integration-access-layer-reference.mdc +8 -0
  91. package/plugins/lisa-cursor/rules/integration-access-layer.mdc +5 -0
  92. package/plugins/lisa-cursor/rules/learnings-ladder-reference.mdc +46 -0
  93. package/plugins/lisa-cursor/rules/learnings-ladder.mdc +23 -0
  94. package/plugins/lisa-cursor/rules/local-ci-first-reference.mdc +39 -0
  95. package/plugins/lisa-cursor/rules/local-ci-first.mdc +20 -0
  96. package/plugins/lisa-cursor/rules/not-blocked-just-waiting-reference.mdc +46 -0
  97. package/plugins/lisa-cursor/rules/not-blocked-just-waiting.mdc +18 -0
  98. package/plugins/lisa-cursor/rules/session-status-updates-reference.mdc +65 -0
  99. package/plugins/lisa-cursor/rules/session-status-updates.mdc +24 -0
  100. package/plugins/lisa-cursor/rules/wiki-knowledge-source-reference.mdc +21 -5
  101. package/plugins/lisa-cursor/rules/wiki-knowledge-source.mdc +7 -12
  102. package/plugins/lisa-cursor/scripts/design-source-gate.mjs +530 -0
  103. package/plugins/lisa-cursor/skills/lisa-implement/SKILL.md +3 -1
  104. package/plugins/lisa-cursor/skills/lisa-quality-review/SKILL.md +7 -0
  105. package/plugins/lisa-cursor/skills/lisa-review-local/SKILL.md +27 -0
  106. package/plugins/lisa-cursor/skills/lisa-tdd-implementation/SKILL.md +1 -0
  107. package/plugins/lisa-cursor/skills/lisa-tracker-source-artifacts/SKILL.md +2 -0
  108. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  109. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  110. package/plugins/lisa-expo-agy/plugin.json +1 -1
  111. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  112. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  113. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  114. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  115. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  116. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  117. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  118. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  119. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  120. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  121. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  122. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  123. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  124. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  125. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  126. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  127. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  128. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  129. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  130. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  131. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  132. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  133. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  134. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  135. package/plugins/lisa-rails-agy/plugin.json +1 -1
  136. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  137. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  138. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  139. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  140. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  141. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  142. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  143. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  144. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  145. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  146. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  147. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  148. package/plugins/src/base/rules/eager/design-source-of-truth.md +38 -0
  149. package/plugins/src/base/rules/eager/do-it-now.md +11 -0
  150. package/plugins/src/base/rules/eager/empirical-inquiry.md +1 -0
  151. package/plugins/src/base/rules/eager/falsifiable-checks.md +2 -0
  152. package/plugins/src/base/rules/eager/integration-access-layer.md +5 -0
  153. package/plugins/src/base/rules/eager/learnings-ladder.md +18 -0
  154. package/plugins/src/base/rules/eager/local-ci-first.md +15 -0
  155. package/plugins/src/base/rules/eager/not-blocked-just-waiting.md +13 -0
  156. package/plugins/src/base/rules/eager/session-status-updates.md +19 -0
  157. package/plugins/src/base/rules/eager/wiki-knowledge-source.md +6 -11
  158. package/plugins/src/base/rules/reference/design-source-of-truth.md +195 -0
  159. package/plugins/src/base/rules/reference/do-it-now.md +37 -0
  160. package/plugins/src/base/rules/reference/empirical-inquiry.md +1 -0
  161. package/plugins/src/base/rules/reference/falsifiable-checks.md +24 -2
  162. package/plugins/src/base/rules/reference/integration-access-layer.md +8 -0
  163. package/plugins/src/base/rules/reference/learnings-ladder.md +41 -0
  164. package/plugins/src/base/rules/reference/local-ci-first.md +34 -0
  165. package/plugins/src/base/rules/reference/not-blocked-just-waiting.md +41 -0
  166. package/plugins/src/base/rules/reference/session-status-updates.md +60 -0
  167. package/plugins/src/base/rules/reference/wiki-knowledge-source.md +21 -5
  168. package/plugins/src/base/scripts/design-source-gate.mjs +530 -0
  169. package/plugins/src/base/skills/lisa-implement/SKILL.md +3 -1
  170. package/plugins/src/base/skills/lisa-quality-review/SKILL.md +7 -0
  171. package/plugins/src/base/skills/lisa-review-local/SKILL.md +27 -0
  172. package/plugins/src/base/skills/lisa-tdd-implementation/SKILL.md +1 -0
  173. package/plugins/src/base/skills/lisa-tracker-source-artifacts/SKILL.md +2 -0
  174. package/typescript/copy-overwrite/scripts/check-nightly-e2e-health.mjs +173 -8
  175. package/typescript/copy-overwrite/scripts/nightly-e2e-suites.schema.json +12 -0
@@ -86,7 +86,7 @@ import { pathToFileURL } from "node:url";
86
86
  * rather than running a contract neither half agrees on. See §8 of
87
87
  * `docs/nightly-e2e-gate.md` for what counts as major / minor / patch.
88
88
  */
89
- export const NIGHTLY_E2E_CONTRACT_VERSION = "1.2.0";
89
+ export const NIGHTLY_E2E_CONTRACT_VERSION = "1.3.0";
90
90
 
91
91
  /**
92
92
  * The conclusions that constitute a verdict about the code.
@@ -204,6 +204,24 @@ export const DEFAULT_BYPASS_REASON_PATTERN = REQUIRED_BYPASS_REASON_PATTERN;
204
204
  /** Hard ceiling on how far out a bootstrap window may sit. */
205
205
  export const BOOTSTRAP_ABSOLUTE_MAX_DAYS = 30;
206
206
 
207
+ /**
208
+ * How long a newly declared suite is forgiven for having no evidence yet.
209
+ *
210
+ * Bootstrap (§4) is one flag for the whole workflow, which made ADDING a suite
211
+ * a repository-wide wedge: the moment a fourth suite lands in the table of an
212
+ * armed repo its evidence is missing (row 9), and every pull request is blocked
213
+ * until that suite's first green nightly. The escapes were re-opening the
214
+ * GLOBAL window — un-arming the three suites that were working — or burning an
215
+ * audited bypass. Neither is a proportionate answer to adding a suite.
216
+ *
217
+ * Two weeks is a fortnight of nightlies: long enough to wire a suite up and
218
+ * burn its first failures down, short enough that forgetting the field is
219
+ * self-correcting. It is a DEFAULT, and `grace_days` may only shorten it —
220
+ * the ceiling it is checked against is `bootstrap_max_days`, the same
221
+ * forgiveness budget the global window spends from (§4.1).
222
+ */
223
+ export const DEFAULT_SUITE_GRACE_DAYS = 14;
224
+
207
225
  /** Hard ceiling on how stale a run may be and still speak for the branch. */
208
226
  export const ABSOLUTE_MAX_FRESHNESS_HOURS = 720;
209
227
 
@@ -326,7 +344,15 @@ export class GateApiError extends Error {
326
344
 
327
345
  /** Keys a suite entry may carry. Anything else is a typo, and typos fail. */
328
346
  const SUITE_KEYS = Object.freeze(
329
- new Set(["label", "workflow", "match", "freshness_hours", "required_sha"])
347
+ new Set([
348
+ "label",
349
+ "workflow",
350
+ "match",
351
+ "freshness_hours",
352
+ "required_sha",
353
+ "first_seen",
354
+ "grace_days",
355
+ ])
330
356
  );
331
357
 
332
358
  /** Keys each match mode may carry. */
@@ -491,6 +517,39 @@ export function validateSuites(raw) {
491
517
  );
492
518
  }
493
519
  }
520
+ // Rows 32-35 — the per-suite grace anchor and its length. Shape only; the
521
+ // WINDOW is resolved (and rejected) in `resolveSuiteGrace`, which is the
522
+ // one place the `bootstrap_max_days` ceiling is applied to it.
523
+ if (entry.first_seen !== undefined) {
524
+ if (
525
+ typeof entry.first_seen !== "string" ||
526
+ entry.first_seen.trim().length === 0
527
+ ) {
528
+ throw new GateConfigError(
529
+ `${where}: \`first_seen\` must be an ISO-8601 UTC timestamp naming when this suite entered the table (e.g. "2026-08-10T00:00:00Z").`
530
+ );
531
+ }
532
+ }
533
+ if (entry.grace_days !== undefined) {
534
+ // A knob with no anchor is a gate configured differently than its author
535
+ // believes — the same defect an ignored key is, so it fails the same way.
536
+ if (entry.first_seen === undefined) {
537
+ throw new GateConfigError(
538
+ `${where}: \`grace_days\` requires \`first_seen\`. A grace length with no anchor forgives nothing and reads as though it forgives everything.`
539
+ );
540
+ }
541
+ const days = entry.grace_days;
542
+ if (
543
+ typeof days !== "number" ||
544
+ !Number.isFinite(days) ||
545
+ days <= 0 ||
546
+ days > BOOTSTRAP_ABSOLUTE_MAX_DAYS
547
+ ) {
548
+ throw new GateConfigError(
549
+ `${where}: \`grace_days\` must be a number in (0, ${BOOTSTRAP_ABSOLUTE_MAX_DAYS}]. A grace that outlives the bootstrap ceiling IS propswap's forever-bootstrap, whatever it is called — rejected rather than clamped, so widening it is a reviewable act.`
550
+ );
551
+ }
552
+ }
494
553
  if (entry.required_sha !== undefined) {
495
554
  if (
496
555
  typeof entry.required_sha !== "string" ||
@@ -777,6 +836,77 @@ export function resolveBootstrap(until, maxDays, now) {
777
836
  });
778
837
  }
779
838
 
839
+ /**
840
+ * Resolves ONE suite's first-seen grace window (rows 32-35).
841
+ *
842
+ * The problem this exists for: bootstrap is workflow-global, so adding a suite
843
+ * to an armed repo blocks every pull request from the moment of the edit until
844
+ * that suite's first green nightly — and the only outs were re-opening the
845
+ * global window (which un-arms every suite that was already working) or an
846
+ * audited bypass. Neither is proportionate to the routine act of adding a
847
+ * suite, and both teach people that the gate is something to get around.
848
+ *
849
+ * What keeps this from becoming propswap's forever-bootstrap is the ANCHOR.
850
+ * The window is not a date somebody picks; it is `first_seen + grace_days`,
851
+ * and `first_seen` MAY NOT BE IN THE FUTURE. A future anchor would make this a
852
+ * hand-typed expiry under another name, extendable by one string edit forever —
853
+ * so it fails as misconfiguration, exactly as row 24 fails a bootstrap window
854
+ * beyond its cap. Rolling the anchor forward is still possible, and it is
855
+ * meant to be: it means writing "this suite is new" about a suite that is not,
856
+ * in a diff a reviewer reads.
857
+ *
858
+ * The ceiling is `bootstrap_max_days` — the SAME forgiveness budget the global
859
+ * window spends from, already clamped to `BOOTSTRAP_ABSOLUTE_MAX_DAYS` by
860
+ * `resolveSecurityLimits`. A grace that could outlive it would be a second,
861
+ * looser bootstrap wearing a per-suite hat.
862
+ *
863
+ * A window that lapsed long ago is INERT, never an error: cleaning the field
864
+ * up must stay optional, or the design buys a churn commit per suite per month
865
+ * and the first person to hit it deletes the anchor rather than the window.
866
+ *
867
+ * @param {object} suite - A validated suite entry
868
+ * @param {number} maxDays - Ceiling on how far out any forgiveness window may sit
869
+ * @param {Date} now - Evaluation instant
870
+ * @returns {{active: boolean, until: string|null, expiresInDays: number|null, firstSeen: string|null}} The window
871
+ * @throws {GateConfigError} When the anchor is unparseable, in the future, or the window exceeds the ceiling
872
+ */
873
+ export function resolveSuiteGrace(suite, maxDays, now) {
874
+ const raw = suite?.first_seen;
875
+ if (typeof raw !== "string" || raw.trim().length === 0) {
876
+ return Object.freeze({
877
+ active: false,
878
+ until: null,
879
+ expiresInDays: null,
880
+ firstSeen: null,
881
+ });
882
+ }
883
+ const anchor = Date.parse(raw.trim());
884
+ if (Number.isNaN(anchor)) {
885
+ throw new GateConfigError(
886
+ `\`first_seen\` for suite ${JSON.stringify(suite.label)} is not an ISO-8601 timestamp: ${JSON.stringify(raw)}. Use e.g. "2026-08-10T00:00:00Z".`
887
+ );
888
+ }
889
+ if (anchor > now.getTime()) {
890
+ throw new GateConfigError(
891
+ `\`first_seen\` for suite ${JSON.stringify(suite.label)} (${raw}) is in the future. A suite cannot have been first seen tomorrow, and an anchor that may sit in the future is a hand-typed expiry under another name — one string edit and the grace never ends.`
892
+ );
893
+ }
894
+ const graceDays = suite.grace_days ?? DEFAULT_SUITE_GRACE_DAYS;
895
+ const untilMs = anchor + graceDays * 86_400_000;
896
+ const daysOut = (untilMs - now.getTime()) / 86_400_000;
897
+ if (daysOut > maxDays) {
898
+ throw new GateConfigError(
899
+ `The first-seen grace for suite ${JSON.stringify(suite.label)} runs ${Math.ceil(daysOut)} days out, beyond \`bootstrap_max_days\` (${maxDays}). Per-suite grace spends from the same forgiveness budget as the bootstrap window, so it fails as misconfiguration rather than being clamped — shorten \`grace_days\`, or raise the cap deliberately in the same review.`
900
+ );
901
+ }
902
+ return Object.freeze({
903
+ active: untilMs > now.getTime(),
904
+ until: new Date(untilMs).toISOString(),
905
+ expiresInDays: Math.max(0, Math.ceil(daysOut)),
906
+ firstSeen: new Date(anchor).toISOString(),
907
+ });
908
+ }
909
+
780
910
  // ---------------------------------------------------------------------------
781
911
  // 4. Bypass — maintainers only, no self-bypass, reason required, auto-expiring
782
912
  // ---------------------------------------------------------------------------
@@ -924,13 +1054,21 @@ const BYPASS_REJECTIONS = Object.freeze({
924
1054
  * carrying a stale label still reports `pass` rather than pretending the label
925
1055
  * did something.
926
1056
  *
1057
+ * A finding may carry its OWN window in `finding.grace` (rows 32-35), resolved
1058
+ * per suite from `first_seen`. It softens the same states the global window
1059
+ * softens and nothing more — `unknown` only, never `fail` — so grace forgives
1060
+ * absence of evidence and never evidence of failure, exactly as bootstrap does.
1061
+ * Two windows, one rule; a suite is forgiven when EITHER is open, which is what
1062
+ * lets a repo arm three suites and still add a fourth.
1063
+ *
927
1064
  * @param {ReadonlyArray<object>} findings - Per-suite findings
928
1065
  * @param {{bootstrap: object, bypass: object|null}} options - Window and bypass decision
929
1066
  * @returns {{verdict: string, blocked: boolean, findings: ReadonlyArray<object>, bootstrap: object, bypass: object|null}} The verdict
930
1067
  */
931
1068
  export function decide(findings, { bootstrap, bypass = null }) {
932
1069
  const rendered = findings.map(finding =>
933
- finding.state === SUITE_STATES.unknown && bootstrap.active
1070
+ finding.state === SUITE_STATES.unknown &&
1071
+ (bootstrap.active || finding.grace?.active === true)
934
1072
  ? { ...finding, state: "bootstrap" }
935
1073
  : finding
936
1074
  );
@@ -1027,7 +1165,14 @@ export function formatFinding(finding) {
1027
1165
  ? "not yet blocking"
1028
1166
  : finding.state.toUpperCase();
1029
1167
  const conclusion = finding.conclusion ? ` [${finding.conclusion}]` : "";
1030
- return `${marker} ${finding.label} ${verdictWord}${conclusion}${when} ${detail}${link}`;
1168
+ // The per-suite expiry rides on the LINE, not just in the trailing
1169
+ // paragraph: with one suite in grace and three armed, a reader has to be able
1170
+ // to tell which line is forgiven and until when. There is no quiet grace.
1171
+ const grace =
1172
+ finding.state === "bootstrap" && finding.grace?.active
1173
+ ? ` — new suite (first seen ${finding.grace.firstSeen}); its grace expires ${finding.grace.until} (in ${finding.grace.expiresInDays} day(s)), after which this line blocks`
1174
+ : "";
1175
+ return `${marker} ${finding.label} — ${verdictWord}${conclusion}${when} — ${detail}${link}${grace}`;
1031
1176
  }
1032
1177
 
1033
1178
  /**
@@ -1056,6 +1201,19 @@ export function formatReport(verdict, context) {
1056
1201
  );
1057
1202
  }
1058
1203
 
1204
+ // Per-suite grace gets its own paragraph for the same reason bootstrap does:
1205
+ // a forgiveness nobody can see is a gate quietly measuring less than it reads
1206
+ // as measuring. Naming the suites keeps "which one is new?" off the reader.
1207
+ const inGrace = verdict.findings.filter(
1208
+ finding => finding.state === "bootstrap" && finding.grace?.active
1209
+ );
1210
+ if (inGrace.length > 0) {
1211
+ lines.push(
1212
+ `🌱 **New-suite grace active for ${inGrace.map(finding => `\`${finding.label}\``).join(", ")}.** A suite gets a bounded window from its \`first_seen\` anchor to produce its first verdict, so adding a suite cannot block every pull request until tomorrow's nightly. Every OTHER suite stays armed, evidence of FAILURE still blocks inside the window, and when the window lapses the line above blocks with no further action.`,
1213
+ ""
1214
+ );
1215
+ }
1216
+
1059
1217
  if (verdict.verdict === "bypassed") {
1060
1218
  lines.push(
1061
1219
  `⚠️ **Gate bypassed — audited.** Applied by \`${verdict.bypass.actor}\` at ${verdict.bypass.appliedAt}, expires ${verdict.bypass.expiresAt}. Ticket: \`${verdict.bypass.ticket}\`. Reason: ${verdict.bypass.detail}`,
@@ -1964,13 +2122,20 @@ export async function runGate(env, wait) {
1964
2122
  settings.branch,
1965
2123
  wait
1966
2124
  );
1967
- const findings = settings.suites.map((suite, index) =>
1968
- assessSuite(suite, observations[index], {
2125
+ const findings = settings.suites.map((suite, index) => {
2126
+ const finding = assessSuite(suite, observations[index], {
1969
2127
  branch: settings.branch,
1970
2128
  freshnessHours: settings.freshnessHours,
1971
2129
  now,
1972
- })
1973
- );
2130
+ });
2131
+ // Resolved for EVERY suite, so a misconfigured anchor fails the gate
2132
+ // whether or not its window is still open — a rule that only runs while it
2133
+ // would forgive something is a rule nobody notices breaking. The window is
2134
+ // attached only when the suite actually declared one, so an untouched
2135
+ // table produces byte-identical findings.
2136
+ const grace = resolveSuiteGrace(suite, settings.bootstrapMaxDays, now);
2137
+ return grace.firstSeen === null ? finding : { ...finding, grace };
2138
+ });
1974
2139
 
1975
2140
  let bypass = null;
1976
2141
  if (settings.pr.number && settings.pr.labels.includes(settings.bypassLabel)) {
@@ -11,6 +11,7 @@
11
11
  "type": "object",
12
12
  "additionalProperties": false,
13
13
  "required": ["label", "workflow", "match"],
14
+ "dependentRequired": { "grace_days": ["first_seen"] },
14
15
  "properties": {
15
16
  "label": {
16
17
  "type": "string",
@@ -40,6 +41,17 @@
40
41
  "type": "string",
41
42
  "pattern": "^[0-9a-f]{40}$",
42
43
  "description": "When present, the run's head_sha must equal this exact commit. Use only where the suite is expected to run on a pinned commit; freshness plus the branch filter is the normal staleness control."
44
+ },
45
+ "first_seen": {
46
+ "type": "string",
47
+ "format": "date-time",
48
+ "description": "ISO-8601 UTC timestamp naming when this suite entered the table — the ANCHOR of its per-suite grace window (truth-table rows 27-30). Set it when you ADD a suite to an already-armed repo: for `grace_days` (default 14) from this instant, MISSING evidence for THIS suite reports with its expiry but does not block, while every other suite stays armed. Evidence of FAILURE blocks inside the window too. It may NOT be in the future — a future anchor would be a hand-typed expiry under another name, extendable forever by one string edit — and a window running beyond `bootstrap_max_days` FAILS as misconfiguration rather than being clamped. A lapsed anchor is inert, so the field can be left in place."
49
+ },
50
+ "grace_days": {
51
+ "type": "number",
52
+ "exclusiveMinimum": 0,
53
+ "maximum": 30,
54
+ "description": "How many days after `first_seen` this suite's grace window lasts. Default 14. Requires `first_seen` — a grace length with no anchor forgives nothing and reads as though it forgives everything. Bounded by the same ceiling as the bootstrap window (`bootstrap_max_days`, hard ceiling 30): per-suite grace spends from the same forgiveness budget, and a grace that could outlive that ceiling is propswap's forever-bootstrap wearing a per-suite hat."
43
55
  }
44
56
  }
45
57
  },