Skip to content

GitPulse v1.2.0

Choose a tag to compare

@github-actions github-actions released this 16 Sep 21:30

A release about checks that were not telling the truth. Every fix here is a
report that read as an answer without being one: a doctor that compared a
version against itself and said ok for a binary running week-old code, an
installation check that named a cause it did not have, warnings dropped on the
way to the panel that promised to show them, and a measurement that silently
never happened on a window that had stopped painting. A check that could not
run must not read like one that ran and passed.

Repository trust is the other half. An approval made before 1.1.0 left every
linked worktree refused with nothing offering a way out — the app reported the
repository as trusted and never asked again, so worktree comparisons, collision
checks and the fleet view quietly dropped every sibling. Trust is now reported
as a scope rather than a yes/no, and the sidebar offers to extend an
approval that predates worktree coverage. Approvals are still never widened by
being read: extending is a decision you take, through the same dialog.

The new surface in this release, Firebase App Hosting, is built on the same
rule. Its backend and rollout listings are click-only rather than loaded on
render, because the upstream commands enable the App Hosting API on the project
they read — a read that bills is a decision, not a side effect of opening a
panel. What it buys is a join rather than a guess: App Hosting reports the full
commit SHA it deployed, so "this commit is live" is looked up against the graph
instead of inferred from a branch name and a timestamp.

That panel can now also deploy, and one action in this release changes what
production serves. Creating a rollout is neither reversible from GitPulse — App
Hosting publishes no rollback verb — nor idempotent, so it is the one place
here guarded twice rather than once: the target must be a full 40-character
SHA, the confirmation names the exact project, backend and commit and says
plainly that GitPulse cannot undo it, and editing any part of that target
disarms the confirmation so the values sent are always the values reviewed. The
policy gate runs before the process, not alongside it. And what the installed
CLI can actually do is probed rather than assumed, because the upstream
subcommand is registered only behind an experiment that is off by default — a
capability that could not be probed is reported as unknown, never as absent.

