diff --git a/docs/devteam/governance/constitution.md b/docs/devteam/governance/constitution.md index 8881fac..6a21893 100644 --- a/docs/devteam/governance/constitution.md +++ b/docs/devteam/governance/constitution.md @@ -16,7 +16,7 @@ Who decides what, how the supervisor earns the right to decide more, and the pri | Fixing production incidents | owner, delegable | On the ladder as `incident-fix`, starting at "ask with a default" (the owner's choice: a fix is a reviewed, gated ticket, and an incident left alone costs more than a question). Scoped by the incident's severity, which the kit computes from its observations: `sev:low` (1 to 2) and `sev:high` (3 and 4), so the owner can let low-severity fixes go ahead and tell them. The default is what lets a fix go ahead without an answer, severe incidents included; it can't fire sooner than 15 minutes after the question. Where merging releases (settings `release: "merge"`), rung 3 of the class needs `release-production` at 3 too: merging a fix deploys it. Whoever makes the fix backs up first where a change could destroy anything, and holds for a human, as an ordinary Inbox question, when not confident in it (`process/worker.md`). Defaulted answers never count as agreement, and a verified fix is evidence, never trust; only the owner's answers move the class. A fix that regresses asks the owner: a wrong call, which they correct (the class drops to rung 1), or a sound call whose fix failed (a problem, no trust change). The kit recommends the second and never corrects. | | Remediating production | owner, delegable | On the ladder as `production-remediation`, start 1, ceiling 2: the owner always sees a restart, a scale or a rollback coming, or it runs at its default (an outage question can carry `--deadline 15m`). Only scripts the owner lists in `remediations` (owner settings, `governance-edit`) run, each pinned by its blob sha, from main, once per decision, soon after the answer, each logged as an act; the answer names the remediation (and a rollback's release), and no standing rule covers the class. A rollback across a `production-migration` act since its target release is a one-way door, whatever `reversible` says: it needs the owner's own answer to a question with no default. | | Allowing anything when a check fails | owner | The default is to deny. | -| A rule, gate or review standard; anything in `governance/` or `process/` | owner | The supervisor proposes with evidence. | +| A rule, gate or review standard; anything in `governance/` or `process/` | owner | The supervisor proposes with evidence. One exception: adding a new gate that runs only development-only tools, leaving every existing gate as it is, is the supervisor's (`gate-add`, §2 and §4). Changing or removing a gate stays the owner's `gate-change`. | | Approving a design, a feature's graph, a ticket's spec and plan | supervisor | Each is an act in the log (`approve-spec`, `approve-plan`, `approve-feature-plan`). | | Contract and ordering rulings; re-plans inside a feature; scheduling | supervisor | Cite the contract text, not convenience. | | **The merge** | supervisor only | `devteam.mjs merge` is the merge word. Workers never merge. | @@ -48,7 +48,7 @@ Some owner decisions can be handed to the supervisor a step at a time, as eviden "incident-fix": { "ceiling": 3, "start": 2, "about": "open and run a ticket that fixes a production incident the sensors found" }, "production-remediation": { "ceiling": 2, "start": 1, "about": "restart, scale or roll back production with a reviewed remediation script" } }, - "constitutional": ["merge-word", "approve-feature-plan", "merge-chore", "worker-slots", "replan-within-feature", "add-ticket", "approve-spec", "approve-plan", "approve-design-change", "contract-ruling", "improvement-trial", "release-staging", "goal-readiness", "apply-default", "withdraw-question", "recovery", "carry-answer", "drain", "undrain", "goal-hold", "fleet-drain", "fleet-park", "fleet-undrain", "fleet-unpark", "registry-edit", "goal-impact", "orient-noise"], + "constitutional": ["merge-word", "approve-feature-plan", "merge-chore", "worker-slots", "replan-within-feature", "add-ticket", "approve-spec", "approve-plan", "approve-design-change", "contract-ruling", "improvement-trial", "release-staging", "goal-readiness", "apply-default", "withdraw-question", "recovery", "carry-answer", "drain", "undrain", "goal-hold", "fleet-drain", "fleet-park", "fleet-undrain", "fleet-unpark", "registry-edit", "goal-impact", "orient-noise", "gate-add"], "owner_only": ["one-way-door", "check-override", "governance-edit", "process-edit", "gate-change", "goal-change", "strategy-change", "goal-met", "registry-attest", "new-dependency", "artifact-publish"], "owner_rules": ["gate-change"], "reasons": [ @@ -66,7 +66,7 @@ Some owner decisions can be handed to the supervisor a step at a time, as eviden } ``` -- **Three kinds of decision.** `classes` are the owner's decisions that can be delegated, each with a start rung and a ceiling. `constitutional` are the supervisor's own; they are logged as decisions or acts and a correction of one feeds knowledge, never authority. `owner_only` stay at "ask" whatever the evidence; of those, the classes in `owner_rules` (gate-change) may also be settled by a standing rule the owner gave themselves (`answer … --rule … --rule-scope class:` from the dashboard, their terminal or chat), which then approves that class's calls and merges as an answer would. A project may lower a ceiling in its settings (`autonomy_ceilings`), never raise one; `validate` fails if it does. `registry-edit` (a url link added, changed or removed) is constitutional: the owner corrects it like any act, and a correction becomes a lesson scoped `link:`, not a trust drop. If the owner wants trust semantics here later, it becomes a ladder class at start 3, ceiling 3. `goal-impact` (how much a goal matters, judged against the strategy) is constitutional too: the owner's correction sets the goal's impact to the value they give and becomes a lesson scoped `class:goal-impact`, not a trust drop. `orient-noise` (a recurring signal ruled noise, citing the observations it explains) is constitutional too: the owner corrects it like any call, a correction brings the signal back to Orient, and it moves no rung. `registry-attest`, the owner's yes that the registry lists everything the project depends on (`planning.md` §7), is owner_only: never defaulted. `artifact-publish`, the owner making a landed artifact readable by anyone from the cloud box or taking it down, is owner_only and the one class of act the owner writes (`artifact publish|unpublish`, which the gateway's confirm page runs): a record of what the owner did, never a call, so trust, decisions and corrections leave it out, and the log refuses it from anyone else. A preview (a running dev server the owner can open) is run state, approves nothing, and is never a record. +- **Three kinds of decision.** `classes` are the owner's decisions that can be delegated, each with a start rung and a ceiling. `constitutional` are the supervisor's own; they are logged as decisions or acts and a correction of one feeds knowledge, never authority. `owner_only` stay at "ask" whatever the evidence; of those, the classes in `owner_rules` (gate-change) may also be settled by a standing rule the owner gave themselves (`answer … --rule … --rule-scope class:` from the dashboard, their terminal or chat), which then approves that class's calls and merges as an answer would. A project may lower a ceiling in its settings (`autonomy_ceilings`), never raise one; `validate` fails if it does. `registry-edit` (a url link added, changed or removed) is constitutional: the owner corrects it like any act, and a correction becomes a lesson scoped `link:`, not a trust drop. If the owner wants trust semantics here later, it becomes a ladder class at start 3, ceiling 3. `goal-impact` (how much a goal matters, judged against the strategy) is constitutional too: the owner's correction sets the goal's impact to the value they give and becomes a lesson scoped `class:goal-impact`, not a trust drop. `orient-noise` (a recurring signal ruled noise, citing the observations it explains) is constitutional too: the owner corrects it like any call, a correction brings the signal back to Orient, and it moves no rung. `registry-attest`, the owner's yes that the registry lists everything the project depends on (`planning.md` §7), is owner_only: never defaulted. `artifact-publish`, the owner making a landed artifact readable by anyone from the cloud box or taking it down, is owner_only and the one class of act the owner writes (`artifact publish|unpublish`, which the gateway's confirm page runs): a record of what the owner did, never a call, so trust, decisions and corrections leave it out, and the log refuses it from anyone else. A preview (a running dev server the owner can open) is run state, approves nothing, and is never a record. `gate-add` (a new gate that runs only development-only tools, such as a linter, added to `settings.gates`) is constitutional too: the owner ruled that development-only tools don't need their approval (§4), and a new check only makes merges stricter. `merge` lands it without a question only from the supervisor's own chore PR, when it adds gates after main's and leaves every gate main has identical (field order included) and in order. A gate's command runs with the supervisor's own environment, like every gate's, so nothing checks that it runs only a development-only tool: that is the supervisor's judgement, and the act is what the owner reviews, and logs it as its own `gate-add` act; the owner corrects it like any act. Changing, loosening, reordering or removing a gate, and any change to `worktree_setup` or `worktree_files`, stays the owner's `gate-change`. - **Why a question was asked.** Every question names its class and carries `why_asked`: the class's rung and ceiling, and reasons from the table. The dashboard shows it first. - **Moving up.** Only through the owner. When answering, the owner can say how the next decision of that class is handled (ask, ask with a default, act and report), for the whole class or one scope; it applies at once, up to the ceiling. Or the supervisor asks a `trust` question when, of the last ten answered questions in the class, the owner went with its recommendation nine or more times (an answer equal to the recommendation; defaulted answers don't count); `ask --kind trust` refuses without that evidence, and the question carries those question ids and the uncorrected calls since the class last dropped. At most one open per class, two in all, asked at a retro. The supervisor never moves itself up. - **Decisions and acts.** A judgment call is a `decision` line (`devteam.mjs decide`): what was decided, the options weighed, why, and the authority it rested on, snapshotted when it was made (the class's rung and ceiling, and the answer, default or standing rule it cites). Acts that carry it out cite it (`act --decision `), take its class and stay within its refs. A decision made on the class's own rung lapses if the class drops after it. A mechanical act (a merge the gates passed, a worker started for a ready ticket) needs no decision line and is its own decision; so is every act logged before decisions existed. @@ -95,7 +95,7 @@ A change is a one-way door if reverting it is expensive or impossible once shipp - a migration that is not rollback-safe, or any destructive data change - a public or cross-component contract change that removes or narrows behaviour - a new persistence shape in an append-only or event-sourced store -- a new top-level dependency, runtime, datastore or vendor; a development-only tool (a linter, formatter, type checker or test helper that ships nothing to production) is not one +- a new top-level dependency, runtime, datastore or vendor; a development-only tool (a linter, formatter, type checker or test helper that ships nothing to production) is not one, and neither is a new gate that runs only such tools (`gate-add`, §2: the supervisor's call, recorded as its own act; changing or removing a gate stays the owner's `gate-change`) - anything touching authentication, authorization, payments or PII - anything the owner lists under one-way doors in the settings diff --git a/docs/devteam/governance/quality.md b/docs/devteam/governance/quality.md index 3a7179c..7db6a2b 100644 --- a/docs/devteam/governance/quality.md +++ b/docs/devteam/governance/quality.md @@ -127,7 +127,7 @@ merge --chore a chore PR (log, retro, design, architecture): the lo `merge ` works in the ticket's repo: its PR, its gates (never inherited from home: a repo with no `gates` is refused like home with none), its `merge_method` and forge, its `ignored_checks`, after fetching home's main and that repo's (either fetch failing refuses). The `gate` and `act` lines land in home's log with a `repo` field. A ticket merges only after every ticket in its `depends_on` has merged, in whatever repo each is (`merge` and `check-pr` refuse it, dry runs included; a dependency whose repo this machine can't read or fetch fails closed: "cannot confirm is merged"), since across repos neither the branch nor the gates can see a provider that hasn't landed; a ticket that reaches main before a dependency anyway (merged by hand or in the forge's UI) is the `merged-out-of-order` finding. Plan and chore PRs are always home's; a chore that must change another repo is a ticket of kind `chore` in that repo's component. `check-pr --repo [--path ]` runs the same checks, minus the gates and the approval acts, for a PR in another repo, in its checkout or the one `--path` names. The kit installs no forge workflow in any repo: `merge` is where these checks are enforced. -Any of the three that changes a setting the owner owns needs `--approved `, an answered question of that class: `gates`, `worktree_setup` and `worktree_files` (`gate-change`: they decide what a gate run sees); `autonomy_ceilings`, `agent_command`, `resume_command`, `worker_denied_commands`, `forge`, `main_branch`, `merge_method`, `merge_admin`, `upstream_repo`, `upstream_sync`, `release`, `secret_commands`, `incident_slots`, `remediations` and `project`, which names every public link (`governance-edit`); and, per entry, `links`: adding, removing or re-kinding a `repo` link, or changing its `remote`, `main_branch`, `forge`, `merge_method`, `release`, `project`, `title`, `notes`, `for` or `source` is `governance-edit`; its `gates`, `worktree_setup`, `worktree_files` or `ignored_checks` is `gate-change`; each repo link entry the PR changes lands only as an act of the `--approved` answer wrote it (`link add|set|rm --approved` logs one per entry its question showed, with the entry that lands and the one main had), so one answer never lands an entry its question didn't show; its `path` and anything on a `url` link are not the owner's: url links are the supervisor's, and `merge` lands a change to one only as a `registry-edit` act wrote it: for each url link whose entry on main differs from the one that would land, acts naming it (`link add|set|rm` logs one) that lead from main's entry to that one: each act records the entry it replaced (`prior_hash`, null for an add) and the one it wrote (`entry_hash`, null for a removal), and the first must have replaced exactly the entry main has, the last written exactly the entry that lands (one act, or an add then a set between landings). An act made from an entry main has since changed never matches, so a stale `link set` never reverts a newer entry and an old `link rm` never removes a link added again since; an act without `prior_hash` (written before it existed) never matches. Only acts logged since main last changed that link's entry count (found from the last 200 commits on main's first-parent line that touched `settings.md`), so an owner's hand-revert of a link on main expires the older acts for that link, and only that link: an old act made from the entry main has again can't carry its entry back over the revert, and a change to another link or another setting expires nothing. `link land` carries an entry into the chore on the same terms, and refuses one main changed since its act: "main's links. changed since ; re-run link set". There is no time window, so an unrelated settings landing expires no act, and a link already as main has it needs none. Only `merge` checks these acts, never `check-pr` (the forge's check can't read the supervisor's live log). It is a process check inside the supervisor's own class, not an authority boundary: `registry-edit` is constitutional, so the supervisor could write a matching act by hand; what it guarantees is that a url link lands through `link add|set|rm` and its logged act, not by a stray edit to `settings.md`. The rest of `project/settings.md` (caps, timings, components) is the supervisor's. +Any of the three that changes a setting the owner owns needs `--approved `, an answered question of that class: `gates`, `worktree_setup` and `worktree_files` (`gate-change`: they decide what a gate run sees), except a chore PR that only adds gates (`gate-add`, the supervisor's: new entries after main's, each a lowercase name with only `cmd`, `paths`, `junit` or `mode`, every gate main has identical as JSON, field order included, and in its order, no key repeated under `gates`; reordering main's gates alone is a change too; a plan PR's gate is the owner's), which `merge` runs at the PR's head (a failing new gate is refused unless it is in ratchet mode) and records as its own `gate-add` act; `worktree_setup` and `worktree_files` have no such exception, since both act on the checkout before every gate runs; `autonomy_ceilings`, `agent_command`, `resume_command`, `worker_denied_commands`, `forge`, `main_branch`, `merge_method`, `merge_admin`, `upstream_repo`, `upstream_sync`, `release`, `secret_commands`, `incident_slots`, `remediations` and `project`, which names every public link (`governance-edit`); and, per entry, `links`: adding, removing or re-kinding a `repo` link, or changing its `remote`, `main_branch`, `forge`, `merge_method`, `release`, `project`, `title`, `notes`, `for` or `source` is `governance-edit`; its `gates`, `worktree_setup`, `worktree_files` or `ignored_checks` is `gate-change`; each repo link entry the PR changes lands only as an act of the `--approved` answer wrote it (`link add|set|rm --approved` logs one per entry its question showed, with the entry that lands and the one main had), so one answer never lands an entry its question didn't show; its `path` and anything on a `url` link are not the owner's: url links are the supervisor's, and `merge` lands a change to one only as a `registry-edit` act wrote it: for each url link whose entry on main differs from the one that would land, acts naming it (`link add|set|rm` logs one) that lead from main's entry to that one: each act records the entry it replaced (`prior_hash`, null for an add) and the one it wrote (`entry_hash`, null for a removal), and the first must have replaced exactly the entry main has, the last written exactly the entry that lands (one act, or an add then a set between landings). An act made from an entry main has since changed never matches, so a stale `link set` never reverts a newer entry and an old `link rm` never removes a link added again since; an act without `prior_hash` (written before it existed) never matches. Only acts logged since main last changed that link's entry count (found from the last 200 commits on main's first-parent line that touched `settings.md`), so an owner's hand-revert of a link on main expires the older acts for that link, and only that link: an old act made from the entry main has again can't carry its entry back over the revert, and a change to another link or another setting expires nothing. `link land` carries an entry into the chore on the same terms, and refuses one main changed since its act: "main's links. changed since ; re-run link set". There is no time window, so an unrelated settings landing expires no act, and a link already as main has it needs none. Only `merge` checks these acts, never `check-pr` (the forge's check can't read the supervisor's live log). It is a process check inside the supervisor's own class, not an authority boundary: `registry-edit` is constitutional, so the supervisor could write a matching act by hand; what it guarantees is that a url link lands through `link add|set|rm` and its logged act, not by a stray edit to `settings.md`. The rest of `project/settings.md` (caps, timings, components) is the supervisor's. With no forge CLI (no `gh`, a self-hosted git remote), set `forge: "git"`. the PR is a file, `run/pr/.md` (a `# ` line, then the body; or `--title` and `--body-file`). `merge` checks it as it would a PR, merges the exact verified sha onto origin's main with `--no-ff`, and pushes. If main moved, the push fails, which is the same guard as `--match-head-commit`. diff --git a/docs/devteam/process/supervisor.md b/docs/devteam/process/supervisor.md index ab5d20d..298b27e 100644 --- a/docs/devteam/process/supervisor.md +++ b/docs/devteam/process/supervisor.md @@ -128,7 +128,7 @@ Production is watched by **sensors**, and they are yours: you add, change and st - **Chore PRs** (`merge --chore <slug>`: log, retros, designs, the architecture doc) may not touch `project/log` (except `chore-log`, which touches nothing else) or `project/tickets`. One that changes anything outside `project/` (`.claude/`, `.github/`, the kit) needs the owner's answer, `merge --chore <slug> --approved <qid>`; one that changes a real kit file (under the kit's own path, other than `project/` and `run/`) needs it in a `governance-edit`, `process-edit` or `gate-change` question, however it was given (chat counts). Kit files merged verbatim from upstream need no question (`chore-devteam-sync-<tag>` under `upstream_sync` `"adopt"` or a standing rule, §9), and a secret chore (`chore-secret-<n>`) lands the encrypted stores with the answer that posted them. - **The forge's checks.** `merge` refuses while any check on the PR's head is pending or failing, whether or not branch protection requires it (a deploy pipeline's status counts too). Wait and merge again. A check that's broken or irrelevant is the owner's to skip, in `ignored_checks`. - **Standing CI fixes.** With the owner's rule scoped `chore:ci-fix` ("You may fix broken CI files without me"), a `ci-fix-<what>` chore merges without asking when it changes only `.github/workflows/*.yml`, deletes none, keeps every job and devteam's own `check-pr`, and its CI passes. Anything more is the owner's answer. -- **Owner settings, in any PR.** A ticket, plan or chore PR that changes `gates`, `worktree_setup`, `worktree_files`, `agent_command`, the deny rules, `autonomy_ceilings`, `forge`, `main_branch`, `merge_method`, `merge_admin`, `upstream_repo`, `upstream_sync`, `release`, `secret_commands`, `incident_slots`, `remediations`, `project` or `ignored_checks` in `project/settings.md` merges only with `--approved <qid>` naming an answered question of that class (`quality.md` §4). Ask it when the ticket is planned, not at merge time. +- **Owner settings, in any PR.** A ticket, plan or chore PR that changes `gates` (except a chore that only adds a gate, below and §6), `worktree_setup`, `worktree_files`, `agent_command`, the deny rules, `autonomy_ceilings`, `forge`, `main_branch`, `merge_method`, `merge_admin`, `upstream_repo`, `upstream_sync`, `release`, `secret_commands`, `incident_slots`, `remediations`, `project` or `ignored_checks` in `project/settings.md` merges only with `--approved <qid>` naming an answered question of that class (`quality.md` §4). Ask it when the ticket is planned, not at merge time. - **Merging is releasing where production deploys on merge** (settings `release: "merge"`, `quality.md` §5). Below rung 3 of `release-production`, decide the release first (`decide --class release-production --refs <ticket> [--approved <qid>] -- "release <what>"`) and merge with `--decision <d-id>`; `merge` refuses otherwise and logs the release as an act when it lands. A decision on the owner's answer releases only what the question named: ask with `--ticket <ticket or chore-<slug>>`, or for a batch `ask --approves <id>,<id>,…` and name the same ids in `--refs`; a question naming neither releases nothing. A decision releases each target once, and a second merge of the same target decides again. - Unblocking: co-land a consumer's small fix in a provider's PR when a contract change breaks it; turn "I need X from component Y" into a contract ticket; give a blocked worker a review or an investigation that doesn't touch its blocked files; let a ticket merge with a documented "built, withheld until X" done-when. @@ -139,7 +139,7 @@ A `--class` names a ladder, owner-only or constitutional class from `constitutio **Ask less, act more.** The owner wants the fleet to move. Every question costs them a context switch and stops whatever waits on it, so the bar for asking is high and the bar for acting is low. Start from what they would want, not from what you are allowed. - **Decide what is yours.** Everything not reserved to the owner (constitution §1) or below rung 3 is yours: decide it, log the act and keep going. The owner sees it in the act feed and corrects what they disagree with; a correction is cheaper for them than a question. On a reversible call you are 0.7 sure of, act. -- **Development-only tools are yours.** A linter, formatter, type checker or test helper that ships nothing to production is not a new dependency and not a one-way door (constitution §4): add it yourself, vetted and pinned like any dependency (code-style.md L-SEC-6, L-SEC-7), log the act and never ask the owner about it. The owner has ruled on this for every project. +- **Development-only tools are yours.** A linter, formatter, type checker or test helper that ships nothing to production is not a new dependency and not a one-way door (constitution §4): add it yourself, vetted and pinned like any dependency (code-style.md L-SEC-6, L-SEC-7), log the act and never ask the owner about it. The owner has ruled on this for every project. A gate that runs only such a tool (shellcheck, a type check) is yours too: add it to `settings.gates` in a chore PR of your own that changes no existing gate, and `merge --chore` lands it with no question, runs it at the PR's head first (one that fails there is refused: fix it, or add it with `"mode": "ratchet"`) and logs it as its own `gate-add` act. Changing, removing, renaming or reordering any gate, and any change to `worktree_setup` or `worktree_files`, stays the owner's `gate-change`. - **Never ask whether to ask.** "Should I ask you for the API key?", "Do you want me to request access?", "Should I set up staging?" are not questions. If the work needs it, ask for the thing itself. `ask` refuses a question about asking. - **Credentials: ask for them outright.** When work needs a password, key, token or environment variable, assume the owner wants to give it: every credential is a step towards running without them. Ask at once with an `owner-action` and `--secrets` (below), so the owner gets the paste box, not a discussion. Put every credential a goal needs in one question (up to 20), each with a line on what it unlocks. Use a credential the project already has without asking, for what it was given for; having a production credential is never leave to act on production, which stays behind its own classes (constitution §1). `ask` refuses a question that asks whether to ask for one, or asks for one in a decision's words ("Can you share the staging password?"), and warns when a question mentions one without `--secrets`. - **Get ahead of the owner.** When you plan a goal, list everything only the owner can give (accounts, keys, access, a decision the design leaves open) and ask for all of it up front, in as few questions as you can, while work starts on what doesn't need it. Never let a worker reach a credential or a decision the owner could have given a day earlier. diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index 86f66b8..c3430b6 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -577,7 +577,94 @@ function settingsAt(ref, { orNull = false } = {}) { function ownerSettingsChanged(base, head) { const a = settingsAt(base), b = settingsAt(head); if (!a || !b) return null; - return [...Object.entries(OWNER_SETTINGS).filter(([k]) => canonical(a[k]) !== canonical(b[k])).map(([key, cls]) => ({ key, cls })), ...linkOwnerChanges(a.links, b.links)]; + // gates run in their order, one after another in one checkout, so their order is the owner's too: + // compared as written (key order included), not canonically + const differs = (k) => (k === 'gates' ? JSON.stringify(a[k]) !== JSON.stringify(b[k]) : canonical(a[k]) !== canonical(b[k])); + return [...Object.entries(OWNER_SETTINGS).filter(([k]) => differs(k)).map(([key, cls]) => ({ key, cls })), ...linkOwnerChanges(a.links, b.links)]; +} +// Adding a gate is the supervisor's call when it only adds (constitution.md §4: development-only +// tools are the supervisor's): a new check makes merges stricter, never looser. A gate's command runs +// with the supervisor's own environment, as every gate's does, so it gives the supervisor nothing it +// can't already do in its shell; that is why merge takes it only from the supervisor's own chore PR +// (a planner's plan PR stays the owner's), and logs it as an act the owner can correct. So a chore may change +// settings.gates without the owner only when it adds new entries after the ones main has, and +// leaves every one of those byte-identical (its text as JSON, field order included), in main's +// order, none removed or renamed. A new entry is a lowercase name (a letter first, so it can't be a +// number JavaScript iterates before main's gates) and a gate of the fields the kit knows (cmd, +// paths, junit, mode): a field the kit adds later, which might widen what a gate is, stays the +// owner's to set. The head's settings block must not repeat a key under gates (JSON.parse keeps the +// last one silently, so a second "lint" would replace main's out of sight). Read from the trees, so +// no log line changes the answer. Returns { added: [names] }, or { problem } saying why it is +// still the owner's gate-change. worktree_setup and worktree_files are not covered: both act on the +// checkout before every gate runs, so even an addition changes what main's gates see. +const GATE_ADD_NAME = /^[a-z][a-z0-9-]{0,39}$/; +const GATE_ADD_FIELDS = ['cmd', 'paths', 'junit', 'mode']; +const settingsBlockAt = (ref) => git(['show', `${ref}:${projectRel}/settings.md`])?.match(/```json devteam\s*\n([\s\S]*?)\n```/)?.[1] ?? null; +function gateAdditions(base, head) { + const a = settingsAt(base), b = settingsAt(head); + if (!a || !b) return { problem: 'settings.md is not readable' }; + const text = settingsBlockAt(head); + const dups = text === null ? [] : jsonDuplicateKeys(text); + const mine = dups?.filter((k) => k[0] === 'gates'); + if (mine === undefined) return { problem: 'settings.md is not valid JSON' }; + if (mine.length) return { problem: `it repeats ${[...new Set(mine.map((k) => k.join('.')))].join(', ')} in settings.md` }; + const before = a.gates === undefined ? {} : a.gates; + if (!isObj(before) || !isObj(b.gates)) return { problem: 'gates is not an object' }; + const was = Object.keys(before), now = Object.keys(b.gates); + const gone = was.filter((k) => !Object.hasOwn(b.gates, k)); + if (gone.length) return { problem: `it removes ${gone.join(', ')}` }; + const edited = was.filter((k) => JSON.stringify(before[k]) !== JSON.stringify(b.gates[k])); + if (edited.length) return { problem: `it changes ${edited.join(', ')}` }; + if (now.slice(0, was.length).some((k, i) => k !== was[i])) return { problem: 'it reorders the gates main has' }; + const added = now.slice(was.length); + for (const n of added) { const p = newGateProblem(n, b.gates[n]); if (p) return { problem: `new gate ${JSON.stringify(n).slice(0, 60)}: ${p}` }; } + return { added }; +} +function newGateProblem(name, spec) { + if (!GATE_ADD_NAME.test(name)) return 'a new gate is a short lowercase name, a letter first (shellcheck, lint-css)'; + if (!isObj(spec) && typeof spec !== 'string') return 'a gate is a command, or an object with cmd'; + const s = typeof spec === 'string' ? { cmd: spec } : spec; + const extra = Object.keys(s).filter((k) => !GATE_ADD_FIELDS.includes(k)); + if (extra.length) return `it sets ${extra.join(', ')}; a new gate the owner hasn't approved has only ${GATE_ADD_FIELDS.join(', ')}`; + if (typeof s.cmd !== 'string' || !s.cmd.trim() || s.cmd.length > 500 || UNSAFE_TEXT.test(s.cmd)) return 'cmd is one line of plain text, at most 500 characters'; + if (s.paths !== undefined && !(Array.isArray(s.paths) && s.paths.length && s.paths.every((p) => typeof p === 'string' && p && !UNSAFE_TEXT.test(p)))) return 'paths is a list of globs'; + if (s.junit !== undefined && !(typeof s.junit === 'string' && s.junit && !UNSAFE_TEXT.test(s.junit) && !path.isAbsolute(s.junit) && !s.junit.split(/[\\/]/).includes('..'))) return 'junit is a path inside the checkout'; + if (s.mode !== undefined && !['enforced', 'ratchet'].includes(s.mode)) return 'mode is "enforced" or "ratchet"'; + return null; +} +// The keys JSON text repeats inside one object, each as its path ([key, …]), or undefined when the +// text isn't JSON. JSON.parse keeps the last of a repeated key and says nothing. +function jsonDuplicateKeys(text) { + try { JSON.parse(text); } catch { return undefined; } + const dups = []; + let i = 0; + const ws = () => { while (' \t\n\r'.includes(text[i]) && i < text.length) i++; }; + const str = () => { const s = i++; while (text[i] !== '"') i += text[i] === '\\' ? 2 : 1; i++; return JSON.parse(text.slice(s, i)); }; + const val = (at) => { + ws(); + if (text[i] === '{') { + i++; ws(); + if (text[i] === '}') { i++; return; } + const seen = new Set(); + for (;;) { + ws(); const k = str(); + if (seen.has(k)) dups.push([...at, k]); + seen.add(k); + ws(); i++; // the colon + val([...at, k]); ws(); + if (text[i++] === '}') return; + } + } + if (text[i] === '[') { + i++; ws(); + if (text[i] === ']') { i++; return; } + for (let n = 0; ; n++) { val([...at, n]); ws(); if (text[i++] === ']') return; } + } + if (text[i] === '"') { str(); return; } + while (i < text.length && !' \t\n\r,]}'.includes(text[i])) i++; + }; + val([]); + return dups; } // A link is classified by both sides: an owner's kind (LINK_KINDS[k].ownedByOwner: repo) on // either side makes every change to it the owner's; an add, a removal or a kind flip is @@ -4386,23 +4473,31 @@ function cmdMerge() { // owner answer; real kit files need it in a kit class (KIT_CLASSES). project/ files are the supervisor's. let approved = flagStr('approved') ?? null; let standing = null; + // gates this PR adds as the supervisor's own call (gateAdditions): run at its head below, and + // logged as their own gate-add act once it lands + let gateAdds = null; // Owner-only settings, on every kind of PR (a ticket, a plan or a chore) in home, where // settings.md is: another repo's PR can't change it. if (R.name === 'home') { const base = mainRef() ? git(['merge-base', mainRef(), sha]) : null; const classed = base ? ownerSettingsChanged(base, sha) : []; - const changed = classed?.filter((c) => !SUPERVISOR_LINK_CLASSES.includes(c.cls)) ?? null; + // Only a chore: the supervisor's own PR. A plan PR is a planner's, and a ticket's can't change + // settings.md at all, so a gate added there is still the owner's gate-change. + const gateAdd = classed?.some((c) => c.key === 'gates') ? (chore ? gateAdditions(base, sha) : { problem: 'only the supervisor\'s own chore PR adds a gate without the owner' }) : null; + if (gateAdd?.added?.length) { gateAdds = gateAdd.added.map((n) => [n, settingsAt(sha).gates[n]]); out(`adds gate${gateAdds.length > 1 ? 's' : ''} ${gateAdd.added.join(', ')} and leaves main's as they are: a new check is the supervisor's call (constitution.md §4), to be logged as a gate-add act when it lands`); } + const changed = classed?.filter((c) => !SUPERVISOR_LINK_CLASSES.includes(c.cls) && !(c.key === 'gates' && gateAdds)) ?? null; fails.push(...registryActFails(base, sha)); fails.push(...strategyFails(base, sha, approved)); if (changed === null) fails.push(`${projectRel}/settings.md: the json devteam block is not valid JSON at the head`); else if (changed.length) { const classes = [...new Set(changed.map((c) => c.cls))]; const what = changed.map((c) => `${c.key} (${c.cls})`).join(', '); + const gatesWhy = gateAdd?.problem && changed.some((c) => c.key === 'gates') ? ` (gates: ${gateAdd.problem}; only adding a gate is the supervisor's)` : ''; // A standing rule the owner gave themselves settles a class in owner_rules (constitution.md): // only when every changed setting is of such a class, and only the owner's own accepted rule. const ruled = !approved && classes.every((c) => (ladder().owner_rules ?? []).includes(c)) && classes.map((c) => ownerRuleFor(c)).every(Boolean); if (ruled) { const rs = classes.map((c) => ownerRuleFor(c)); standing = { rule: rs[0] }; out(`changes owner settings: ${what}; approved by the owner's standing rule${rs.length > 1 ? 's' : ''} ${rs.map((r) => `${r.id} ("${r.statement}")`).join(', ')}`); } - else if (!approved) fails.push(`changes owner settings: ${what}; the owner's answer is needed: --approved <qid> of class ${classes.join(' or ')}`); + else if (!approved) fails.push(`changes owner settings: ${what}${gatesWhy}; the owner's answer is needed: --approved <qid> of class ${classes.join(' or ')}`); else { const q = ownerAnswer(approved, 'merge --approved'); if (!classes.includes(q.class)) fails.push(`${approved} is a ${q.class ?? 'classless'} question; changing ${what} needs an answered ${classes.join(' or ')} question`); @@ -4538,6 +4633,23 @@ function cmdMerge() { R.git(['worktree', 'remove', '--force', wt], R.root); } } + // A gate this PR adds runs at its head before it lands, on every kind of PR: one that fails there + // would refuse every merge after it, and taking it out again is the owner's gate-change. A + // ratchet gate may fail (pre-existing debt is what ratchet mode is for). + if (!fails.length && gateAdds?.length) { + const wt = detachedWorktree(sha, `gate-add-${slug(branch)}`, R); + try { + for (const [name, spec] of gateAdds) { + const started = Date.now(); + let ok = true; + try { execFileSync('sh', ['-c', typeof spec === 'string' ? spec : spec.cmd], { cwd: wt, stdio: 'inherit', env: gateEnv(null, R) }); } catch { ok = false; } + appendLog({ type: 'gate', gate: name, ok, ms: Date.now() - started, sha, tests_total: 0, tests: [], preexisting: !ok && spec.mode === 'ratchet' ? true : undefined }, 'supervisor'); + if (!ok && spec.mode !== 'ratchet') fails.push(`the gate ${name} this PR adds fails at ${sha.slice(0, 12)}: fix it, or add it with "mode": "ratchet", since a failing gate would refuse every merge after it`); + } + } finally { + R.git(['worktree', 'remove', '--force', wt], R.root); + } + } if (fails.length) report(fails, ''); if (dry) return out(`ok: ${branch} at ${sha.slice(0, 12)} would merge${inR}`); const mainBranch = S.main_branch; @@ -4561,6 +4673,12 @@ function cmdMerge() { const cls = id ? 'merge-word' : plan ? 'approve-feature-plan' : 'merge-chore'; // A release-production decision is carried out by the release act, never by the merge act. const act = appendLog({ type: 'act', decision: releaseDecision ? undefined : citing(), class: cls, what: `merged ${branch} at ${sha}${['park', 'let-go'].includes(heldBy) ? ` while its goal is ${holdWords(heldBy)}, with --force` : ''}${standing?.tag ? ` (upstream ${standing.tag} at ${standing.tagSha}, verbatim${standing.policy ? ', under settings.upstream_sync "adopt"' : ''})` : standing ? ` (${standing.what}, under the owner's standing rule)` : ''}`, refs: id ? [id] : plan ? [plan] : [`chore-${chore}`], reversible: false, approved_by: approved ?? standing?.rule?.id ?? undefined, ...onR }, 'supervisor'); + // Adding a gate is the supervisor's own act (constitutional gate-add), recorded apart from the merge + // so the owner sees each new check in the act feed and can correct it. + if (gateAdds?.length) { + const ga = appendLog({ type: 'act', class: 'gate-add', what: `added gate${gateAdds.length > 1 ? 's' : ''} ${gateAdds.map(([n, spec]) => `${n} (${JSON.stringify(typeof spec === 'string' ? spec : spec.cmd).slice(0, 200)})`).join(', ')} to settings.gates by merging ${branch} at ${sha}`, refs: [...act.refs, act.id], reversible: false }, 'supervisor'); + out(`added gate${gateAdds.length > 1 ? 's' : ''} ${gateAdds.map(([n]) => n).join(', ')} (${ga.id})`); + } if (release) { const rel = appendLog({ type: 'act', class: 'release-production', decision: release.decision, rung: release.rung, what: `released ${release.target}${inR} to production by merging ${branch} at ${sha} (release: merge)`, refs: [release.target, act.id], reversible: false, ...onR }, 'supervisor'); out(`released ${release.target}${inR} to production (${rel.id})`); diff --git a/test/gate-add.test.mjs b/test/gate-add.test.mjs new file mode 100644 index 0000000..bc65a22 --- /dev/null +++ b/test/gate-add.test.mjs @@ -0,0 +1,185 @@ +// Adding a gate is the supervisor's call (constitution.md §4, gate-add): a PR that only adds new +// gates after main's, leaving every one of main's byte-identical and in order, merges with no owner +// question, after the new gate passes at its head, and is logged as its own gate-add act. Anything +// else that changes settings.gates (an edit, a removal, a rename, a reorder, a repeated key) stays the +// owner's gate-change, decided from the trees whatever the log says. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fleetFixture, write, line } from './lib/kit.mjs'; + +const SUP = { DEVTEAM_ROLE: 'supervisor' }; +const SETTINGS = 'docs/devteam/project/settings.md'; +const MAIN_GATES = { lint: 'true', test: { cmd: 'true', paths: ['src/**'] } }; + +// settings.md's json devteam block, rewritten: fn edits the parsed object; raw (optional) edits the +// text after, for what JSON.stringify can't write (a repeated key) +const editSettings = (dir, fn, raw = (t) => t) => { + const f = path.join(dir, SETTINGS); + const txt = fs.readFileSync(f, 'utf8'); + const m = txt.match(/```json devteam\s*\n([\s\S]*?)\n```/); + const j = JSON.parse(m[1]); + fn(j); + fs.writeFileSync(f, txt.replace(m[1], () => raw(JSON.stringify(j, null, 2)))); +}; + +// main with two gates, one a string and one an object +function withGates(F) { + editSettings(F.old, (j) => { j.gates = MAIN_GATES; }); + F.g(['commit', '-qam', 'gates'], F.old); + F.g(['push', '-q', 'origin', 'main'], F.old); +} + +// A chore branch off origin/main whose settings edit is fn (and raw), pushed, with its PR body. +function chore(F, slug, fn, raw) { + const { old, g } = F; + const w = path.join(old, '.worktrees', `chore-${slug}`); + g(['worktree', 'add', '-q', '-b', `chore-${slug}`, w, 'origin/main'], old); + editSettings(w, fn, raw); + g(['add', '-A'], w); + g(['commit', '-qm', slug], w); + g(['push', '-q', 'origin', `chore-${slug}`], w); + g(['worktree', 'remove', '--force', w], old); + write(path.join(old, `docs/devteam/run/pr/chore-${slug}.md`), `# chore-${slug}: x\n\n## One-way doors\n\nNone.\n`); +} +const merge = (F, slug, ...extra) => { const r = F.run(F.old, ['merge', '--chore', slug, ...extra], SUP); return { status: r.status, text: `${r.out}\n${r.err}` }; }; +const liveLog = (F) => { const d = path.join(F.old, 'docs/devteam/run/log'); return (fs.existsSync(d) ? fs.readdirSync(d) : []).filter((f) => f.endsWith('.jsonl')).flatMap((f) => fs.readFileSync(path.join(d, f), 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l))); }; +const refused = (r, re) => { assert.notEqual(r.status, 0, r.text); assert.match(r.text, re); }; + +test('an added gate merges with no question, runs at the head first, and is logged as its own gate-add act', () => { + const F = fleetFixture('gate-add-merges'); + try { + withGates(F); + chore(F, 'shellcheck', (j) => { j.gates.shellcheck = 'true'; }); + const r = merge(F, 'shellcheck'); + assert.equal(r.status, 0, r.text); + assert.match(r.text, /adds gate shellcheck and leaves main's as they are/); + assert.doesNotMatch(r.text, /changes owner settings/); + const log = liveLog(F); + const mergeAct = log.find((o) => o.type === 'act' && o.class === 'merge-chore' && o.refs?.includes('chore-shellcheck')); + const add = log.find((o) => o.type === 'act' && o.class === 'gate-add'); + assert.ok(mergeAct && add, JSON.stringify(log.filter((o) => o.type === 'act'))); + assert.match(add.what, /^added gate shellcheck \("true"\) to settings\.gates by merging chore-shellcheck at [0-9a-f]{40}$/); + assert.deepEqual(add.refs, ['chore-shellcheck', mergeAct.id]); + assert.equal(add.approved_by, undefined); + assert.ok(log.some((o) => o.type === 'gate' && o.gate === 'shellcheck' && o.ok), 'the new gate ran at the head'); + F.g(['fetch', '-q', 'origin'], F.old); + const onMain = JSON.parse(F.g(['show', `origin/main:${SETTINGS}`], F.old).match(/```json devteam\s*\n([\s\S]*?)\n```/)[1]); + assert.deepEqual(Object.keys(onMain.gates), ['lint', 'test', 'shellcheck']); + } finally { F.done(); } +}); + +test('an edited, removed or renamed gate is refused without the owner, alone or beside an added one', () => { + const F = fleetFixture('gate-add-refused'); + try { + withGates(F); + chore(F, 'edit', (j) => { j.gates.test.paths = ['src/**', 'lib/**']; }); + refused(merge(F, 'edit', '--dry-run'), /changes owner settings: gates \(gate-change\) \(gates: it changes test; only adding a gate is the supervisor's\)/); + chore(F, 'loosen', (j) => { j.gates.test.mode = 'ratchet'; }); + refused(merge(F, 'loosen', '--dry-run'), /gates \(gate-change\) \(gates: it changes test/); + chore(F, 'remove', (j) => { delete j.gates.lint; }); + refused(merge(F, 'remove', '--dry-run'), /gates \(gate-change\) \(gates: it removes lint/); + chore(F, 'rename', (j) => { j.gates = { test: j.gates.test, eslint: 'true' }; }); + refused(merge(F, 'rename', '--dry-run'), /gates \(gate-change\) \(gates: it removes lint/); + chore(F, 'add-and-edit', (j) => { j.gates.shellcheck = 'true'; j.gates.lint = 'exit 0'; }); + refused(merge(F, 'add-and-edit', '--dry-run'), /gates \(gate-change\) \(gates: it changes lint/); + // the same entries, main's in another order, then the new one + chore(F, 'reorder', (j) => { j.gates = { test: j.gates.test, lint: j.gates.lint, shellcheck: 'true' }; }); + refused(merge(F, 'reorder', '--dry-run'), /gates \(gate-change\) \(gates: it reorders the gates main has/); + // the same value written with its fields in another order is not byte-identical + chore(F, 'refield', (j) => { j.gates.test = { paths: ['src/**'], cmd: 'true' }; j.gates.shellcheck = 'true'; }); + refused(merge(F, 'refield', '--dry-run'), /gates \(gate-change\) \(gates: it changes test/); + // main's gates in another order and nothing else: they run in order, so that is the owner's too + chore(F, 'reorder-only', (j) => { j.gates = { test: j.gates.test, lint: j.gates.lint }; }); + refused(merge(F, 'reorder-only', '--dry-run'), /changes owner settings: gates \(gate-change\) \(gates: it reorders the gates main has/); + // a field reordered inside one gate and nothing else + chore(F, 'refield-only', (j) => { j.gates.test = { paths: ['src/**'], cmd: 'true' }; }); + refused(merge(F, 'refield-only', '--dry-run'), /gates \(gate-change\) \(gates: it changes test/); + // nothing lands, and no gate-add act was written + assert.ok(!liveLog(F).some((o) => o.class === 'gate-add')); + } finally { F.done(); } +}); + +test('an added gate whose name repeats a key under gates is refused: JSON.parse would keep the last one unseen', () => { + const F = fleetFixture('gate-add-dup'); + try { + withGates(F); + // a second "lint" after the new gate; its value is main's, so the parsed gates look like an addition + chore(F, 'dup', (j) => { j.gates.shellcheck = 'true'; }, (t) => t.replace('"shellcheck": "true"', '"shellcheck": "true",\n "lint": "true"')); + refused(merge(F, 'dup', '--dry-run'), /gates \(gate-change\) \(gates: it repeats gates\.lint in settings\.md/); + // a repeated key elsewhere in settings is not this rule's business; a repeated gates is + chore(F, 'dup-gates', (j) => { j.gates.shellcheck = 'true'; }, (t) => t.replace(/\n}$/, `,\n "gates": ${JSON.stringify({ ...MAIN_GATES, shellcheck: 'true' })}\n}`)); + refused(merge(F, 'dup-gates', '--dry-run'), /gates \(gate-change\) \(gates: it repeats gates in settings\.md/); + // a key repeated inside one of main's gates + chore(F, 'dup-field', (j) => { j.gates.shellcheck = 'true'; }, (t) => t.replace('"cmd": "true",', '"cmd": "true",\n "cmd": "true",')); + refused(merge(F, 'dup-field', '--dry-run'), /gates \(gate-change\) \(gates: it repeats gates\.test\.cmd in settings\.md/); + } finally { F.done(); } +}); + +test('a new gate is a plain check: a known shape, a lowercase name, and it passes at the head unless in ratchet mode', () => { + const F = fleetFixture('gate-add-shape'); + try { + withGates(F); + chore(F, 'numeric', (j) => { j.gates['1'] = 'true'; }); + refused(merge(F, 'numeric', '--dry-run'), /gate-change\) \(gates: (it reorders the gates main has|new gate "1": a new gate is a short lowercase name)/); + chore(F, 'upper', (j) => { j.gates.Shellcheck = 'true'; }); + refused(merge(F, 'upper', '--dry-run'), /new gate "Shellcheck": a new gate is a short lowercase name/); + chore(F, 'field', (j) => { j.gates.shellcheck = { cmd: 'true', env: { RAILS_ENV: 'production' } }; }); + refused(merge(F, 'field', '--dry-run'), /new gate "shellcheck": it sets env; a new gate the owner hasn't approved has only cmd, paths, junit, mode/); + chore(F, 'junit', (j) => { j.gates.shellcheck = { cmd: 'true', junit: '../../secrets.xml' }; }); + refused(merge(F, 'junit', '--dry-run'), /junit is a path inside the checkout/); + chore(F, 'newline', (j) => { j.gates.shellcheck = 'true\nrm -rf "$DEVTEAM_HOME"'; }); + refused(merge(F, 'newline', '--dry-run'), /cmd is one line of plain text/); + chore(F, 'failing', (j) => { j.gates.shellcheck = 'false'; }); + refused(merge(F, 'failing', '--dry-run'), /the gate shellcheck this PR adds fails at [0-9a-f]{12}: fix it, or add it with "mode": "ratchet"/); + chore(F, 'ratchet', (j) => { j.gates.shellcheck = { cmd: 'false', mode: 'ratchet', paths: ['**/*.sh'] }; }); + const r = merge(F, 'ratchet', '--dry-run'); + assert.equal(r.status, 0, r.text); + assert.match(r.text, /adds gate shellcheck/); + } finally { F.done(); } +}); + +test('a hand-written log line changes nothing: the check reads the trees', () => { + const F = fleetFixture('gate-add-hand'); + try { + withGates(F); + chore(F, 'edit', (j) => { j.gates.shellcheck = 'true'; j.gates.lint = 'exit 0'; }); + // an act claiming the edit was a gate-add, and one claiming a gate-change, both by hand + const at = new Date().toISOString(); + F.ok(F.old, ['act', '--class', 'gate-add', '--refs', 'chore-edit', '--', 'added gate shellcheck; lint is unchanged'], SUP); + fs.mkdirSync(path.join(F.old, 'docs/devteam/run/log'), { recursive: true }); + fs.appendFileSync(path.join(F.old, 'docs/devteam/run/log', `supervisor-${at.slice(0, 7)}.jsonl`), + line({ id: 'a-20990101-000000000-supervisor', op: 'new', at, by: 'supervisor', type: 'act', class: 'gate-add', what: 'added gate shellcheck; lint is unchanged', refs: ['chore-edit'], reversible: true }) + + line({ id: 'a-20990101-000000001-supervisor', op: 'new', at, by: 'supervisor', type: 'act', class: 'gate-change', what: 'the owner approved the lint change', refs: ['chore-edit'], reversible: true })); + refused(merge(F, 'edit', '--dry-run'), /changes owner settings: gates \(gate-change\) \(gates: it changes lint/); + // and an addition needs no line at all + chore(F, 'add', (j) => { j.gates.shellcheck = 'true'; }); + const r = merge(F, 'add', '--dry-run'); + assert.equal(r.status, 0, r.text); + } finally { F.done(); } +}); + +test('worktree_setup and worktree_files stay the owner\'s, even beside an added gate', () => { + const F = fleetFixture('gate-add-setup'); + try { + withGates(F); + chore(F, 'setup', (j) => { j.gates.shellcheck = 'true'; j.worktree_setup = 'npm ci'; }); + refused(merge(F, 'setup', '--dry-run'), /changes owner settings: worktree_setup \(gate-change\)/); + chore(F, 'files', (j) => { j.gates.shellcheck = 'true'; j.worktree_files = [...j.worktree_files, 'config/master.key']; }); + refused(merge(F, 'files', '--dry-run'), /changes owner settings: worktree_files \(gate-change\)/); + } finally { F.done(); } +}); + +test('the first gate on a main with none, or no gates key at all, is an addition too', () => { + const F = fleetFixture('gate-add-first'); + try { + editSettings(F.old, (j) => { delete j.gates; }); + F.g(['commit', '-qam', 'no gates key'], F.old); + F.g(['push', '-q', 'origin', 'main'], F.old); + chore(F, 'first', (j) => { j.gates = { shellcheck: 'true' }; }); + const r = merge(F, 'first'); + assert.equal(r.status, 0, r.text); + assert.ok(liveLog(F).some((o) => o.type === 'act' && o.class === 'gate-add' && o.reversible === false)); + } finally { F.done(); } +});