@codyswann/lisa 3.2.0 → 3.3.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/dist/cli/doctor-lisa-owned-artifacts.d.ts.map +1 -1
- package/dist/cli/doctor-lisa-owned-artifacts.js +41 -2
- package/dist/cli/doctor-lisa-owned-artifacts.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +65 -11
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/expo/create-only/.github/workflows/nightly-e2e-health.yml +21 -2
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/rules/eager/do-it-now.md +11 -0
- package/plugins/lisa/rules/eager/empirical-inquiry.md +1 -0
- package/plugins/lisa/rules/eager/falsifiable-checks.md +2 -0
- package/plugins/lisa/rules/eager/integration-access-layer.md +5 -0
- package/plugins/lisa/rules/eager/learnings-ladder.md +18 -0
- package/plugins/lisa/rules/eager/local-ci-first.md +15 -0
- package/plugins/lisa/rules/eager/not-blocked-just-waiting.md +13 -0
- package/plugins/lisa/rules/eager/session-status-updates.md +19 -0
- package/plugins/lisa/rules/eager/wiki-knowledge-source.md +6 -11
- package/plugins/lisa/rules/reference/do-it-now.md +37 -0
- package/plugins/lisa/rules/reference/empirical-inquiry.md +1 -0
- package/plugins/lisa/rules/reference/falsifiable-checks.md +24 -2
- package/plugins/lisa/rules/reference/integration-access-layer.md +8 -0
- package/plugins/lisa/rules/reference/learnings-ladder.md +41 -0
- package/plugins/lisa/rules/reference/local-ci-first.md +34 -0
- package/plugins/lisa/rules/reference/not-blocked-just-waiting.md +41 -0
- package/plugins/lisa/rules/reference/session-status-updates.md +60 -0
- package/plugins/lisa/rules/reference/wiki-knowledge-source.md +21 -5
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/rules/eager/do-it-now.md +11 -0
- package/plugins/lisa-copilot/rules/eager/empirical-inquiry.md +1 -0
- package/plugins/lisa-copilot/rules/eager/falsifiable-checks.md +2 -0
- package/plugins/lisa-copilot/rules/eager/integration-access-layer.md +5 -0
- package/plugins/lisa-copilot/rules/eager/learnings-ladder.md +18 -0
- package/plugins/lisa-copilot/rules/eager/local-ci-first.md +15 -0
- package/plugins/lisa-copilot/rules/eager/not-blocked-just-waiting.md +13 -0
- package/plugins/lisa-copilot/rules/eager/session-status-updates.md +19 -0
- package/plugins/lisa-copilot/rules/eager/wiki-knowledge-source.md +6 -11
- package/plugins/lisa-copilot/rules/reference/do-it-now.md +37 -0
- package/plugins/lisa-copilot/rules/reference/empirical-inquiry.md +1 -0
- package/plugins/lisa-copilot/rules/reference/falsifiable-checks.md +24 -2
- package/plugins/lisa-copilot/rules/reference/integration-access-layer.md +8 -0
- package/plugins/lisa-copilot/rules/reference/learnings-ladder.md +41 -0
- package/plugins/lisa-copilot/rules/reference/local-ci-first.md +34 -0
- package/plugins/lisa-copilot/rules/reference/not-blocked-just-waiting.md +41 -0
- package/plugins/lisa-copilot/rules/reference/session-status-updates.md +60 -0
- package/plugins/lisa-copilot/rules/reference/wiki-knowledge-source.md +21 -5
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/rules/do-it-now-reference.mdc +42 -0
- package/plugins/lisa-cursor/rules/do-it-now.mdc +16 -0
- package/plugins/lisa-cursor/rules/empirical-inquiry-reference.mdc +1 -0
- package/plugins/lisa-cursor/rules/empirical-inquiry.mdc +1 -0
- package/plugins/lisa-cursor/rules/falsifiable-checks-reference.mdc +24 -2
- package/plugins/lisa-cursor/rules/falsifiable-checks.mdc +2 -0
- package/plugins/lisa-cursor/rules/integration-access-layer-reference.mdc +8 -0
- package/plugins/lisa-cursor/rules/integration-access-layer.mdc +5 -0
- package/plugins/lisa-cursor/rules/learnings-ladder-reference.mdc +46 -0
- package/plugins/lisa-cursor/rules/learnings-ladder.mdc +23 -0
- package/plugins/lisa-cursor/rules/local-ci-first-reference.mdc +39 -0
- package/plugins/lisa-cursor/rules/local-ci-first.mdc +20 -0
- package/plugins/lisa-cursor/rules/not-blocked-just-waiting-reference.mdc +46 -0
- package/plugins/lisa-cursor/rules/not-blocked-just-waiting.mdc +18 -0
- package/plugins/lisa-cursor/rules/session-status-updates-reference.mdc +65 -0
- package/plugins/lisa-cursor/rules/session-status-updates.mdc +24 -0
- package/plugins/lisa-cursor/rules/wiki-knowledge-source-reference.mdc +21 -5
- package/plugins/lisa-cursor/rules/wiki-knowledge-source.mdc +7 -12
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/rules/eager/do-it-now.md +11 -0
- package/plugins/src/base/rules/eager/empirical-inquiry.md +1 -0
- package/plugins/src/base/rules/eager/falsifiable-checks.md +2 -0
- package/plugins/src/base/rules/eager/integration-access-layer.md +5 -0
- package/plugins/src/base/rules/eager/learnings-ladder.md +18 -0
- package/plugins/src/base/rules/eager/local-ci-first.md +15 -0
- package/plugins/src/base/rules/eager/not-blocked-just-waiting.md +13 -0
- package/plugins/src/base/rules/eager/session-status-updates.md +19 -0
- package/plugins/src/base/rules/eager/wiki-knowledge-source.md +6 -11
- package/plugins/src/base/rules/reference/do-it-now.md +37 -0
- package/plugins/src/base/rules/reference/empirical-inquiry.md +1 -0
- package/plugins/src/base/rules/reference/falsifiable-checks.md +24 -2
- package/plugins/src/base/rules/reference/integration-access-layer.md +8 -0
- package/plugins/src/base/rules/reference/learnings-ladder.md +41 -0
- package/plugins/src/base/rules/reference/local-ci-first.md +34 -0
- package/plugins/src/base/rules/reference/not-blocked-just-waiting.md +41 -0
- package/plugins/src/base/rules/reference/session-status-updates.md +60 -0
- package/plugins/src/base/rules/reference/wiki-knowledge-source.md +21 -5
- package/typescript/copy-overwrite/scripts/check-nightly-e2e-health.mjs +173 -8
- package/typescript/copy-overwrite/scripts/nightly-e2e-suites.schema.json +12 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Session Status Updates — Plain Language, and Always Say If It's Safe to Close
|
|
2
|
+
|
|
3
|
+
## Why this is a rule and not a style preference
|
|
4
|
+
|
|
5
|
+
Lisa's premise is that a non-technical person can direct software work by describing outcomes. That premise already binds everything crossing a gate outward — intake rejections, clarifying questions, ticket descriptions, verification reports — because a non-technical operator is standing at the gate.
|
|
6
|
+
|
|
7
|
+
In-session conversation was never held to the same bar, and the result was a split personality: careful, plain-language ticket prose and simultaneously a stream of chat messages full of file paths, rule slugs, and reasoning narrative. The human reading both is the same human. This rule closes the split.
|
|
8
|
+
|
|
9
|
+
## The three-part shape
|
|
10
|
+
|
|
11
|
+
Every update answers, in this order:
|
|
12
|
+
|
|
13
|
+
1. **What changed** — what is different in the world now that was not before. Shipped, fixed, filed, deployed.
|
|
14
|
+
2. **What's blocked** — using the strict meaning from `not-blocked-just-waiting`. If nothing is blocked, say so; "waiting on the CI run" belongs here labelled as waiting.
|
|
15
|
+
3. **What needs a decision** — see below.
|
|
16
|
+
|
|
17
|
+
What is deliberately excluded: how you found it. The files you read, the searches you ran, the hypotheses you discarded, the dead ends. That is your working memory, not their update. If they want it they will ask, and the offer to elaborate costs one clause.
|
|
18
|
+
|
|
19
|
+
## Voice
|
|
20
|
+
|
|
21
|
+
Write the way you would speak to a competent colleague who does not work on this system.
|
|
22
|
+
|
|
23
|
+
- Prefer the user-visible name of a thing over its implementation name. "The login page broke" beats "the auth guard regressed at the controller boundary."
|
|
24
|
+
- Do not use Lisa vocabulary (rung, gate, leaf, intake, ratchet) with someone who has not asked for it.
|
|
25
|
+
- Do not use a file path where a description works. Paths are for when the human will open the file.
|
|
26
|
+
- Short sentences. No preamble about what you are about to say.
|
|
27
|
+
|
|
28
|
+
Two lines the user themselves offered as the target: *"just tell me what's going on and what my options are"* and *"give me the summary, I'll ask for detail if I want it."*
|
|
29
|
+
|
|
30
|
+
## Decisions
|
|
31
|
+
|
|
32
|
+
A decision presented as a paragraph of context is a decision the human has to excavate. State it as a decision:
|
|
33
|
+
|
|
34
|
+
- **The decision** — one sentence naming the choice to be made.
|
|
35
|
+
- **Your recommendation** — you have more context than they do; do not withhold it. Recommending is not deciding.
|
|
36
|
+
- **The ramifications of each option** — one line apiece, in terms of consequences they care about (time, cost, risk, what breaks, what it forecloses), not in terms of implementation.
|
|
37
|
+
|
|
38
|
+
An option you consider unacceptable is still listed, with the reason it is unacceptable. Presenting one option as if it were the only one is a decision you made on their behalf without saying so.
|
|
39
|
+
|
|
40
|
+
## The close line
|
|
41
|
+
|
|
42
|
+
Every update ends with exactly:
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
Safe to close: yes/no — <reason>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`yes` means killing the session right now loses nothing: work is committed and pushed, tickets are in the state they should be in, nothing is running that will not finish on its own.
|
|
49
|
+
|
|
50
|
+
`no` names the specific in-flight thing that would be lost. Real examples: a local dev server or test run still going; a ticket filed but not yet flipped to ready, so nothing will pick it up; a commit that exists only in the working tree; a PR whose checks nobody is watching; a deploy mid-flight.
|
|
51
|
+
|
|
52
|
+
The line exists because the human's most common question — asked or unasked — is "can I close this?" Making them ask it costs a round trip; making them guess costs them work.
|
|
53
|
+
|
|
54
|
+
## Relationship to the other communication rules
|
|
55
|
+
|
|
56
|
+
- `report-actionability` governs **completeness**: the denominator, every item accounted for, who acts on each. It is about what a report must contain.
|
|
57
|
+
- `automation-runbook-contract` governs the **outcome line** a terminating flow opens with.
|
|
58
|
+
- This rule governs the **voice** of a session update, its three-part shape, and the close line.
|
|
59
|
+
|
|
60
|
+
None of the three is relaxed by the others. An update can satisfy all three at once and usually should: outcome line first, denominator stated, plain language throughout, close line last.
|
|
@@ -9,13 +9,29 @@ A project has a wiki in one of two shapes:
|
|
|
9
9
|
|
|
10
10
|
Either way, freshness is not your concern. The query and ingest skills run `scripts/ensure-wiki.mjs` as their own first step, which resolves the wiki root and — for a remote wiki — clones the mirror if missing and fast-forwards it when stale (subject to a short TTL, and tolerant of being offline: it proceeds with the existing mirror and warns rather than blocking). The freshness guarantee lives in the tool, not in the caller's discipline. Do **not** add a separate "make sure the wiki is current" step to your own workflow — calling the skill already does it.
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
## Load posture: on demand, not at session start
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
The wiki is **not** read during session bootstrap, and no flow may add a "load the wiki" step to its startup. This is a deliberate reversal of the rule's earlier "consult the wiki first" posture, recorded in `wiki/decisions/2026-08-12-agent-neutral-host-rules-path.md` section 6 (decision D3). The reasoning is context budget: the wiki is deep declarative knowledge, which is the WIKI rung of the learnings ladder precisely *because* it should be paid for only when routed to. Loading it unconditionally converts an on-demand rung into an eager one and charges every session — including the large majority that never ask a knowledge question — for knowledge they do not use.
|
|
15
|
+
|
|
16
|
+
What survives the softening is discoverability. An agent must **know the wiki exists and know how to query it**, so that a question needing depth goes to the curated answer instead of a fresh scrape of the codebase. What is dropped is the obligation to consult it before every piece of research, and the framing of the wiki as the authority that outranks primary sources.
|
|
17
|
+
|
|
18
|
+
When you do need project depth:
|
|
19
|
+
|
|
20
|
+
1. Call the wiki query skill (`/lisa-wiki-query`, or the runtime's equivalent), which resolves the wiki root for you. For a local wiki you may also start from `wiki/index.md` and follow links.
|
|
21
|
+
2. Prefer what the wiki says over re-deriving the same answer from raw sources — that is what it is curated for.
|
|
22
|
+
3. Fall back to primary sources (code, tickets, commit history, external docs) when the wiki is silent, ambiguous, or contradicted by what you observe in the code.
|
|
17
23
|
4. If you find the wiki is wrong, stale, or missing knowledge that belongs there, surface the gap — and where the project's workflow supports it, capture the correction back into the wiki via its ingestion path (`/lisa-wiki-ingest` or equivalent) rather than leaving the knowledge only in this session.
|
|
18
24
|
|
|
19
25
|
The wiki documents knowledge; it does not override executable behavior. When the wiki and the running code disagree about what the system actually does, trust the code and treat the wiki as out of date. See the `documentation-source-paths` rule for how source-material directories relate to the wiki.
|
|
20
26
|
|
|
21
|
-
|
|
27
|
+
## Applicability is the plugin gate, not just the wiki content
|
|
28
|
+
|
|
29
|
+
This rule ships in the **base** plugin, but the skill it directs you to (`lisa-wiki-query`) ships in the **wiki** plugin. Those are gated differently, and for a period a base-only project received this rule pointing at a skill it did not have — a rule with a dangling instruction, which teaches an agent to distrust its rules.
|
|
30
|
+
|
|
31
|
+
The applicability condition is therefore stated as the *installation* gate rather than as "does a `wiki/` folder exist". The `lisa-wiki` plugin is selected when `wiki/lisa-wiki.config.json` exists **or** `.lisa.config.json` declares a top-level `wiki` key (`src/core/lisa-plugin-selection.ts`); on Claude Code it is additionally gated by `enabledPlugins["lisa-wiki@lisa"]` in `.claude/settings.json`. When that gate is not satisfied:
|
|
32
|
+
|
|
33
|
+
- This rule does not apply. There is no wiki to query and no query skill to call.
|
|
34
|
+
- The recovery path is `/lisa-wiki-install`, the bootstrap skill that **base** ships precisely so a project that never enabled the wiki plugin can still discover it. Naming a base-shipped skill is what keeps the instruction reachable in every install.
|
|
35
|
+
- **Never block on the absent skill.** Degrade to primary sources and continue, per the same degrade-never-block posture `readiness-rubric` and `rejection-detection` use for surfaces that may not be installed.
|
|
36
|
+
|
|
37
|
+
A remote-only wiki declared through `wiki.source` is covered by the `wiki` config key, so the pointer form satisfies the same gate.
|
|
@@ -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.
|
|
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([
|
|
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 &&
|
|
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
|
-
|
|
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
|
},
|