Added

  • App Hosting can deploy a named commit, not only report one. Create rollout
    is the only command in the Firebase module that changes what production
    traffic serves, and it is built to be refused easily and taken deliberately.
    The commit must be a full 40-character SHA — an abbreviation is an ambiguous
    target for something that reaches production, and is declined with the
    reason — and the deploying button does not exist until a confirmation naming
    the project, the backend, the commit and the fact that GitPulse cannot undo
    it has been read; changing any part of that target disarms it again. The
    write gate judges the argv before any process is spawned, and a contract test
    asserts that ordering directly rather than trusting it, because this argv is
    built in the Firebase module instead of being spelled out in the handler and
    is therefore exempt from the literal comparison the other commands get.
    --token is never passed, so no credential is placed on a command line other
    local processes can read, and --force is never passed either, so nothing
    here suppresses a prompt the user would otherwise have seen. A rollout that
    started is never reported as failed: if the CLI exits zero without confirming
    the result, the panel says so and warns against a blind retry, because
    upstream allocates a new rollout id per call and a false failure is what
    turns one deployment into two.

  • The Firebase panel asks the installed CLI what it can do instead of assuming.
    apphosting:rollouts:list is registered upstream only behind the
    internaltesting experiment, which is off by default, and an unregistered
    subcommand exits non-zero having written nothing to either stream — which
    would have read as "no rollouts" rather than as "this CLI cannot answer". The
    probe lists a command group, so it needs no login, no project and no
    network, and the action is offered only when the subcommand is really there.
    A capability that could not be probed is reported as unknown rather than as
    absent, because the two have different remedies.

  • The board's quick-add line can draft. Return still saves the typed line
    exactly as before, and then asks the configured model for a title and a
    description to review. Two rules keep it honest, and neither is a tiebreak
    applied afterwards: the task is written from the typed markers first and the
    model proposes against the saved result, so markers always win structurally;
    and the model can only ever touch title and description, because that is what
    the enhancement field type admits — priority, labels, owner, type, repository
    and due date are out of reach by type rather than by policy. The sentence
    shown before you press Return and the request made after it are derived from
    one value, so the promise on screen cannot name a field the request does not
    carry. Off by default, and remembered: it changes what a keystroke does, so
    it is chosen once rather than every time, and a stored value this build
    cannot read falls back to manual — the mode that spends a model call must not
    be the one a failed decode selects.

  • Due is a control the sheet owns rather than the browser's datetime-local
    box. A trigger, a portaled month grid, and a word box that shares the board's
    quick-add grammar — type friday 09:30 and press Return, the same phrasing
    that works on the quick-add line, which is also what proves the grammar is
    wired rather than duplicated. taskDue owns only what was unowned: the
    grammar of a date word stays with parseQuickAddDue and how urgent a deadline
    is stays with dueState, because the board sorts and filters on that and the
    sheet must not answer it a second way. All of it is local time — a due date is
    a day in the reader's week, not an instant, so the sheet cannot show "Sep 18"
    for a timestamp the board files under the 19th.

  • The plugin package has a display name, and a mark where a client will actually
    render it. name is the install identifier, so the list showed gitpulse.
    The logo lives in .cursor-plugin/plugin.json and nowhere else: Cursor is the
    only one of the three hosts that renders a logo, Claude Code documents no
    field for one, and the Agent Plugins schema sets additionalProperties: false,
    so the same key in the portable manifest invalidates the package for a
    conformant client. check-release-version walks the Cursor manifest with the
    other three, so its version cannot drift from the release.

  • Firebase App Hosting backends and rollouts, read through the firebase CLI
    and joined to the commit graph. Build.source.codebase carries hash — a
    full SHA-1, the same key git cat-file speaks — so "is this commit live?"
    becomes a lookup rather than an inference, and presence is read from the
    command's own success flag rather than from is_ok(), because
    git_captured returns Ok on a non-zero exit: absence is data, not failure,
    and reading it the other way would have made every rollout look locally
    present.

    The two listings are click-only and wear Guarded<T> on purpose, and this is
    the part worth stating plainly: apphosting:backends:list and
    apphosting:rollouts:list both declare .before(ensureApiEnabled) upstream,
    so reading them enables the App Hosting API on the Cloud project. REST does
    not avoid it — App Hosting's only OAuth scope is cloud-platform, full
    read/write, with no read-only counterpart of the kind classic Hosting accepts.
    A read that mutates a billed resource must be something you choose, never
    something a panel does because it rendered. Passing --json also implies
    non-interactive, which turns a would-be enablement prompt into a loud error
    rather than a silent one. apphosting:rollouts:list exists only in
    firebase-tools' source and is documented nowhere, so its envelope is parsed
    strictly and fixture-pinned instead of trusted to keep its shape.

Changed

  • Writing a task and asking the model to help with it are one pane again.
    Splitting the sheet into four panes fixed a sheet where nothing read as
    important and introduced a worse problem: the AI pane's output was drawn on
    the Task pane, beside the fields each suggestion would replace, so a reader
    pressed a button on one pane and the result appeared on another. Writing a
    task, scheduling it and improving its wording are one sitting, so they are now
    one pane in two columns. Only Agent still earns a tab — handing a saved
    revision to something that will act on it is a different decision, with its
    own risk and its own run history. A draft is still never tabbed.

  • The suggestion picker can tell two attempts apart. Its rows read
    state · model plus Task revision N, which was the same string for two
    attempts by the same model at the same revision. A timestamp separates
    attempts made minutes apart and an ordinal separates attempts made in the
    same second; the ordinal is a reading aid derived from the page total, never
    an identity — the row's value is still the proposal id. State wording now
    comes from one table read by both the picker row and the heading it selects,
    which were previously the same literal list written twice.

  • extended_child_path leaves a PATH already at the execve argument-size
    ceiling alone instead of appending to it. Growing a near-limit environment
    turns a working spawn into E2BIG, surfacing as "Failed to spawn git:
    Argument list too long" with nothing naming the cause.

  • The vendored DevCouncil crates move to b366f42, taking the devmap store's
    schema from 20 to 22. A GitPulse built from the previous vendored tree
    refused the store its own repository already had — "schema version 22 is not
    supported by this binary (schema 20)" — and the code graph then answered
    "nothing" to every question, which reads exactly like a repository with no
    callers. dc-proc joins the vendored closure at the same time: it had never
    been vendored, but dc-verify and devmap-extract both took it as a hard
    dependency upstream, so a re-vendor without it fails to resolve rather than
    quietly building a stale tree.

  • Anchored popovers have one owner. Ten surfaces hand-rolled the same panel — a
    clamp call, a dismiss call, and an onMount block pairing addEventListener
    with removeEventListener for some subset of pointerdown / click /
    contextmenu / scroll / resize / keydown — and they had drifted into ten
    different answers to the same questions, none of them visible at the call
    site. The action attaches to the popover node itself, so listener lifetime
    becomes popover lifetime: the old blocks lived for the whole component behind
    if (!open) return guards, so a component could hold four window listeners
    while showing nothing, and a node inside {#if open} cannot. What stays at the
    call site is what genuinely differs there — the dismiss selector, because what
    counts as "inside" is a per-surface fact and most sites must count their own
    trigger or the opening pointerdown closes the panel; focus restoration; and
    keyboard ownership, since a menu with roving focus already owns keydown and
    Escape is one branch of it.

  • The task sheet's repository control names its links instead of counting them.
    The closed trigger draws a chip per linked repository, primary first and
    starred, with the summary sentence beneath for what chips cannot say — a
    remainder, or a link with no primary chosen. Its two rules moved out of the
    markup into taskRepositories, where they can be tested: a filter never hides
    a linked repository, because hiding a chosen row makes the control lie about
    what the task links to; and rows keep catalog order, because sorting linked
    rows to the top moves a row out from under the pointer that just checked it.

  • A task handed to an agent now says what to keep and what to ask with. The
    preamble prepended to every copy — draft or saved brief — is the whole
    prompt: a run started from the handoff form sends only the task identity to
    Manvi, which reads the rest from the store, and a clipboard copy lands in a
    session that has never seen this repository. So everything the two agent
    guides say about DevMap and GitPulse was unavailable on exactly the path
    where an agent is least oriented. It now names both — the gitpulse-insights
    and gitpulse-collisions skills with the gitpulse_* MCP tools, the five
    devmap skills with the devmap_* MCP tools, the absolute repo_path every
    call needs, and this repository's own rule that a query which was unavailable,
    truncated or empty is not evidence of absence. The skill names are asserted
    against the directories that ship them, so the list cannot fall behind the
    package. It also says the thing the board's own tests have always been
    written around: the author's wording is evidence, not a first draft to tidy.

  • Preserving the author's meaning moved from one on-device drafting kind to the
    block all three are built from. It sat only on improve, which left out the
    two kinds that need it most — draft writes the first version of a task from
    raw notes, and extract is under explicit instruction to leave material out,
    which is precisely where a model drops the error code the note was written to
    record. The contract asserts the structure rather than three strings: the
    kinds are derived from the Rust and TypeScript validators and cross-checked,
    and every branch may only add to the shared rules, so a fourth kind cannot
    reintroduce the gap.

  • The two @Guide annotations no longer contradict the instructions above
    them. A guide steers the structured decoder token by token while the
    instructions are only something the model read, so where they disagree the
    guide wins — and both of them disagreed. "At most 12 words" is a budget a
    title cannot always pay: a note whose whole point is an error code and a
    symbol name spends most of it on the evidence, so the model bought the word
    count by rewording the identifier. And "two to five sentences" is a floor,
    which on a thin note is an instruction to invent — it repealed "if the notes
    are thin, stay general rather than guessing" from the layer that is actually
    enforced. The title guide now states the bound that is really checked (300
    characters, in interpret and again in the store) and says to go longer
    rather than reword evidence; the description guide caps at five sentences
    with no floor.

  • The on-device prompt names the field that is not being written. Asked for a
    description with a title already present, the model would restate the title
    as the opening sentence; asked for a title, it would spend effort proposing a
    description the bridge then discarded. The clause appears only when that
    other field is actually in the prompt, because telling a model to leave a
    title alone when none was supplied asserts that one exists.

  • A repository or label list that had to be shortened says how many it left
    out. Eight of forty presented as a closed list is how a model comes to write
    "affects both repositories" about a task that spans five more.

  • The offer to widen a pre-repository approval moved above the branch list. It
    lived inside the worktrees panel, beside the rows it is about; adjacency read
    well and placed it badly. The sidebar body is one scroller and the branch list
    above it has no height cap, so on a repository with many branches the banner
    sat below the fold — and the refusal that sends people looking for it named a
    control they could not see. The refusal text now names the top of the sidebar,
    where the control actually is. Because the offer and the rows it affects are
    no longer one component, a grant made up there has to reach them:
    repoStore.refresh cannot carry it, since it re-hydrates under the existing
    generation and generation is precisely what panels compare to tell a stale
    response from a current one. The signal is its own store, and a counter rather
    than a flag — two extensions in one session are two events, and a boolean that
    has to be reset has a window in which it is wrong.

  • A worktree row gives its name a line of its own. The single-line row could not
    hold the name, the session chips, the branch and the action rail at once: the
    name was the only flex item among shrink-0 chips, a branch capped at 90px and
    a permanently reserved rail, so it was the only thing that could give — and it
    gave all of it. Measured at the 360px default,
    gitpulse-prompting-extraction-c8e628 rendered in 79px of the 210px it needs;
    at the 264px minimum it rendered in 0px and the branch painted straight
    through the change count. Line two carries the chips, the branch and the
    count, and the armed remove confirm moves there too — under the button that
    armed it, instead of splicing a 119px sentence into the name's line at the
    exact moment the reader needs to know which worktree is about to lose work.
    The right-edge mask is load-bearing rather than tidiness: the chips are
    shrink-0 and would otherwise paint through the row's edge, and the mask bites
    only when content actually reaches the last 14px, so a clipped row looks
    clipped instead of looking like a shorter branch name.

Fixed

  • The DORA change-failure rate and restore time no longer present a capped
    commit scan as the whole window. Both are derived from one git log that was
    fixed at -n 200, so on this repository the rate covered the newest 200 of
    532 commits in the default 90 days — 38% of the window — beside a card
    reporting release tags across all of it, and git log -n N returns the
    newest N, so the cut skewed both numbers recent. cfr_sample_commits could
    not surface this: it separates "measured" from "nothing to measure", but
    200 is a plain positive number whether it is the whole window or a slice of
    it. The cap bought nothing to justify the blind spot — measured over a
    25,000-commit history, the scan costs 0.01s capped and 0.07s uncapped,
    against the 0.32s the lead-time loop already spends on git describe — so it
    now matches MAX_PULSE_COMMITS at 25,000, and the report carries
    commit_scan_truncated with the window total behind it when the cap is still
    reached. Both cards say when they read only the newest part of the window,
    because one scan feeds them both. The uncapped count is only taken when the
    scan actually hits the cap, and a count that cannot be read leaves the
    truncation flag set rather than letting a partial scan report itself complete.

  • The DORA change-failure card no longer reports an empty window as 0%. An
    empty commit window, a repository with no commits, and a shallow clone all
    left the rate at 0.0 with nothing to divide, and the card rendered that
    beside three measured numbers — a check that could not run reading exactly
    like one that ran and found nothing wrong. The sibling restore-time card had
    always declined to invent a number here; the failure rate could not follow it,
    because neither of its two existing signals can say "nothing to measure":
    is_cfr_approximation is set unconditionally at both construction sites, so
    it only ever means "heuristic", and unlike a restore time, 0% is a
    legitimate answer when commits were examined and none were reverts. So the
    report now carries the denominator, cfr_sample_commits, and zero is the
    sentinel: the card shows — and says there were no commits in the window to
    examine. A measured zero still shows 0%, and now names the sample it was
    measured over rather than leaving the reader to assume the whole window was
    read.

  • The change-failure card's Heuristic pill was the literal word rather than
    the flag. is_cfr_approximation crossed the wire, was set by Rust, and was
    read by nobody, so a measured rate would have arrived still labelled a guess —
    and a stale label on a number is worse than no label, because it is believed.
    The pill and its caption now both follow the flag, as the restore-time card
    beside them always has.

  • A repository approved before 1.1.0 made the repository the unit of trust kept
    every one of its worktrees refused, with nothing anywhere offering to fix it.
    The approval still admitted the checkout it named, so the app reported the
    repository as trusted and never asked again; the only way to extend it was to
    open a linked worktree as a repository, which nothing suggested. Worktree
    comparisons, collision checks, and the fleet view quietly dropped every
    sibling — honestly, saying each time that they had not been read, but with no
    way out. Trust is now reported as a scope rather than a yes/no, the worktrees
    panel offers to extend an approval that predates worktree coverage, and the
    refusal a linked worktree returns says so instead of telling you to approve a
    repository you already approved. Approvals are still never widened by being
    read: extending is a decision you take, through the same dialog.

  • Installation health no longer invents a fault. GitPulse resolves devmap
    through PATH plus the GUI-launch fallback dirs, but spawned it with the
    PATH it inherited — under a Dock or Finder launch, launchd's
    /usr/bin:/bin:/usr/sbin:/sbin. devmap doctor resolves the bare devmap
    command that host MCP configs name against its own PATH, could not find the
    binary GitPulse had just resolved out of ~/.local/bin, and reported "host
    MCP config names a devmap path that is not a file: devmap — this is not
    version skew". Nothing was wrong with the install; the check could not run
    and named a cause it did not have. Every child that reaches the bounded
    runner now gets the extended PATH, so this is fixed for devmap, the
    tool-install probes and the curl/tar/cargo/go ladder at once — a
    caller that chose its own PATH still keeps it.

  • A warning devmap doctor raised could reach a panel that then said it "found
    nothing to report". The warning fields were written out by hand and devmap
    had six, so stray_state_warning was dropped. They are swept out of the
    payload now: unrecognised fields are shown last rather than discarded.

  • Warnings are bounded before they are rendered, and say so with both numbers
    when they are shortened or the list is capped. stale_server_warning
    enumerates one process id per running devmap mcp — 84 of them on the
    machine that reported this — into a single line.

  • npm run mcp:doctor no longer reports a clean pass over binaries running
    older code. gitpulse-mcp and gitpulse-hook report only their package
    version, which is a release identity: it does not move when a fix lands
    between releases, so the doctor compared 1.1.0 against 1.1.0 and said ok
    for a hook built the day before the repository-trust fix — while that hook
    went on handing users the pre-fix refusal. mcp:install now records a digest
    of the sources it compiled, and the doctor re-derives it as a third verdict.
    An install nobody recorded reports unverifiable rather than ok, because
    one that was never checked must not read like one that was checked and
    matched.

  • A refusal embedded in the collision-guard notice no longer brings its own
    full stop with it, which was rendering "…covers all of them.. Other
    worktrees may hold…" in every agent session inside an untrusted worktree.

  • The walkthrough no longer waits on a frame that may never arrive. Its
    spotlight was positioned from a measurement taken inside
    requestAnimationFrame, so on a host that had stopped painting — an occluded
    or minimised window, a background tab, a headless runner — the measurement
    never happened: no highlight was drawn, and the step said "The Open menu is
    not currently visible" about a control that was on screen. The measurement is
    now bounded, so it lands whether or not a frame does.

  • Hook payloads from Cursor are read and answered in Cursor's schema rather
    than Claude's, which had broken the session brief in both directions. Cursor
    native sessionStart sends workspace_roots and usually no cwd, so every
    Cursor session looked like a session with no repository and got no brief at
    all; and Cursor injects only a top-level additional_context, so a brief
    that was generated parsed as valid JSON and was then ignored. Cursor
    preToolUse is a permission hook whose schema is not Claude's either, and
    emitting Claude's nested permissionDecision there blocked the tool — it now
    emits nothing and fails open. The host is chosen by a non-null
    cursor_version, not by event-name casing: Claude plugins running on Cursor
    still send camelCase sessionStart. Claude's own output is unchanged.

  • An agent worktree's kind and slug come from one scan instead of two searches
    that could disagree. The kind was found by locating /.<name>/worktrees, the
    slug by independently finding the first /worktrees/ in the whole path — so
    any ancestor directory named worktrees captured it, and
    ~/worktrees/app/.claude/worktrees/session-abc reported its slug as app.
    Every session under such a parent collapsed into one label, which is the
    exact merging the slug exists to prevent. Two further shapes were misread on
    the way: .GIT/worktrees is git's own metadata store on the
    case-insensitive volumes macOS and Windows ship by default, and was reported
    as a real agent of kind GIT; and /repo/../worktrees/x reported an agent
    of kind .. Windows separators are matched in the same pass, so an agent
    worktree there is no longer labelled hand-made. Both implementations of the
    rule — the Work view's and the backend's — now answer to one shared corpus,
    so they cannot drift apart silently again.

  • An agent-session count taken from a capped sample no longer reads as an exact
    total. The worktree walk is bounded, and a kind whose only worktrees fell
    past the cap was missing from the list outright rather than undercounted. The
    cell now carries the bound with it.

  • The rule deciding whether a vendored manifest still inherits from a workspace
    has one owner. The vendor contract test carried its own copy, and when the
    copy in vendor-crates.mjs learned that a description is prose, the test's
    did not — so dc-proc, which describes itself as "shared by every place this
    workspace shells out", vendored cleanly and then failed the very contract
    that confirms it vendored cleanly.

  • The worktree trust refusal named a control the UI does not have. It said
    "Extend Trust" while the button read "Extend trust to every worktree" in the
    left sidebar's Worktrees section, so a reader handed those words found nothing
    on screen and concluded the control had been removed — one of them read the
    source to find a banner that was visible the whole time. Naming a control by
    the wrong name is worse than naming none. The message is also shorter: the
    sentence before it already says approving any working tree covers the family,
    so restating the cheap path said the same thing twice. A new contract test
    pins the string against the button's own label, because Rust owns the message
    and Svelte owns the button and nothing else compares them.

  • Choosing which suggested fields to accept no longer marks the task edited. The
    sheet marks dirty from any change inside its form, so the unsaved-edits guard
    was refusing the very acceptance the checkbox was selecting.

  • The platform-vocabulary guard reads a comment for its whole length, and
    resumes scanning the moment it ends. It decided comment-or-not one line at a
    time, and the house style indents a block comment's continuation lines as prose
    rather than gutter-marking them, so line two was read as template text and a
    platform named in the explanation was reported as output. 629 comment lines in
    the scanned trees read that way; the guard was green only because none of them
    happened to name a platform, and the first that did was reworded to appease the
    bug. The reverse half matters as much — <!-- why --> ⌘K really does print a
    glyph, and the old line test skipped that line whole. The one way comment
    tracking can fail silently is an opener it should not have believed, which
    skips the rest of the file as prose, so the scanner now reports whether it
    closed what it opened and every scanned file is asserted to do so: a blinded
    scanner must not read as a clean one.

  • A draft copy that had to cut an oversized description said so instead of
    ending mid-sentence. Notes are deliberately kept whole until the save
    boundary reports them, so an unsaved draft can carry more than a saved task's
    64 KiB — and the copy silently truncated to that cap, leaving an agent to
    answer as though it had read the rest. Same contract the model budgeters in
    ai/prompt.rs already follow: cut, and say so inside the text that was cut.

  • The Swift bridge bounds a generated title in the unit everything downstream
    counts in. String.prefix counts grapheme clusters; interpret and the
    store both count Unicode scalars, and those are not the same number — one
    family emoji is one grapheme and five scalars. So the cut was wrong in both
    directions: a 300-grapheme title could carry 1500 scalars and be refused by
    the store after the model had already run, while on plain ASCII it landed
    exactly on 300 and made that refusal unreachable, so an over-long title was
    silently clipped instead of caught. The cut now counts scalars and stops on a
    whole character, so it never severs a combining mark or a ZWJ sequence.

  • The assist button says which operation will actually run. The drafting kind
    and the button's verb were two separate readings of the same three fields, so
    they disagreed: with notes present the button read "Draft" while the request
    carried extract, and on an empty task it read "Improve" while the request
    carried draft. Neither expression was reachable from a test — both lived
    inside the component — so nothing caught it. The verb is now derived from the
    kind rather than re-read from the state, and both live in taskEnhance where
    they are covered.

  • The archive-separation plan names the commit its vendored store was re-synced
    to. The closure moved to 0944ef51 and the plan still named b366f42, so the
    plan's own drift guard was red. Its other five checks re-read the new source
    and still hold — work_items has no archived column, put_item accepts
    exactly its eighteen named fields, list_items filters on exactly six, and
    the schema version is unchanged — so only the reference had gone stale.

  • A task harness check asserted the button verb a bug used to produce, and so
    failed on both renderers. On a brand-new task — no notes, no title, no
    description — draftingKind returns draft and draftingVerb renders that
    as "Draft", but the check looked for button("Improve with Manvi"), and the
    harness matches button text exactly, so the lookup returned undefined.
    "On an empty task it read 'Improve' while the request carried draft" is the
    precise disagreement draftingKind/draftingVerb were extracted to end, so
    this line had been pinning the defect rather than the repair — it would have
    gone green again only if the bug came back. It now asserts "Draft with
    Manvi", which fails if the verb ever drifts off the kind again.

    Worth recording because of how it was nearly dismissed: it was carried in
    notes as a known pre-existing failure, on the strength of it also failing
    against a reverted baseline. That only established it predated one branch.
    The same check reads "pass":true in the 2026-09-14 CI log, so it was a
    regression with a cause, not a standing exception. "Fails on the baseline
    too" is not evidence that a failure is expected.

Verification

Built and installed on macOS (Darwin 27.0.0, aarch64) from main at 28e6f3a,
after three branches were merged into it: the App Hosting rollout work, the
empty-window change-failure fix, and the capped-commit-scan disclosure. The
tagged commit differs from that built tree only by this changelog entry.

  • npm run ci:local → exit 0, 15 passed · 0 failed · 0 skipped of 15 gates.
    Every number below comes from that single run.

    An earlier run of the same gates is not reported here, and the distinction
    matters more than the result did. Sources were edited while it was still
    running, so its later gates describe a tree that no longer exists; reporting
    them would have been the exact substitution this release is about — a check
    that could not run reading like one that ran and passed. It was discarded and
    the gates were re-run from a clean tree.

  • npm run check → svelte-check over 5087 files: 0 errors, 0 warnings, 0
    files with problems
    , then tsc --noEmit clean.

  • npm run coverage → 7100 passed, 0 failed across 504 test files.

  • Rust tests under cargo llvm-cov → 2584 passed, 0 failed, 19 ignored
    across 79 test binaries, summed from the individual test result: lines
    rather than read off a tail: this shell is zsh, where a piped tail launders
    both the counts and the exit status.

  • npm run test:browser:all and npm run test:webkit:all → 894 checks across
    13 harnesses on each engine, 1788 in total
    , headless Chrome 152 and
    WKWebView both green. The Firebase harness accounts for 85 of each engine's
    checks, up from 36, covering the rollout confirmation and the capability probe.

  • npm run check:coverage → floors hold: frontend lines 96.17%
    (12842/13354), frontend branches 89.47% (12554/14032), Rust lines
    85.36% (69057/80902).

  • npm run check:ipc → 224 handlers, 0 orphaned, 0 missing.
    npm run check:types → 67 contracts, 167 structs, 1168 fields, 0 drift.
    Both counts moved twice as branches landed and were read back from the
    checkers at each step rather than added up by hand; the documented-counts
    contract passes 13/13 over the four tracked docs.

  • npm run check:release → OK: all version sources agree on 1.2.0 across all
    ten manifests.

  • npm run tauri build → exit 0, first attempt, release profile in 1m 30s.
    GitPulse.app and GitPulse_1.2.0_aarch64.dmg (21,974,040 bytes), every
    nested helper (gitpulsed, gitpulse-mcp, gitpulse-hook) ad-hoc signed
    before the outer bundle.

    The install was verified rather than assumed: diff -r between the built
    bundle and /Applications/GitPulse.app reports them identical, and all four
    binaries match by SHA-256. codesign --verify --deep --strict on the
    installed copy: valid on disk, satisfies its Designated Requirement, all
    three helpers validated, designated cdhash a755a292ca1f8749…. Not notarized
    — no Apple credentials in the environment, as usual for a local build.

    hdiutil info was checked for an attached dmg.* volume before the build,
    not after one failed; only system simulator runtimes were mounted.

  • npm run mcp:install → gitpulse-mcp and gitpulse-hook replaced at 1.2.0,
    provenance recorded at source digest 36f4f49a2e01672c… over 676 files.
    npm run mcp:doctor → OK on all three claims.

    The digest is the whole reason this was caught. Before the reinstall, the
    doctor reported the version it wanted (1.2.0) and the store schema it wanted
    (22) on both sides and still failed, because the recorded digest was
    06abf2cc16fb5fed… over the same 676 files while the tree had moved to
    36f4f49a2e01672c…. Version is a release identity and does not move between
    releases, so two of the three verdicts would have passed a binary running
    pre-merge code.

  • Superseded build evidence was pruned to the single entry whose chunks match
    the shipped build, 39 MB down to 9.8 MB. Revision alone could not identify
    it: two builds of the same commit 28e6f3a produced different chunk hashes
    (main-1nI-zcB3.js and main-CJc8Sj5r.js), so an entry can carry the right
    revision and still not be the one that shipped. The surviving entry was
    matched two independent ways — dist/index.html, and the chunk name embedded
    in the installed binary itself, which agree on main-CJc8Sj5r.js.

  • Consolidation removed seven worktrees and three branches. Each branch was
    deleted with git branch -d, which refuses an unmerged branch, so the merge
    was confirmed by the tool rather than asserted; every worktree HEAD was
    separately checked to be an ancestor of main with zero unique commits, using
    a test carrying its own discrimination case so that an always-true check could
    not pass for one. About 57 GB was reclaimed: 38 GB of worktrees, 18.7 GB
    of target/debug and stale coverage instrumentation, and the pruned evidence.

What is not verified here. Notarization is absent, as above. Nothing was
pushed: main is ahead of origin/main and the tag is local, so the
CI-provenance precondition in release:ready — a successful push run of
ci.yml and coverage.yml on the tagged commit — is not satisfied, and no
draft was prepared. The App Hosting panel, including the new rollout action, was
exercised by its own harness and unit tests only; no live Firebase project was
contacted and no rollout was created, deliberately, because creating one changes
what production serves and both listings enable the API on the project they
read. docs/PROMO.md needed a handler-count correction that is not in any
commit: .gitignore excludes it, and its contract test reports skipped rather
than passed where the file is absent.

Changed — vendored crates

  • The vendored DevCouncil closure is re-synced to devcouncil@0944ef51, moving
    the ten dc-* and devmap-* crates from 0.2.2 to 0.2.3. vendor:check had
    been failing — every crate local: clean, upstream: drifted — because the
    record was pinned at b366f420 while that repository's main moved on; it
    now reports upstream: matches for all eleven and exits 0.
    check:vendor-schema confirms the vendored store schema is still 22, so this
    is not a migration: an existing store opens unchanged.

    Only the ten vendored packages moved in Cargo.lock, confirmed by name — a
    blanket 0.2.2 → 0.2.3 rewrite is the shape that has corrupted an unrelated
    dependency sharing that version before, and cargo resolved this rather than a
    text substitution.

  • The end-to-end half of the pre-repository-approval test asserts
    EXTEND_TRUST_CONTROL instead of its own copy of the button label. Spelled as
    a literal it passed while the message and the button disagreed, then failed on
    the commit that made them agree — the exact drift the constant exists to
    prevent. The unit test beside the constant already referenced it; this one was
    missed, and the suite rather than the diff is what found it.

Hardened — release automation

  • Remote release verification (release-state.mjs) is hardened against three
    failure points before deployment:
    • Line endings: notes round-trip comparison during finalize normalizes CRLF
      (\r\n) to LF (\n), preventing spurious refusals when GitHub's Releases
      API converts markdown newlines.
    • Asset digests: SHA-256 digest inspection accepts uppercase hexadecimal and
      normalizes to lowercase before snapshot comparison.
    • HTTP 404 handling: the API helper allows missing endpoints unconditionally
      when allowMissing is true, regardless of child process exit status.